ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

如何按 Modular Monolith with DDD 的模块结构新建限界上下文模块并只通过集成事件与其他模块通信

如何按 Modular Monolith with DDD 的模块结构新建限界上下文模块并只通过集成事件与其他模块通信 如何按 Modular Monolith with DDD 的模块结构新建限界上下文模块并只通过集成事件与其他模块通信【免费下载链接】modular-monolith-with-dddFull Modular Monolith application with Domain-Driven Design approach.项目地址: https://gitcode.com/GitHub_Trending/mo/modular-monolith-with-ddd如果你在这个 .NET 8.0 模块化单体项目中要新增一个限界上下文例如新的子域模块需要完成两件事按项目既定的四程序集结构搭建模块并让新模块与其他模块之间只通过集成事件Integration Events和内存事件总线通信禁止直接方法调用。本文以现有的Meetings模块为模板给出从创建程序集、实现模块对外契约、实现 Startup 与组合根到在 API 宿主中注册、最后用架构测试和集成测试验证模块边界的完整路径。前提环境已安装 .NET 8.0 SDK、MS SQL Server Express并已完成 README 中 How to Run 一节的数据库创建与迁移项目可以按现有四个模块正常构建。新模块必须满足的结构约束在动手前先明确 README High Level View 一节和 架构决策记录 0004、0014 中固定的边界约束新模块必须逐条满足模块间通信只允许异步事件In Memory Events Bus 做 Publish/Subscribe禁止模块之间直接方法调用README 关键假设第 4 条每个模块的数据存放在独立 schema不允许共享数据也不存在跨模块事务README 关键假设第 5、3.7 节模块对其他模块的依赖只能指向对方的 IntegrationEvents 程序集README 关键假设第 6 条每个模块有自己的组合根Composition Root即独立的 Autofac 容器API 作为宿主负责初始化每个模块每个模块必须提供一个初始化入口模块高度封装——只有需要的类型和成员是public其余为internal或private。模块内部的程序集划分Clean Architecture见下节。决策记录 0014 还提醒一点集成事件成为各限界上下文的发布语言事件结构应尽量保持稳定。按 Meetings 模块创建四个程序集每个模块由 4 个程序集组成README Module Level View 一节程序集职责Application请求处理的应用逻辑用例Command/Query 处理器、领域事件通知、集成事件处理器、内部命令DomainDDD 领域模型实现该限界上下文Infrastructure模块初始化、后台处理、数据访问、事件总线通信等基础设施代码IntegrationEvents发布到事件总线的契约集成事件。只有这个程序集可以被其他模块引用以 Meetings 模块 为参照目录结构为src/Modules/Meetings/{Application,Domain,Infrastructure,IntegrationEvents,Tests}。新建模块时按同样的命名和目录形态创建程序集例如src/Modules/MyBoundedContext/并把新程序集加入解决方案 CompanyName.MyMeetings.sln——CI 构建NUKE 的DotNetBuild作用于Solution依赖它们已纳入解决方案。README 中有一个务实的说明Application、Domain、Infrastructure 三个程序集可以合并成一个程序集README 建议be pragmatic按你的限界上下文复杂度决定但 IntegrationEvents 作为唯一的对外契约程序集必须独立存在。集成事件就是一个继承IntegrationEvent的契约类。MeetingGroupProposedIntegrationEvent.cs 展示了事件的标准形态——只携带其他模块需要的数据不带领域模型public class MeetingGroupProposedIntegrationEvent : IntegrationEvent { public Guid MeetingGroupProposalId { get; } public string Name { get; } public string Description { get; } public string LocationCity { get; } public string LocationCountryCode { get; } public Guid ProposalUserId { get; } public DateTime ProposalDate { get; } public MeetingGroupProposedIntegrationEvent( Guid id, DateTime occurredOn, Guid meetingGroupProposalId, string name, string description, string locationCity, string locationCountryCode, Guid proposalUserId, DateTime proposalDate) : base(id, occurredOn) { // 属性赋值 } }实现模块对外契约模块接口与模块类README API and Module Communication 一节规定每个模块向 API 暴露的接口签名相同包含三个方法带结果的命令、不带结果的命令、查询public interface IMeetingsModule { TaskTResult ExecuteCommandAsyncTResult(ICommandTResult command); Task ExecuteCommandAsync(ICommand command); TaskTResult ExecuteQueryAsyncTResult(IQueryTResult query); }新模块在Application/Contracts目录下定义自己的ICommand、IQuery、CommandBase、QueryBase契约Meetings 模块的 Contracts 目录 是可参照的完整示例以及形如I{YourModule}Module的接口。模块类实现该接口命令走CommandsExecutor查询则从模块组合根开启生命周期作用域并解析 MediatRIMediator。MeetingsModule.cs 是完整实现public class MeetingsModule : IMeetingsModule { public async TaskTResult ExecuteCommandAsyncTResult(ICommandTResult command) { return await CommandsExecutor.Execute(command); } public async Task ExecuteCommandAsync(ICommand command) { await CommandsExecutor.Execute(command); } public async TaskTResult ExecuteQueryAsyncTResult(IQueryTResult query) { using (var scope MeetingsCompositionRoot.BeginLifetimeScope()) { var mediator scope.ResolveIMediator(); return await mediator.Send(query); } } }把Meetings/MeetingsCompositionRoot换成你的新模块名即可保持同一形态。查询用 Dapper 执行原始 SQL、命令用 CQRS DDD 战术模式处理这是项目已固定的读写分离方式README 3.4 节。实现模块的 Startup 与组合根模块初始化入口是 Infrastructure 层的一个静态Initialize方法。MeetingsStartup.cs 展示了它要做的事构建独立的 Autofac 容器、注册各配置模块、把容器设为该模块的组合根再启动 Quartz 后台处理和事件总线订阅。public static void Initialize( string connectionString, IExecutionContextAccessor executionContextAccessor, ILogger logger, EmailsConfiguration emailsConfiguration, IEventsBus eventsBus, long? internalProcessingPoolingInterval null) { var moduleLogger logger.ForContext(Module, Meetings); ConfigureCompositionRoot( connectionString, executionContextAccessor, moduleLogger, emailsConfiguration, eventsBus); QuartzStartup.Initialize(moduleLogger, internalProcessingPoolingInterval); EventsBusStartup.Initialize(moduleLogger); }ConfigureCompositionRoot内的注册清单摘自同一文件决定新模块 Startup 需要复制的模块集var containerBuilder new ContainerBuilder(); containerBuilder.RegisterModule(new LoggingModule(logger.ForContext(Module, Meetings))); containerBuilder.RegisterModule(new DataAccessModule(connectionString, loggerFactory)); containerBuilder.RegisterModule(new ProcessingModule()); containerBuilder.RegisterModule(new EventsBusModule(eventsBus)); containerBuilder.RegisterModule(new MediatorModule()); containerBuilder.RegisterModule(new AuthenticationModule()); var domainNotificationsMap new BiDictionarystring, Type(); domainNotificationsMap.Add(MeetingGroupProposalAcceptedNotification, typeof(MeetingGroupProposalAcceptedNotification)); domainNotificationsMap.Add(MeetingGroupProposedNotification, typeof(MeetingGroupProposedNotification)); // ... 本模块其余领域事件通知 containerBuilder.RegisterModule(new OutboxModule(domainNotificationsMap)); containerBuilder.RegisterModule(new EmailModule(emailsConfiguration)); containerBuilder.RegisterModule(new QuartzModule()); containerBuilder.RegisterInstance(executionContextAccessor); _container containerBuilder.Build(); MeetingsCompositionRoot.SetContainer(_container);新模块按相同模式实现自己的MyBoundedContextStartupdomainNotificationsMap里注册本模块领域事件通知 → 通知类型的映射这是把领域事件经 Outbox 发出去的前提下一节展开最后把构建好的容器交给模块自己的CompositionRoot。在 API 宿主中注册新模块API 项目是唯一知道所有模块的地方架构决策 0004 的后果之一The API/GUI layer needs to know about all of the modules。注册分两步都在 Startup.cs第一步在ConfigureContainer中注册模块的 Autofac 模块public void ConfigureContainer(ContainerBuilder containerBuilder) { containerBuilder.RegisterModule(new MeetingsAutofacModule()); containerBuilder.RegisterModule(new AdministrationAutofacModule()); containerBuilder.RegisterModule(new UserAccessAutofacModule()); containerBuilder.RegisterModule(new PaymentsAutofacModule()); // 新模块在这里追加containerBuilder.RegisterModule(new MyBoundedContextAutofacModule()); }第二步在InitializeModules中调用新模块的Startup.Initialize参数与其他模块保持一致连接串、executionContextAccessor、logger以及按需传入的EmailsConfiguration等private void InitializeModules(ILifetimeScope container) { var httpContextAccessor container.ResolveIHttpContextAccessor(); var executionContextAccessor new ExecutionContextAccessor(httpContextAccessor); var emailsConfiguration new EmailsConfiguration(_configuration[EmailsConfiguration:FromEmail]); MeetingsStartup.Initialize( _configuration.GetConnectionString(MeetingsConnectionString), executionContextAccessor, _logger, emailsConfiguration, null); // 新模块在这里追加 // MyBoundedContextStartup.Initialize( // _configuration.GetConnectionString(MeetingsConnectionString), // executionContextAccessor, // _logger, // emailsConfiguration, // null); }连接串来自 API 根配置中的MeetingsConnectionStringREADME How to Run 一节的配置说明。只通过集成事件与其他模块通信模块间通信链路是固定实现的README 3.7 Modules Integration 一节领域事件 → Outbox 表 → Quartz 后台 worker 发布到事件总线 → 其他模块的 Inbox 表 → 触发内部命令。Outbox/Inbox 用两张 SQL 表和每模块一个后台 worker 实现提供 At-Least-Once 的投递与处理保证发布事件新模块 → 其他模块命令处理提交时领域事件经OutboxModule中注册的domainNotificationsMap映射为集成事件写入 Outbox 表Quartz 后台 worker 轮询 Outbox 并通过事件总线发布。新模块只需在上一节的domainNotificationsMap中登记自己的通知类型。订阅事件其他模块 → 新模块在Application程序集中编写集成事件处理器类名以IntegrationEventHandler结尾、实现 MediatR 的INotificationHandler处理来自对方模块 IntegrationEvents 程序集的事件这是架构测试允许的跨模块依赖形态。MeetingGroupProposalAcceptedIntegrationEventHandler.cs 是 Meetings 消费 Administration 模块事件的现成示例。Inbox 收到事件后以内部命令继承InternalCommandBase的方式触发本模块的命令处理器使事件处理同样落入单元工作并提交README 3.8 节。通信是异步的决策记录 0014 明确后果之一是模块集成过程中会出现最终一致性——不要在新模块里假设调用方能看到立即生效的跨模块结果。验证构建、架构测试与异步集成测试1. 构建与编译检查新模块纳入解决方案后用 NUKE 构建整个解决方案DotNetBuild作用于Solution确认四个程序集及 API 注册代码都能编译。2. 架构测试模块边界的关键验证src/Tests/ArchTests/Modules/ModuleTests.cs 用 NetArchTest 为每个模块断言不依赖其他模块。以 Meetings 为例[Test] public void MeetingsModule_DoesNotHave_Dependency_On_Other_Modules() { Liststring otherModules [AdministrationNamespace, PaymentsNamespace, UserAccessNamespace]; ListAssembly meetingsAssemblies [ typeof(IMeetingsModule).Assembly, typeof(Meeting).Assembly, typeof(MeetingsContext).Assembly ]; var result Types.InAssemblies(meetingsAssemblies) .That() .DoNotImplementInterface(typeof(INotificationHandler)) .And().DoNotHaveNameEndingWith(IntegrationEventHandler) .And().DoNotHaveName(EventsBusStartup) .Should() .NotHaveDependencyOnAny(otherModules.ToArray()) .GetResult(); AssertArchTestResult(result); }含义模块的 Application/Domain/Infrastructure 三个程序集中除实现INotificationHandler的类型、名为*IntegrationEventHandler的类型和EventsBusStartup之外任何类型都不允许依赖其他模块的命名空间——即跨模块引用只能发生在集成事件处理器和事件总线启动类里。现有测试只覆盖既有模块新模块必须按同一模式补一个测试把otherModules换成其余模块命名空间程序集换成新模块的三个程序集否则边界不会被测试守护。运行架构测试CI 中使用 NUKE 的ArchitectureTests目标过滤条件为ArchTestsdotnet test --filter ArchTests3. 跨模块事件流的集成测试异步通信的效果用探测 超时方式验证参考 CreateMeetingGroupTests.cs 的场景在 Meetings 模块发起ProposeMeetingGroupCommand以 10 秒超时轮询等待 Administration 模块中出现待审核的提案在 Administration 模块执行AcceptMeetingGroupProposalCommand以 15 秒超时轮询等待 Meetings 模块中生成 Meeting Group。测试中AssertEventually对IProbe周期性SampleAsync()并检查IsSatisfied()超时未满足即失败——这正是事件确实跨模块流动的判定方式。本地运行集成测试前需要按 README 3.13 节的说明设置连接串环境变量ASPNETCORE_MyMeetings_IntegrationTests_ConnectionString指向集成测试数据库也可以直接用 NUKE 目标在 Docker 中执行全部集成测试.\build RunAllIntegrationTests为新模块增加类似的系统级测试从模块 A 发命令探测模块 B 中由事件驱动产生的可观察状态变化。已知限制与边界跨模块不存在事务模块不共享数据无法创建跨模块的事务README 3.7 节涉及多模块的流程必须接受最终一致性直接方法调用被架构测试显式排除We allow direct calls in the future, but this should be an exception, not a rule决策记录 0014集成事件结构应保持稳定决策记录 0014 的后果新模块发布事件后再改字段会影响所有订阅方数据库结构方面新模块的数据应放入自己的 schema 并经由 DatabaseMigrator 的迁移流程MigrateDatabaseNUKE 目标README 3.16 节落库脚本目录参照 Structure 下按模块分目录的组织方式。【免费下载链接】modular-monolith-with-dddFull Modular Monolith application with Domain-Driven Design approach.项目地址: https://gitcode.com/GitHub_Trending/mo/modular-monolith-with-ddd创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进