跳转至

DTO、领域模型与 ViewModel 映射闭环

结论

仍然需要完整映射,但不应让一个通用自动映射器直接贯穿 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 注册:

services.AddTransient<
    IOrderEditorViewModelFactory,
    OrderEditorViewModelFactory>();

纯映射不要为了形式统一而全部注册进 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 明确表达一次应用用例。

五、读取列表与编辑页面分开处理

不是所有页面都需要加载完整领域聚合。

查询列表

API/SQL → OrderRowReadModel → 不可变行项目

如果列表行只显示数据且不需要编辑,可直接绑定具有只读属性的 OrderRowReadModel,甚至不需要继承 ObservableObject。这样会减少无意义的 DTO → Domain → ViewModel 往返。

编辑页面

OrderResponse → Order → OrderEditorViewModel
OrderEditorViewModel → UpdateOrderCommand → Order.Change(...)

编辑路径保留领域模型,因为保存时需要业务规则。

刷新已绑定的 ViewModel

优先创建新的 ViewModel 并替换页面状态。如果必须刷新同一个实例,提供显式 ApplySnapshot 方法,并明确决定哪些通知、验证和 Command 重算需要发生;不要使用通用 Mapper 向现有 ViewModel 批量灌值。

六、如果映射很多,如何保持效率

默认方式

  1. 用 record 主构造函数和不可变 DTO 降低样板代码。
  2. 让 Visual Studio/Copilot 生成 ToDomainToDto、Factory 和映射测试的初稿。
  3. 每个业务功能保留一个映射文件,不建立全局巨大映射配置。
  4. 使用参数化构造函数,让缺少必填字段直接成为编译错误。
  5. 对金额、时区、枚举、null、集合和 ID 写边界测试。

Mapster 的可选边界

如果存在大量纯数据契约,可采用混合方案:

Mapster:DTO ↔ Application ReadModel / 无行为数据模型
显式 Mapper:DTO ↔ 富领域模型
显式 Factory:Domain → ViewModel
Command:ViewModel → Domain

Mapster 官方支持映射到新对象、已有对象和查询投影;若启用,应设置 RequireExplicitMappingRequireDestinationMemberSource 并在测试或启动阶段调用 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 映射,而是把它们变成可编译、可调试、可测试,并且符合各层生命周期的显式映射。