Modular Monolith 中的 CQRS 实践:MyMeetings 模块化架构读写分离决策全解析
【免费下载链接】modular-monolith-with-dddFull Modular Monolith application with Domain-Driven Design approach.项目地址: https://gitcode.com/GitHub_Trending/mo/modular-monolith-with-ddd
MyMeetings(modular-monolith-with-ddd)是一个采用 Domain-Driven Design(DDD)方法的完整模块化单体(Modular Monolith)应用,由 Meetings、Administration、Payments、User Access 四个业务模块构成。本文围绕架构决策记录 0007-use-cqrs-architectural-style.md 展开,结合仓库源码与后续关联 ADR,系统讲解 MyMeetings 如何在每个业务模块内部落地 CQRS(Command Query Responsibility Segregation)架构风格。读完本文,你将掌握:CQRS 决策的完整背景与理由、模块门面(Façade)只接收 Command/Query 的契约设计、读模型两层架构与写模型 Clean Architecture 的源码级实现,以及"命令与查询可差异化处理、可序列化"两大架构红利在项目中的真实用法。
一、ADR-0007 决策背景:读写请求的两种天然形态
1.1 决策记录原文要义
ADR-0007 于 2019-07-01 记录、2019-11-04 归档,状态为Accepted(已接受)。其提出的核心问题非常朴素却直击本质:一个应用要处理两类请求——读取(reading)与写入(writing),而这两类请求对数据模型的需求是截然不同的:
- 对于读取:我们需要关系型(relational)形式的数据模型,以便以表格化/扁平化(tabular/flattened)的方式返回数据——例如表格、列表、字典。
- 对于写入:我们需要**对象图(graph of objects)**来完成更复杂的工作——例如校验(validations)、业务规则检查(business rules checks)、计算(calculations)。
这一观察与仓库源码完全吻合。以 Meetings 模块为例:
- 读取侧
GetAllCountriesQueryHandler返回的是扁平的List<CountryDto>,直接对应一张 SQL 视图; - 写入侧
ProposeMeetingGroupCommandHandler操作的是MeetingGroupProposal聚合根及其值对象MeetingGroupLocation,整个过程涉及业务规则校验与聚合内状态变更。
也就是说,一份模型不可能同时完美服务"扁平化快照读取"与"复杂对象图写入"两个目标,这是 CQRS 决策最根本的动机。
1.2 从"同源问题"看后续 ADR 的呼应
值得说明的是,这个 Context 并非孤立存在,它直接孵化出两条后续决策,形成完整的"读写分离"决策链条:
- 0009-use-2-layered-architectural-style-for-reads.md:决定读请求采用两层架构(API 层 + Application Service 层);
- 0010-use-clean-architecture-for-writes.md:决定写请求采用 Clean Architecture 四层架构(API、Application Service、Infrastructure、Domain)。
本 ADR 是这条链条的"总开关",后续两篇 ADR 均以"我们已采用 CQRS 风格(见 ADR #7)"作为前提展开。因此,理解本 ADR 是理解整个模块读写架构的入口。
二、决策内容:每个业务模块内部分离读写模型
2.1 决策原文核心
ADR-0007 的 Decision 部分明确了三点:
- 在每个业务模块内应用 CQRS 架构风格/模式——CQRS 不是系统级全局方案,而是"per module"的局部决策;
- 每个模块拥有独立的读模型与写模型(separate model for reading and writing);
- 采用最简单的 CQRS 实现:读模型是即时一致的(immediate consistent)——不引入事件溯源、不引入读写分离的独立存储,读写共用同一数据库。
最后一点尤其关键:MyMeetings 刻意选择了"CQRS 的最小可行形态"。即时一致意味着查询看到的永远是最近一次命令提交后的数据,没有异步投影带来的滞后窗口,实现成本最低、心智负担最小。这种克制与 0015-use-in-memory-events-bus.md 中"选择最简单方案,必要时再演进"的思路一脉相承。
2.2 "连简单模块也适用":User Access 的佐证
决策原文特别指出:"这种分离即使在像 User Access 这样的简单模块中也是有用的(This kind of separation is useful even in simple modules like User Access)。"
查看仓库源码可以验证这一点:User Access 模块同样拥有完整的读写契约体系——ICommand.cs(含ICommand与ICommand<TResult>)、IQuery.cs、ICommandHandler(src/Modules/UserAccess/Application/Configuration/Commands/ICommandHandler.cs)以及ICommandsScheduler。即使模块体量小、业务简单,依然按照 CQRS 的标准姿势搭建了读写分离的骨架,这印证了决策中"分离本身就有价值"的判断——它统一了所有模块的开发范式,让新加入的开发者面对任何模块都有相同的认知模型。
2.3 模块化的前提:4 个 Bounded Context
"每个业务模块"的"模块"划分来自更早的 0004-divide-the-system-into-4-modules.md:MyMeetings 域包含 4 个子域——Meetings(核心域)、Administration(支撑子域)、Payments(支撑子域)、User Access(通用域),并按 Bounded Context 1:1 映射为 4 个自治模块。CQRS 决策正是在这 4 个模块内部各自生效的。
三、CQRS 落地骨架之一:模块门面只接收 Command 或 Query
3.1 门面契约(Consequence 1 的落地)
ADR-0007 的 Consequences 第一条:"每个模块的门面方法只应接收 Command 或 Query 对象作为参数(Façade method of each module should take as parameter only Command or Query object)。"
这条后果直接呼应 0006-create-facade-between-api-and-business-module.md 中定义的门面接口。以 Meetings 模块为例,门面契约 IMeetingsModule.cs 只暴露 3 个方法:
public interface IMeetingsModule { Task<TResult> ExecuteCommandAsync<TResult>(ICommand<TResult> command); Task ExecuteCommandAsync(ICommand command); Task<TResult> ExecuteQueryAsync<TResult>(IQuery<TResult> query); }API 层只能通过这 3 个门面方法与业务模块通信,且参数类型被严格限定为ICommand/IQuery及其泛型变体——不允许出现"直接传 DTO、直接调领域方法"的旁路通道。这保证了模块封装性:API 看不见模块内部的领域模型、仓储、DbContext,模块内部实现如何演进(换 ORM、换存储)都不会波及 API 层。
其实现类 MeetingsModule.cs 展示了门面背后的调度逻辑:
public async Task<TResult> ExecuteQueryAsync<TResult>(IQuery<TResult> query) { using (var scope = MeetingsCompositionRoot.BeginLifetimeScope()) { var mediator = scope.Resolve<IMediator>(); return await mediator.Send(query); } }注意这里MeetingsCompositionRoot(Autofac 组合根,见 MeetingsCompositionRoot.cs)为每次调用开启独立 LifetimeScope,配合 ADR-0016"每模块独立 IoC 容器"的思想,实现请求级依赖隔离。
3.2 从 Controller 到门面的完整链路
以 Meetings 模块的会议组提议功能为例,MeetingGroupProposalsController.cs 是 CQRS 门面用法的典型样本:
[HttpGet("")] [HasPermission(MeetingsPermissions.GetMeetingGroupProposals)] public async Task<IActionResult> GetMemberMeetingGroupProposals() { var meetingGroupProposals = await _meetingsModule.ExecuteQueryAsync( new GetMemberMeetingGroupProposalsQuery()); return Ok(meetingGroupProposals); } [HttpPost("")] [HasPermission(MeetingsPermissions.ProposeMeetingGroup)] public async Task<IActionResult> ProposeMeetingGroup(ProposeMeetingGroupRequest request) { await _meetingsModule.ExecuteCommandAsync( new ProposeMeetingGroupCommand( request.Name, request.Description, request.LocationCity, request.LocationCountryCode)); return Ok(); }同一个 Controller 中,读请求走ExecuteQueryAsync、写请求走ExecuteCommandAsync,路径清晰分离;HasPermission特性(见 src/API/CompanyName.MyMeetings.API/Configuration/Authorization)则在门面之前完成基于权限的授权过滤。
四、CQRS 落地骨架之二:读写契约体系
4.1 ICommand / IQuery:基于 MediatR 的请求契约
所有 Command 与 Query 都继承自 MediatR 的IRequest,这意味着门面内部统一通过 MediatR 分发,命令/查询处理器即 MediatR 的IRequestHandler。以 Meetings 模块契约为例(ICommand.cs、IQuery.cs):
public interface ICommand<out TResult> : IRequest<TResult> { Guid Id { get; } } public interface ICommand : IRequest { Guid Id { get; } } public interface IQuery<out TResult> : IRequest<TResult> { }设计要点:
- Command 必须携带
Id(Guid),这是为命令可追溯、可调度、可持久化(见第六节)埋下的伏笔;Query 则没有 Id——查询是幂等、无副作用的,不需要被追踪。 ICommand<TResult>允许命令返回结果(如ProposeMeetingGroupCommand返回新聚合的Guid),这对应后续 [0008-allow-return-result-after-command-processing.md 决策思路](仓库中对应契约即ICommand<out TResult>,实际落地文件为 CommandBase.cs)。
4.2 CommandBase / QueryBase:统一行为基类
实际业务命令/查询继承自抽象基类(CommandBase.cs、QueryBase.cs):
public abstract class CommandBase<TResult> : ICommand<TResult> { protected CommandBase() { Id = Guid.NewGuid(); } protected CommandBase(Guid id) { Id = id; } // 支持反序列化/定时任务重放时指定 Id public Guid Id { get; } } public abstract class QueryBase<TResult> : IQuery<TResult> { public Guid Id { get; } protected QueryBase() { Id = Guid.NewGuid(); } protected QueryBase(Guid id) { Id = id; } }值得注意的细节:CommandBase/QueryBase都提供了"显式指定 Id"的受保护构造函数。这个看似不起眼的入口,实际服务于内部命令(Internal Command)调度——当 Quartz 定时任务从数据库恢复并重放一个已持久化的命令时,需要保持其原始 Id 以便回写处理状态(见第六节UnitOfWorkCommandHandlerDecorator对InternalCommandBase的判定逻辑)。
具体业务命令示例 ProposeMeetingGroupCommand.cs:
public class ProposeMeetingGroupCommand : CommandBase<Guid> { public ProposeMeetingGroupCommand(string name, string description, string locationCity, string locationCountryCode) { Name = name; Description = description; LocationCity = locationCity; LocationCountryCode = locationCountryCode; } public string Name { get; } public string Description { get; } public string LocationCity { get; } public string LocationCountryCode { get; } }命令对象是纯数据载体:只读属性 + 构造函数注入,无行为。这种"命令即数据"的设计是 CQRS + 可序列化(见第六节)能够成立的前提。
五、Consequence 2:读写模型各自优化(SRP 原则)的源码证据
ADR-0007 的 Consequences 第二条:"我们为写入和读取分别优化了模型(SRP 原则)。"这条原则在仓库中的落地方式,需要结合 ADR-0009 与 ADR-0010 才能真正看懂——读模型与写模型不仅概念分离,连架构层数、技术栈、代码路径都完全分离。
5.1 读模型:两层架构 + Dapper + SQL 视图
依据 0009-use-2-layered-architectural-style-for-reads.md,查询处理只有两层:API 层负责基于 HTTP 请求创建 Query,模块 Application 层负责处理 Query。其取舍直白:不抽象数据库、不做对象映射、查询几乎即时到达数据库,性能更好、方案简单易懂。
查询处理器直接依赖ISqlConnectionFactory(见 ISqlConnectionFactory.cs)并用 Dapper 执行原生 SQL。以 GetAllCountriesQueryHandler.cs 为例:
internal class GetAllCountriesQueryHandler : IQueryHandler<GetAllCountriesQuery, List<CountryDto>> { private readonly ISqlConnectionFactory _sqlConnectionFactory; public async Task<List<CountryDto>> Handle(GetAllCountriesQuery query, CancellationToken cancellationToken) { var connection = _sqlConnectionFactory.GetOpenConnection(); const string sql = $""" SELECT [Country].[Code] AS [{nameof(CountryDto.Code)}], [Country].[Name] AS [{nameof(CountryDto.Name)}] FROM [meetings].[v_Countries] AS [Country] """; return (await connection.QueryAsync<CountryDto>(sql)).AsList(); } }三个值得强调的实现事实:
- 查询直接打 SQL 视图:
FROM [meetings].[v_Countries]。视图定义在 v_Countries.sql,数据库层把底层表结构封装成面向查询的扁平化形态,这正是"读模型即 SQL 视图"的体现。 - 读模型数据库独立 schema:Meetings 模块的查询视图全部位于
meetingsschema 下,共 11 个(v_Countries、v_Meetings、v_MeetingDetails、v_MeetingAttendees、v_MeetingComments、v_MeetingGroupProposals、v_MeetingGroups、v_Members、v_MemberMeetings等,见 src/Database/CompanyName.MyMeetings.Database/Structure/meetings/Views),与写模型的物理表在结构层面就做了区隔。 - 跨模块同样成立:Payments 模块的 GetMeetingFeesQueryHandler.cs 也是同款姿势——
ISqlConnectionFactory+ Dapper +WHERE [MeetingFee].MeetingId = @MeetingId参数化查询,直接读[payments].[MeetingFees]表并映射为扁平 DTO。说明这套"读模型两层架构"是所有模块的统一惯例。
对应地,查询处理器接口 IQueryHandler.cs 只是一个标记性约束接口,没有额外装饰器——查询路径零横切关注点,直来直去。
5.2 写模型:Clean Architecture + DDD 聚合
依据 0010-use-clean-architecture-for-writes.md,命令处理需要 4 层:API → Application Service → Infrastructure → Domain。新增 Domain 层的理由是"领域逻辑会复杂,需要把它与基础设施、API 隔离,以支撑可测试性、可维护性与可读性"。
命令处理器依赖领域抽象(仓储接口、上下文接口),而非具体设施。以 ProposeMeetingGroupCommandHandler.cs 为例:
internal class ProposeMeetingGroupCommandHandler : ICommandHandler<ProposeMeetingGroupCommand, Guid> { private readonly IMeetingGroupProposalRepository _meetingGroupProposalRepository; private readonly IMemberContext _memberContext; public async Task<Guid> Handle(ProposeMeetingGroupCommand request, CancellationToken cancellationToken) { var meetingGroupProposal = MeetingGroupProposal.ProposeNew( request.Name, request.Description, MeetingGroupLocation.CreateNew(request.LocationCity, request.LocationCountryCode), _memberContext.MemberId); await _meetingGroupProposalRepository.AddAsync(meetingGroupProposal); return meetingGroupProposal.Id.Value; } }这条命令处理链路完整呈现了"Clean Architecture for writes"的形态:
- 处理器只依赖
IMeetingGroupProposalRepository(领域层接口)与IMemberContext(成员上下文抽象,见 IMemberContext.cs),不直接触碰 DbContext 或 SQL; - 业务动作通过聚合根的领域方法
MeetingGroupProposal.ProposeNew(...)触发,聚合内部执行业务规则检查、抛出BusinessRuleValidationException(见 BusinessRuleValidationException.cs)、产生领域事件; - 返回
meetingGroupProposal.Id.Value满足ICommand<TResult>的"命令返回结果"能力。
读模型没有领域层、写模型才有领域层——这正是"读写模型各自优化"最直接的架构证据:读模型薄、快、直连数据库;写模型厚、稳、承载全部业务复杂度。
六、Consequence 3:命令与查询的差异化处理机制
ADR-0007 的 Consequences 第三条:"我们可以用不同的方式处理 Command 和 Query。"
在 MyMeetings 中,这种"差异化"通过命令管线装饰器实现,而查询管线则刻意保持裸奔。命令分发入口 CommandsExecutor.cs 如下:
internal static async Task Execute(ICommand command) { using (var scope = MeetingsCompositionRoot.BeginLifetimeScope()) { var mediator = scope.Resolve<IMediator>(); await mediator.Send(command); } } internal static async Task<TResult> Execute<TResult>(ICommand<TResult> command) { using (var scope = MeetingsCompositionRoot.BeginLifetimeScope()) { var mediator = scope.Resolve<IMediator>(); return await mediator.Send(command); } }命令在 MediatR 管道上被注册了一组装饰器(decorator),其中两个可直接看到横切逻辑:
① 工作单元装饰器UnitOfWorkCommandHandlerDecorator.cs:命令执行成功后统一CommitAsync;若命令是InternalCommandBase(内部命令),还会同步把数据库内命令记录的ProcessedDate置为当前时间,形成"命令执行 + 内部命令状态回写 + 事务提交"的原子单元。
② 领域事件分发装饰器DomainEventsDispatcherNotificationHandlerDecorator.cs:命令触发的领域事件在命令边界内被收集并经IDomainEventsDispatcher分发,随后通过 Outbox 模式与 In-Memory Events Bus 传播为模块间集成事件(详见 0014-event-driven-communication-between-modules.md 与 0015-use-in-memory-events-bus.md)。
而在同一模块中,查询路径 ExecuteQueryAsync 直接mediator.Send(query),不挂任何装饰器——读操作不需要事务、不需要领域事件分发、不需要审计。写重、读轻,两者处理方式的差异在管线上得到了最直白的体现。
七、Consequence 4:Command/Query 对象化带来的可序列化能力
ADR-0007 的 Consequences 第四条:"由于 Command 或 Query 是对象,我们可以轻松地序列化它们并保存/记录它们。"这是本 ADR 最具前瞻性的一条后果,仓库中有两处直接证据。
7.1 内部命令调度:命令被序列化进数据库
Meetings 模块的 CommandsScheduler.cs 将命令序列化为 JSON 后写入InternalCommands表,供 Quartz 后台任务定时取回执行:
command.Id, EnqueueDate = DateTime.UtcNow, Type = command.GetType().FullName, Data = JsonConvert.SerializeObject(command, new JsonSerializerSettings { ContractResolver = new AllPropertiesContractResolver() })这里有两个关键实现细节:
Type = command.GetType().FullName:序列化时保存命令类型的完整名称,反序列化时据此还原具体命令类型;AllPropertiesContractResolver(见 AllPropertiesContractResolver.cs):自定义的契约解析器,确保包括只读属性在内的全部属性都能被序列化——这正是第四节中"命令是纯数据载体、只有只读属性"这一设计能落地的配套基础。
配合ICommandsScheduler接口(src/Modules/Meetings/Application/Configuration/Commands/ICommandsScheduler.cs)以及CommandBase(Guid id)受保护构造函数,"命令可持久化、可重放、可追溯"的定时任务机制得以成立。
7.2 Outbox 模式:命令/事件的序列化中转
同样的序列化思路也用于模块间通信。Outbox 消息(OutboxMessage.cs)以Type+Data形式保存集成事件;IntegrationEventGenericHandler.cs 在消费集成事件时同样使用JsonConvert.SerializeObject+AllPropertiesContractResolver组合。命令对象、集成事件对象因此成为"可序列化的统一消息载体"。
可以说,"命令/查询是对象"这一看似朴素的设计,实际上为整个项目的异步化、持久化、可观测性能力提供了最底层的数据结构支撑。
八、即时一致性读模型与边界澄清
最后有必要澄清一个容易混淆的边界,以准确理解本 ADR 的适用范围。
ADR-0007 明确选择"读模型即时一致"的最简实现,这意味着模块内部的读写关系是同步、即时的:命令提交成功后,同一个模块内的查询立刻能看到最新数据。这一点在模块内部的集成测试中大量使用——例如 Meetings 模块的集成测试(src/Modules/Meetings/Tests/IntegrationTests)与 Payments 模块的集成测试,均采用"执行命令 → 轮询/查询断言"的模式,其前提正是读模型的即时一致性。
而模块之间的通信则走事件驱动(0014-event-driven-communication-between-modules.md)与 In-Memory Events Bus(0015-use-in-memory-events-bus.md),是最终一致的。两类一致性的边界以"模块"为界:模块内即时一致(CQRS 最简实现),模块间最终一致(事件驱动)。理解这条边界,是阅读本项目其余 ADR 与源码的关键前提。
九、总结:一份 ADR 背后的完整架构拼图
ADR-0007 全文不过一页,但它撬动了 MyMeetings 架构中几乎所有的读写相关机制。回顾整个决策的价值链条:
| 决策要点 | 落地位置(仓库证据) |
|---|---|
| 每个业务模块应用 CQRS | 4 个模块各自维护独立的 Contracts、Application、Infrastructure 层 |
| 门面只接收 Command/Query | IMeetingsModule.cs + MeetingsModule.cs |
| 读写模型分离(SRP) | 读:两层 + Dapper + SQL 视图(GetAllCountriesQueryHandler.cs);写:Clean Architecture + DDD 聚合(ProposeMeetingGroupCommandHandler.cs) |
| 命令/查询差异化处理 | 命令挂工作单元、领域事件分发等装饰器(CommandsExecutor.cs),查询裸跑 |
| 命令/查询可序列化 | 内部命令调度(CommandsScheduler.cs)与 Outbox(OutboxMessage.cs) |
这份 ADR 也充分体现了 MyMeetings 项目"架构决策可追溯"的工程文化:每一条 Consequences 都能在 docs/architecture-decision-log 目录下的后续 ADR 与源码中找到对应实现。对于希望在自己的模块化单体或单体应用中引入 CQRS 的团队,本文梳理的"门面契约 + 读写契约体系 + 读两层/写四层 + 命令装饰器管线 + 命令可序列化"五个落地点,可以作为一套可直接对照的检查清单。
【免费下载链接】modular-monolith-with-dddFull Modular Monolith application with Domain-Driven Design approach.项目地址: https://gitcode.com/GitHub_Trending/mo/modular-monolith-with-ddd
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考