DTO、领域模型与 ViewModel 映射闭环¶
- 归档日期:2026-08-25
- 适用基线:
.NET 10 LTS + WPF + CommunityToolkit.Mvvm - 前置决策:WPF 项目用显式映射替代 Mapperly
结论¶
仍然需要完整映射,但不应让一个通用自动映射器直接贯穿 DTO、领域模型和 ViewModel。推荐使用“分段、强类型、按方向”的映射闭环:
服务端 / 数据库
│
▼
DTO
│ ContractMapper:ToDomain / ToDto
▼
Domain Model
│ ViewModelFactory:Create
▼
ViewModel
│ CreateCommand / CreateRequest
▼
Application Command
│ Handler 调用领域行为
▼
Domain Model
│ ToDto
▼
DTO
对应规则:
| 映射方向 | 推荐实现 | 原因 |
|---|---|---|
| DTO → Domain | 显式 ContractMapper,调用 Restore/Create |
保护领域不变量,不要求领域对象开放 setter |
| Domain → DTO | 普通扩展方法或 ContractMapper.ToDto |
通常是无副作用的数据投影 |
| Domain → ViewModel | ViewModelFactory 或 ViewModel 静态 Create |
构造期间可直接写 backing field |
| ViewModel → Domain | 先生成 Command/Request,再由 Handler 调用领域方法 | 表达用户意图,避免覆盖聚合和绕过规则 |
| DTO → ViewModel | 查询页面可直接使用专用 ReadModel;编辑页面仍建议先进入 Domain/Application Model | 避免把传输契约扩散到 Presentation |
| ViewModel → DTO | 仅在无领域行为的简单 CRUD 中允许;默认先转 Command | 防止 UI 决定领域状态 |
“显式映射”本身仍然是完整的映射方案,只是不再依赖隐藏约定。
一、建议的项目位置¶
Product.Contracts
└─ Orders
├─ OrderResponse.cs
└─ UpdateOrderRequest.cs
Product.Domain
└─ Orders
├─ Order.cs
└─ Money.cs
Product.Application
└─ Orders
├─ OrderContractMapper.cs
├─ UpdateOrderCommand.cs
└─ UpdateOrderHandler.cs
Product.Presentation
└─ Orders
├─ OrderEditorViewModel.cs
└─ OrderEditorViewModelFactory.cs
依赖方向:
Presentation → Application → Domain
Application → Contracts
Domain → 不依赖 Contracts、Presentation 或映射库
映射代码放在同时知道源类型和目标类型的外层,不能为了方便让 Domain 引用 DTO 或 ViewModel。Microsoft 的 DDD 指南同样强调领域层应独立于基础设施和表现层,实体行为负责保护业务规则与不变量。Microsoft:DDD 分层设计
二、DTO 与领域模型双向映射¶
DTO¶
public sealed record OrderResponse(
Guid Id,
string OrderNumber,
decimal Amount,
string Currency,
DateTimeOffset UpdatedAt);
领域模型¶
public sealed class Order
{
private Order(
Guid id,
string orderNumber,
Money total,
DateTimeOffset updatedAt)
{
Id = id;
OrderNumber = orderNumber;
Total = total;
UpdatedAt = updatedAt;
}
public Guid Id { get; }
public string OrderNumber { get; private set; }
public Money Total { get; private set; }
public DateTimeOffset UpdatedAt { get; private set; }
public static Order Restore(
Guid id,
string orderNumber,
Money total,
DateTimeOffset updatedAt)
{
ArgumentException.ThrowIfNullOrWhiteSpace(orderNumber);
return new Order(id, orderNumber, total, updatedAt);
}
public void Change(string orderNumber, Money total)
{
ArgumentException.ThrowIfNullOrWhiteSpace(orderNumber);
OrderNumber = orderNumber;
Total = total;
}
}
ContractMapper¶
public static class OrderContractMapper
{
public static Order ToDomain(this OrderResponse source)
{
ArgumentNullException.ThrowIfNull(source);
return Order.Restore(
source.Id,
source.OrderNumber,
Money.Create(source.Amount, source.Currency),
source.UpdatedAt);
}
public static OrderResponse ToDto(this Order source)
{
ArgumentNullException.ThrowIfNull(source);
return new OrderResponse(
source.Id,
source.OrderNumber,
source.Total.Amount,
source.Total.Currency,
source.UpdatedAt);
}
}
不要让映射代码直接修改领域对象的私有状态,也不要为了自动映射而增加公共无参构造函数或公共 setter。复杂领域模型应通过行为保护不变量;简单 CRUD 才可能适合贫血数据模型。Microsoft:设计领域模型
三、领域模型到 ViewModel¶
ViewModel¶
public sealed partial class OrderEditorViewModel : ObservableValidator
{
private readonly Guid orderId;
[ObservableProperty]
private string orderNumber = string.Empty;
[ObservableProperty]
private decimal amount;
[ObservableProperty]
private string currency = string.Empty;
internal OrderEditorViewModel(Order source)
{
ArgumentNullException.ThrowIfNull(source);
orderId = source.Id;
// 构造阶段直接初始化字段,不触发 setter、通知或 partial hook。
orderNumber = source.OrderNumber;
amount = source.Total.Amount;
currency = source.Total.Currency;
}
public UpdateOrderCommand CreateCommand()
=> new(
orderId,
OrderNumber.Trim(),
Amount,
Currency);
}
CommunityToolkit.Mvvm 的字段式 [ObservableProperty] 会生成调用 SetProperty 的公共属性,并支持变更 hook、验证和 Command 通知。构造阶段直接写字段,交互阶段通过生成属性,是这里刻意保留的语义边界。Microsoft:ObservableProperty 生成器
Factory¶
无额外依赖时,静态 Create 或直接构造即可;创建过程需要权限、格式化器或其他 Presentation 服务时,再使用 Factory:
public interface IOrderEditorViewModelFactory
{
OrderEditorViewModel Create(Order source);
}
public sealed class OrderEditorViewModelFactory
: IOrderEditorViewModelFactory
{
public OrderEditorViewModel Create(Order source) => new(source);
}
DI 注册:
纯映射不要为了形式统一而全部注册进 DI;静态无状态转换使用扩展方法更简单。
四、ViewModel 不直接反向覆盖领域模型¶
定义用户意图:
public sealed record UpdateOrderCommand(
Guid OrderId,
string OrderNumber,
decimal Amount,
string Currency);
Application Handler:
public sealed class UpdateOrderHandler(IOrderRepository repository)
{
public async Task HandleAsync(
UpdateOrderCommand command,
CancellationToken cancellationToken)
{
var order = await repository.GetAsync(
command.OrderId,
cancellationToken);
order.Change(
command.OrderNumber,
Money.Create(command.Amount, command.Currency));
await repository.SaveChangesAsync(cancellationToken);
}
}
这条路径比 viewModel.Adapt(order) 更明确:
- 用户可以修改哪些字段由 Command 决定。
- 领域对象的身份、集合和内部状态不会被整体覆盖。
- 验证和不变量仍由领域方法执行。
- Handler 明确表达一次应用用例。
五、读取列表与编辑页面分开处理¶
不是所有页面都需要加载完整领域聚合。
查询列表¶
如果列表行只显示数据且不需要编辑,可直接绑定具有只读属性的 OrderRowReadModel,甚至不需要继承 ObservableObject。这样会减少无意义的 DTO → Domain → ViewModel 往返。
编辑页面¶
OrderResponse → Order → OrderEditorViewModel
OrderEditorViewModel → UpdateOrderCommand → Order.Change(...)
编辑路径保留领域模型,因为保存时需要业务规则。
刷新已绑定的 ViewModel¶
优先创建新的 ViewModel 并替换页面状态。如果必须刷新同一个实例,提供显式 ApplySnapshot 方法,并明确决定哪些通知、验证和 Command 重算需要发生;不要使用通用 Mapper 向现有 ViewModel 批量灌值。
六、如果映射很多,如何保持效率¶
默认方式¶
- 用 record 主构造函数和不可变 DTO 降低样板代码。
- 让 Visual Studio/Copilot 生成
ToDomain、ToDto、Factory 和映射测试的初稿。 - 每个业务功能保留一个映射文件,不建立全局巨大映射配置。
- 使用参数化构造函数,让缺少必填字段直接成为编译错误。
- 对金额、时区、枚举、null、集合和 ID 写边界测试。
Mapster 的可选边界¶
如果存在大量纯数据契约,可采用混合方案:
Mapster:DTO ↔ Application ReadModel / 无行为数据模型
显式 Mapper:DTO ↔ 富领域模型
显式 Factory:Domain → ViewModel
Command:ViewModel → Domain
Mapster 官方支持映射到新对象、已有对象和查询投影;若启用,应设置 RequireExplicitMapping、RequireDestinationMemberSource 并在测试或启动阶段调用 Compile()。Mapster 官方项目;Mapster 配置验证
var config = new TypeAdapterConfig
{
RequireExplicitMapping = true,
RequireDestinationMemberSource = true,
};
config.NewConfig<OrderTransportDto, OrderReadModel>();
config.Compile();
禁止注册这些目标映射:
Mapster → Domain Aggregate
Mapster → ObservableObject / ObservableValidator
Mapster → 已绑定的 ViewModel 实例
七、测试闭环¶
ContractMapper 测试¶
[TestMethod]
public void ToDomain_PreservesBusinessFields()
{
var dto = new OrderResponse(
Guid.NewGuid(), "SO-1001", 99.50m, "CNY",
DateTimeOffset.Parse("2026-08-25T10:00:00+08:00"));
var domain = dto.ToDomain();
Assert.AreEqual(dto.Id, domain.Id);
Assert.AreEqual("SO-1001", domain.OrderNumber);
Assert.AreEqual(99.50m, domain.Total.Amount);
Assert.AreEqual("CNY", domain.Total.Currency);
}
至少覆盖:
- DTO → Domain 的合法值和非法值。
- Domain → DTO 的精度、时区和枚举表达。
- Domain → ViewModel 的初始字段。
- ViewModel → Command 的用户修改结果。
- Handler 是否调用正确的领域行为。
- Mapster 可选配置是否能
Compile()。
不要盲目要求所有映射 round-trip 后逐字节相等:领域模型可能做规范化、舍入或默认值填充,应断言业务语义。
八、最终选型¶
必须采用
├─ OrderContractMapper:DTO ⇄ Domain
├─ OrderEditorViewModelFactory:Domain → ViewModel
├─ UpdateOrderCommand:ViewModel → Application
├─ Domain method:Command → Domain 状态变化
└─ 每个边界的关键字段测试
提升效率
├─ record / 参数化构造函数
├─ Visual Studio/Copilot 生成普通 C# 草稿
└─ Feature 级 Mappings 文件夹
严格可选
└─ Mapster:仅纯数据 DTO ⇄ ReadModel,不进入 Domain Aggregate 或 ViewModel
这套方案并没有取消 DTO、领域模型和 ViewModel 映射,而是把它们变成可编译、可调试、可测试,并且符合各层生命周期的显式映射。