深入理解 Command:MyMeetings 模块化单体仓库中的命令模式与 CQRS 实战
【免费下载链接】modular-monolith-with-dddFull Modular Monolith application with Domain-Driven Design approach.项目地址: https://gitcode.com/GitHub_Trending/mo/modular-monolith-with-ddd
Command(命令)是 CQRS 架构中"写"半边的基本载体:它表达系统用户的意图,驱动领域对象改变状态,并最终以领域事件的形式宣告结果。本文以 modular-monolith-with-ddd 仓库(MyMeetings 模块化单体应用)中的取消会议(Cancel Meeting)用例为骨架,完整拆解命令的定义、两种形态、命名规范、处理链路与拒绝回滚机制,并结合源码证明每一处结论。读完你将能在这套代码库中快速定位、读懂甚至仿写任意一条命令。
一、什么是 Command:定义与特征
Command 术语条目给出的定义是:
A command is a request made to do something. A command represents the intention of a system's user regarding what the system will do to change its state.
即:命令是一次"要做某事"的请求,它代表系统用户关于"系统将如何改变自身状态"的意图。它与查询(Query)相对:查询只读不改,命令只改必返回结果(成功或失败)。
该定义还给出了命令的三条关键特征:
- 命令的结果只能是成功或失败,结果以事件(Event)的形式呈现——也就是说,命令本身不"返回数据",它要么产生一个或多个领域事件,要么抛异常被拒绝,参见 Event 术语条目;
- 成功时,系统中必然发生了状态改变——如果什么都没变,说明这条命令"什么都没做",属于异常情况;
- 命令命名应使用动词(现在时或不定式)+ 来自领域的名词组(聚合或实体)——例如
CancelMeetingCommand、CreateMeetingCommand、BuySubscriptionCommand,一眼即可看出意图与作用对象。
这三条特征在本仓库的代码中都有严格体现,下面逐条验证。
二、命令的两种形态:应用层对象与聚合上的方法
原文档特别强调,在 MyMeetings 中命令存在两种互补的形态:
- 作为应用层的对象:即实现 Parameter Object(参数对象)模式的可序列化请求对象,携带完成操作所需的全部输入参数;
- 作为领域对象上的方法:在 DDD 中,命令最终落在聚合(Aggregate)上的一个公开方法,由该方法完成业务规则校验与状态变更,参见 Aggregate 术语条目。
以"取消会议"为例,两种形态的代码分别位于应用层与领域层。应用层形态是CancelMeetingCommand(源码),与文档示例完全一致:
public class CancelMeetingCommand : CommandBase { public CancelMeetingCommand(Guid meetingId) { MeetingId = meetingId; } public Guid MeetingId { get; } }领域层形态是Meeting聚合上的Cancel方法(源码):
public void Cancel(MemberId cancelMemberId) { this.CheckRule(new MeetingCannotBeChangedAfterStartRule(_term)); if (!_isCanceled) { _isCanceled = true; _cancelDate = SystemClock.Now; _cancelMemberId = cancelMemberId; this.AddDomainEvent(new MeetingCanceledDomainEvent(this.Id, _cancelMemberId, _cancelDate.Value)); } }注意命名规范:Cancel是不定式动词 + 领域名词Meeting;CancelMeetingCommand同样遵循"动词 + 领域名词 + Command"的约定。整个仓库内CreateMeeting、JoinToGroup、BuySubscription等命令均沿用同一套命名,便于在源码中按名称直接定位。
三、命令处理链路:从 Command 到 Handler 再到聚合
CancelMeetingCommand本身只是一个参数对象,真正驱动领域模型的是它的处理器(Handler)。文档给出了CancelMeetingCommandHandler,该类的完整实现位于 CancelMeetingCommandHandler.cs:
internal class CancelMeetingCommandHandler : ICommandHandler<CancelMeetingCommand> { private readonly IMeetingRepository _meetingRepository; private readonly IMemberContext _memberContext; internal CancelMeetingCommandHandler(IMeetingRepository meetingRepository, IMemberContext memberContext) { _meetingRepository = meetingRepository; _memberContext = memberContext; } public async Task Handle(CancelMeetingCommand request, CancellationToken cancellationToken) { var meeting = await _meetingRepository.GetByIdAsync(new MeetingId(request.MeetingId)); meeting.Cancel(_memberContext.MemberId); } }这条链路揭示了命令处理的标准三步曲,在 MyMeetings 的每个业务模块中反复出现:
- 解析输入:把命令对象中的原始
Guid包装为强类型 ID(new MeetingId(request.MeetingId)),这是 DDD 中防止"ID 混用"的典型做法; - 加载聚合:通过仓储接口(
IMeetingRepository.GetByIdAsync)把聚合从持久化中取出; - 调用领域方法:把命令语义交给聚合上的方法(
meeting.Cancel(...)),由聚合自行完成规则校验与状态变更。
注意 Handler 中并没有任何Save调用——持久化与事务由基础设施层的装饰器统一处理(见下文第五节)。同时可以观察到,Handler 依赖的IMeetingRepository与IMemberContext均通过构造函数注入,这类处理器的组装由各模块独立的 Autofac 容器完成(模块间通过 ADR-0016 每模块独立 IoC 容器 实现解耦)。
四、命令对象的底层契约:CommandBase 与 ICommand
所有命令对象并非孤立存在,它们都继承自模块内的CommandBase。以 Meetings 模块为例,CommandBase.cs 的实现如下:
public abstract class CommandBase : ICommand { public Guid Id { get; } protected CommandBase() { Id = Guid.NewGuid(); } protected CommandBase(Guid id) { Id = id; } }这里有两个值得注意的细节:
- 每个命令实例自动获得全局唯一的
Id:该 ID 用于在日志、Outbox、内部命令调度等场景中追踪同一条命令的完整生命周期; CommandBase同时提供无参与带参两种构造函数:带参版本允许在重放、定时任务等场景中显式指定命令 ID(例如 IRecurringCommand 相关实现)。
CommandBase继承自模块契约层定义的ICommand(ICommand.cs):
public interface ICommand<out TResult> : IRequest<TResult> { Guid Id { get; } } public interface ICommand : IRequest { Guid Id { get; } }该接口直接继承 MediatR 的IRequest,因此命令天然具备请求-响应语义,ICommandHandler<CancelMeetingCommand>实际就是 MediatR 的IRequestHandler。此外还有返回结果的泛型变体ICommand<TResult>,用于需要命令处理结果(如新实体 ID)的场景——这正是 ADR-0008 允许命令处理返回结果 的落地体现。
从项目层面看,命令/查询分离是 MyMeetings 架构决策的基石:ADR-0007 采用 CQRS 架构风格 明确要求"每个模块的 Facade 方法只接受 Command 或 Query 对象",因为命令作为普通对象可以方便地被序列化、保存与记录日志(这也是 Outbox/内部命令机制能够工作的前提)。
五、命令的拒绝与回滚:为什么失败不会留下脏状态
原文档强调的核心要点是:命令在状态改变之前可以被随时拒绝。这体现在两层防护上:
- Handler 层:如果
meetingId无效(GetByIdAsync找不到聚合),仓储会抛出异常,命令在此被拒绝; - 领域层:
Cancel方法开头的this.CheckRule(new MeetingCannotBeChangedAfterStartRule(_term))会校验业务规则——如果会议已经开始,就抛出BusinessRuleValidationException(定义于 src/BuildingBlocks/Domain/BusinessRuleValidationException.cs),命令同样被拒绝。
拒绝之后的回滚机制来自基础设施层的统一处理:Meetings 模块的命令在执行时会经过 UnitOfWorkCommandHandlerDecorator.cs 这类装饰器的包装(src/Modules/Meetings/Infrastructure/Configuration/Processing目录下的 22 个处理类即为完整的命令处理管线)。该装饰器在命令成功时才提交工作单元,一旦命令或领域规则抛异常,所有未提交的更改全部回滚,数据库不产生任何部分状态——这正对应原文档中"all uncommitted changes are rolled back (state does not change)"的说明。
需要强调的是,Cancel方法还做了一个幂等保护:if (!_isCanceled)保证重复调用不会重复改变状态、不会重复发布领域事件。这与命令特征第二条"成功时状态必须改变,否则等于什么都没做"形成呼应——已取消的会议再次调用Cancel属于"无效果调用",方法直接跳过。
六、命令的产物:领域事件(Domain Event)
命令成功的"结果"不是返回值,而是领域事件。Cancel在状态变更后调用:
this.AddDomainEvent(new MeetingCanceledDomainEvent(this.Id, _cancelMemberId, _cancelDate.Value));MeetingCanceledDomainEvent(源码)携带三个事实:被取消的会议 ID、执行取消的成员 ID、取消时间。它的构造函数:
public MeetingCanceledDomainEvent(MeetingId meetingId, MemberId cancelMemberId, DateTime cancelDate)领域事件会经由 DomainEventsDispatcher 在工作单元提交时被发布,进而触发模块内通知(Notification)或跨模块集成事件。这正是"命令的结果是事件"这一特征的具体实现:命令驱动状态改变,改变被固化为事件记录,事件再驱动下游反应(如发送邮件、更新其他模块的数据)。感兴趣可进一步阅读 Domain-Event 术语条目 与 Event 术语条目。
七、实战小结:如何在 MyMeetings 中读懂或新增一条命令
把以上内容浓缩成可操作的清单,无论阅读还是仿写命令都可参照:
- 命名:动词(现在时/不定式)+ 领域名词 +
Command后缀,如CancelMeetingCommand; - 载体:继承模块的
CommandBase,在构造函数中接收并固化所有输入参数,属性只读; - 处理:实现
ICommandHandler<TCommand>,注入所需仓储与上下文,加载聚合后调用其领域方法,不写任何业务逻辑到 Handler; - 规则:把校验放进聚合方法开头的
CheckRule中,失败即抛异常由管线回滚; - 结果:通过
AddDomainEvent发布领域事件,而不是返回"成功提示"; - 定位入口:模块的 Facade(如 IMeetingsModule)接收命令对象,通过 MediatR 分发到对应 Handler。
以 CancelMeeting 目录 为例,它只包含CancelMeetingCommand.cs与CancelMeetingCommandHandler.cs两个文件,是理解"命令 = 参数对象 + 处理器"最小闭环的最佳起点;而Meeting聚合上的Cancel方法(src/Modules/Meetings/Domain/Meetings/Meeting.cs#L278-L290)则是领域模型承载命令语义的标准示范。整套设计贯穿于 Administration、Meetings、Payments、Registrations、UserAccess 五个业务模块,构成了 MyMeetings 写操作的一致骨架。
【免费下载链接】modular-monolith-with-dddFull Modular Monolith application with Domain-Driven Design approach.项目地址: https://gitcode.com/GitHub_Trending/mo/modular-monolith-with-ddd
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考