领域驱动设计(五):CQRS——读写分离与领域模型

写在前面

前三篇把"写"这一侧讲透了——实体、值对象、聚合、领域事件,都是围绕"怎么正确地变更业务状态"。但真实系统里,的负载往往完全不对称:写要严谨一致,读要快、要灵活、要多形态。用同一个模型硬扛两边,两头不讨好。

这就是 CQRS(Command Query Responsibility Segregation,命令查询职责分离) 要解决的。这篇从 DDD 的视角讲 CQRS——它和领域事件、事件溯源是天然搭档。

这篇和微服务系列第五篇是互补关系:那篇从"数据一致性"角度讲 CQRS 解决最终一致;这篇从"领域建模"角度讲 CQRS 怎么把读写模型分开。


一、CQRS 的本质

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
传统:一个模型既读又写(CRUD 一把梭)
  Order 实体 + OrderRepository + OrdersController
  → 写要校验、要维护不变量、要走聚合根
  → 读要 JOIN、要排序、要返回 DTO、要支持各种查询
  → 一个模型被两种截然不同的需求拉扯,越来越臃肿

CQRS:把"写"和"读"拆成两套模型

  写侧(Command)          读侧(Query)
  ────────────────         ────────────────
  命令处理                  查询服务
  领域模型(聚合)           读模型(DTO/物化视图)
  仓储                      只读数据源
  强一致、校验、规则          快、灵活、可反规范化
1
2
3
4
一句话:CQRS 不是技术,是"承认读写不一样,别硬凑一起"的设计观。

  来源:Bertrand Meyer 的 CQS 原则(命令查询分离:方法要么改状态,要么返回值,不兼得)
  CQRS 把它从"方法级"放大到"架构级"——整个系统的读写分开。

二、为什么要分离

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
写(Command)的真实诉求:
  ✓ 严格校验业务规则(不能超卖、余额够不够)
  ✓ 维护不变量(聚合一致性)
  ✓ 强一致(一个事务一个聚合)
  ✓ 领域模型要"对",要可维护
  → 频率通常不高(用户操作)

读(Query)的真实诉求:
  ✓ 快(缓存、索引、反规范化)
  ✓ 灵活(各种筛选/排序/聚合查询)
  ✓ 多形态(列表页/详情页/报表,各有 DTO)
  ✓ 高并发(一次列表查 N 条,QPS 远高于写)
  → 频率通常很高(浏览、搜索)

  一个模型同时满足"严格校验"和"高速查询",本质矛盾。
  分开后,各自优化到极致。

三、写侧:领域模型

1
2
3
4
5
6
7
8
写侧 = DDD 的主战场(前三篇的内容)

  入口:命令(PlaceOrderCommand、CancelOrderCommand)
  处理:应用服务 → 加载聚合 → 调聚合方法 → 保存 → 发事件
  模型:聚合根(带完整不变量校验)
  存储:仓储,按聚合根整体存取

  写侧不关心"页面要显示什么",只关心"变更是否合法"。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 写侧:命令处理(领域模型驱动)
public sealed record CancelOrderCommand(OrderId OrderId, string Reason);

public class OrderCommandHandler
{
    private readonly IOrderRepository _repo;
    private readonly IUnitOfWork _uow;
    private readonly IDomainEventDispatcher _events;

    public async Task Handle(CancelOrderCommand cmd, CancellationToken ct)
    {
        var order = await _repo.Find(cmd.OrderId, ct) ?? throw new NotFoundException();
        order.Cancel(cmd.Reason);                  // 聚合内校验 + 收集事件
        await _repo.Save(order, ct);
        await _uow.SaveChangesAsync(ct);
        await _events.DispatchAsync(order.DequeueEvents(), ct);  // 通知读侧更新
    }
}

四、读侧:读模型

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
读侧 = 为"查询"优化的模型,不管业务规则

  特征:
    - 反规范化(denormalized):把需要的字段拍平,免 JOIN
    - 直接返回 DTO,不经过领域模型
    - 可以用完全不同的存储(关系库宽表 / Redis / Elasticsearch)
    - 不校验业务规则(读而已)

  一个"订单列表"读模型可能是:
    OrderListItemDto { OrderId, CustomerName, Total, Status, PlacedOn }
    → 已经 JOIN 好客户名,列表查询一次搞定
  而"订单详情"又是另一个读模型,各取所需。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 读侧:直接查、直接返 DTO,不碰领域模型
public class OrderQueryService
{
    private readonly IDbConnection _db;   // Dapper 直接查读库

    public Task<IReadOnlyList<OrderListItemDto>> ListByCustomer(CustomerId cust, CancellationToken ct)
        => _db.QueryAsync<OrderListItemDto>(@"
            SELECT o.id AS OrderId, c.name AS CustomerName,
                   o.total AS Total, o.status AS Status, o.placed_on AS PlacedOn
            FROM order_list_view o JOIN customers c ON c.id = o.customer_id
            WHERE o.customer_id = @cust", new { cust });

    // 详情、报表……各有各的读模型,互不干扰
}

public sealed record OrderListItemDto(
    Guid OrderId, string CustomerName, decimal Total, string Status, DateTime PlacedOn);
1
2
3
4
5
读侧自由度极高:
  - 不走聚合根(读不修改,没必要)
  - 不走仓储(仓储是为"整体存取聚合"设计的,读不需要)
  - 可以直接 SQL / Dapper / 查 ES / 读 Redis
  → 读侧就是"怎么快怎么来",领域规则是写侧的事。

五、数据同步:读模型怎么跟写侧一致

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
写侧一更新,读侧怎么同步?用领域事件投影。

  写侧 Order 状态变了 → 发 OrderCancelled 事件
  → 读侧投影器(Projector)订阅 → 更新对应的读模型表
  → 读侧最终一致

  读模型表(投影):
    order_list_view        ← 订单列表用
    order_detail_view      ← 订单详情用
    customer_orders_view   ← 按客户聚合的视图
    每个视图由各自的事件投影维护。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 读侧投影器:监听领域事件,更新读模型
public class OrderListProjector
{
    private readonly IDbConnection _db;
    public async Task On(OrderPlaced e, CancellationToken ct)
    {
        await _db.ExecuteAsync(@"
            INSERT INTO order_list_view (id, customer_id, total, status, placed_on)
            VALUES (@OrderId, @CustomerId, @Total, 'Placed', @OccurredOn)", e);
    }
    public async Task On(OrderCancelled e, CancellationToken ct)
    {
        await _db.ExecuteAsync(
            "UPDATE order_list_view SET status='Cancelled' WHERE id=@OrderId", e);
    }
}
1
2
3
4
5
所以前面四篇是层层铺垫:
  实体/值对象/聚合 → 写侧的领域模型
  领域事件         → 写侧通知读侧的渠道
  CQRS             → 读写两侧正式分家,事件是同步纽带
  这就是 DDD 的完整闭环。

六、CQRS + 事件溯源

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
CQRS 和事件溯源是黄金搭档(但可独立使用):

  事件溯源(第四篇):写侧存"事件流",状态由重放得到
  CQRS(本篇):读写分离

  合体:
    写侧:命令 → 聚合 → 存事件流(Event Store)
    读侧:事件 → 投影 → 读模型(Materialized View)

  ┌─────────┐  事件   ┌───────────┐  投影  ┌──────────┐
  │ 写侧    │ ──────→ │ 事件存储   │ ─────→ │ 读模型    │ ← 查询
  │ (聚合)  │         │ (Event    │        │ (宽表/ES)│
  └─────────┘         │  Store)   │        └──────────┘
                      └───────────┘

  价值:写侧有完整审计(事件流),读侧极致优化(投影)
       两者解耦,各自演进。

  代价:复杂。只有读多写少 + 强审计需求才值得。
       普通 CRUD,单独用 CQRS 都未必需要,更别说加事件溯源。

七、什么时候用 CQRS(和什么时候别用)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
值得用 CQRS:
  ✓ 读写负载严重不对称(读 >> 写)
  ✓ 查询复杂、多形态(列表/详情/报表差异大)
  ✓ 需要独立扩容读侧(读加缓存、加副本,写保持单库)
  ✓ 已用领域事件,顺势做投影(边际成本低)

不要用 CQRS:
  ✗ 简单 CRUD(读写都很简单,分离只增复杂度)
  ✗ 读和写模型差异不大
  ✗ 团队没准备好维护"两套模型 + 同步"

  铁律:CQRS 是用"复杂度"换"读写各自优化"。
       只有读写矛盾真的痛,才划得来。
1
2
3
4
5
6
7
渐进式落地:
  1. 先把写侧领域模型建好(前三篇)
  2. 读侧仍用同一套库,但用独立查询服务 + DTO(轻量 CQRS)
  3. 哪个读场景扛不住了,单独给它建投影读模型
  4. 读量真的大,再引入独立读存储(Redis/ES)

  不要一上来就全套 CQRS + 事件溯源,按痛点逐步推进。

八、小结

  • CQRS 本质:承认读写需求不同,命令(写)和查询(读)分家
  • 写侧:命令 → 领域模型(聚合),严格校验、强一致(前三篇内容)
  • 读侧:为查询优化的读模型(反规范化 DTO),不经领域模型,怎么快怎么来
  • 同步纽带:领域事件投影——写侧发事件,读侧投影更新读模型(最终一致)
  • CQRS + 事件溯源:黄金搭档但复杂,按需组合
  • 何时用:读写严重不对称 / 查询复杂多形态;简单 CRUD 别用
  • 渐进落地:先领域模型,再独立查询服务,再按痛点加投影,最后才上独立读存储
  • 闭环:实体/值对象/聚合(写模型)+ 领域事件(纽带)+ CQRS(读写分家)= DDD 完整架构

最后一篇,把这些落到工程结构——整洁架构与 .NET 项目分层。