
MyMeetings 单一 REST API 模块架构决策实录Modular Monolith 如何组织对外暴露层【免费下载链接】modular-monolith-with-dddFull Modular Monolith application with Domain-Driven Design approach.项目地址: https://gitcode.com/GitHub_Trending/mo/modular-monolith-with-ddd导读本文围绕 MyMeetingsmodular-monolith-with-ddd 开源仓库架构决策日志ADR中的第 5 号决策记录展开剖析为 Modular Monolith 系统创建唯一一个 REST API 模块这一关键抉择的来龙去脉。你将看到该决策在 源码 API 层 中的具体落地方式——单一 Host 如何引用全部业务模块、Controller 如何扮演薄委托层把 Command/Query 转发给各模块门面以及这套方案相对每模块独立 API 项目的收益与代价。决策背景Architecture Decision Log 的由来在深入 0005 号决策之前有必要先交代它所属的记录体系。MyMeetings 是一个演示更高级单体架构的完整开源项目为了让所有架构决策沉淀在统一位置项目创建了 Architecture Decision LogADL见 0001-record-architecture-decisions.md。每条 ADR 采用 Michael Nygard 模板包含 Status、Context、Decision、Consequences 四段结构。仓库 architecture-decision-log 目录目前沉淀了从 0001 到 0017 共 17 条决策覆盖系统架构形态0002 采用 Modular Monolith、技术栈0003 .NET Core C#、模块划分0004 划分 4 个业务模块、门面模式0006 API 与业务模块之间建立 Facade、CQRS0007、领域事件驱动0014等一整套演进路径。0005 号决策正是在这一体系下回答系统如何向外部世界暴露 API这一问题。Context我们面临的问题系统需要把 API 暴露给外部世界。当时决策日期 2019-07-01登记日期 2019-11-04预期只有一个客户端——前端 SPA 应用。结合仓库 C2 容器图 可以更直观地理解上下文SPAReactJS 容器通过 HTTP 使用 My Meetings API.NET Core 容器API 再通过 SQL 读写数据库、通过 SMTP 发送邮件、通过 HTTP 对接第三方支付网关。也就是说API 层是整个 Modular Monolith 对外唯一入口内部所有业务模块的能力都要经由它暴露。两种候选方案一个 Host vs. 每模块一个 API 项目方案一单一 .NET Core MVC Host 承载全部端点创建一个 .NET Core MVC Host 应用包含所有端点该 Host 直接引用全部业务模块并与之通信Host/API 引用 Administration 模块Host/API 引用 Meetings 模块Host/API 引用 Payments 模块Host/API 引用 User Access 模块方案二一个 Host 每模块一个 API 项目创建一个 .NET Core MVC Host 应用外加每模块独立 API 项目每个 API 项目中的端点由对应业务模块处理Host 引用 Administration APIAdministration API 引用 Administration 模块Host 引用 Meetings APIMeetings API 引用 Meetings 模块Host 引用 Payments APIPayments API 引用 Payments 模块Host 引用 User Access APIUser Access API 引用 User Access 模块两种方案的核心差异在于为每个业务模块单独建 API 项目还是在一个 Host 应用内用目录组织。Decision采用方案一单一 API 模块决策结论是方案一。理由非常直白为每个模块创建独立的 API 项目会增加复杂度却几乎没有带来价值。把特定业务模块的端点分组放在一个特殊目录里就足够了。在模块之上再叠一层是没有必要的。这个判断基于一个朴素的工程直觉API 层本质上是门面之上的门面其职责仅仅是接收 HTTP 请求、转换成领域层的 Command/Query、再返回结果。为这层薄薄的委托逻辑引入四个项目属于过度设计。Consequences决策的收益与代价ADR 记录了一系列后果可归纳为正面收益与局部代价正面收益系统中只有唯一一个 API 层/模块每个 Controller 的职责明确把 Command/Query 处理委托给对应模块无需在 Host 之外扫描其他项目的 Controller、路由及其他 MVC 机制API 配置更简单认证、Swagger、异常处理、中间件等只需配置一份API 层整体复杂度更低构建时间更短项目数量更少局部代价单个 Controller 的复杂度略有上升一个 Controller 可能要面对多个领域操作需要靠目录纪律来维持模块边界而不是靠项目边界强制值得注意的是这种复杂度转移与 0006 号决策在 API 与业务模块之间建立 Facade 形成互补Facade 保证了 Controller 不直接触碰模块内部实现细节从而让单一 API 层的目录分组方案得以安全落地——因为跨模块访问路径被严格收敛到各模块公开的模块门面接口上。源码落地单一 Host 的真实结构Program.csAutofac 接管依赖注入Program.cs 使用AutofacServiceProviderFactory替换默认 DI 容器为后续按模块注册 Autofac Module 做准备public static IHostBuilder CreateWebHostBuilder(string[] args) { return Host.CreateDefaultBuilder(args) .UseServiceProviderFactory(new AutofacServiceProviderFactory()) .ConfigureWebHostDefaults( webBuilder { webBuilder.UseStartupStartup(); }); }Startup.cs在单一 Host 内初始化全部模块Startup.cs 是单一 API 模块决策最集中的体现ConfigureContainer中按模块注册 Autofac ModuleMeetingsAutofacModule、AdministrationAutofacModule、UserAccessAutofacModule、PaymentsAutofacModule被注册进同一个容器。这印证了 ADR 中Host 引用全部业务模块的表述——引用粒度是模块级而不是每个模块一个 Host。public void ConfigureContainer(ContainerBuilder containerBuilder) { containerBuilder.RegisterModule(new MeetingsAutofacModule()); containerBuilder.RegisterModule(new AdministrationAutofacModule()); containerBuilder.RegisterModule(new UserAccessAutofacModule()); containerBuilder.RegisterModule(new PaymentsAutofacModule()); }InitializeModules中统一完成各模块自举MeetingsStartup.Initialize、AdministrationStartup.Initialize、UserAccessStartup.Initialize、PaymentsStartup.Initialize、RegistrationsStartup.Initialize依次被调用每个模块的启动入口接收连接字符串、执行上下文访问器、日志器、邮件配置等基础设施在进程内完成各自的 IoC 容器初始化对应 0016 号决策每个模块独立 IoC 容器。所有模块共享同一个MeetingsConnectionString连接字符串所有配置都集中在 API Host 的appsettings.json系列配置文件中。跨切面配置只做一份SwaggerAddSwaggerDocumentation、IdentityServerConfigureIdentityService、ProblemDetails 异常映射InvalidCommandException→InvalidCommandProblemDetails、BusinessRuleValidationException→BusinessRuleValidationExceptionProblemDetails、基于HasPermission策略的授权HasPermissionPolicyName、CorrelationMiddleware关联 ID 中间件、CORS允许任意来源/头/方法等全部在 API Host 统一配置。这正是 ADR 后果中API 配置更容易的实证。API 目录按模块分组而非按项目分割API 的 Modules 目录 完美体现了把特定业务模块的端点分组放在一个特殊目录里src/API/CompanyName.MyMeetings.API/Modules/ ├── Administration/ → 管理端端点MeetingGroupProposals 等 ├── Meetings/ → 会议域端点Countries、MeetingComments、MeetingGroups、Meetings 等 ├── Payments/ → 支付域端点MeetingFees、Payers、PriceListItems、Subscriptions └── UserAccess/ → 用户与身份端点AuthenticatedUserController、EmailsController、UserRegistrationsController每个业务模块目录内再放一个XxxAutofacModule.cs负责把该模块的门面接口注册进 Host 容器。以 MeetingsAutofacModule.cs 为例public class MeetingsAutofacModule : Module { protected override void Load(ContainerBuilder builder) { builder.RegisterTypeMeetingsModule() .AsIMeetingsModule() .InstancePerLifetimeScope(); } }这清楚展示了 Facade 注册模式API 层只依赖IMeetingsModule门面接口定义见 IMeetingsModule.cs具体实现MeetingsModule由 Autofac 按请求作用域InstancePerLifetimeScope解析。Controller 的委托职责命令与查询的转发层ADR 后果中提到每个 Controller 有责任把 Command/Query 处理委托给适当的模块MeetingsController.cs 是这一模式的标准范例。它只做三件事通过构造函数注入模块门面IMeetingsModule _meetingsModule在 Action 中组装 Command/Query 对象并调用门面返回Ok()或查询结果[Route(api/meetings/meetings)] [ApiController] public class MeetingsController : ControllerBase { private readonly IMeetingsModule _meetingsModule; public MeetingsController(IMeetingsModule meetingsModule) { _meetingsModule meetingsModule; } [HttpGet({meetingId})] [HasPermission(MeetingsPermissions.GetMeetingDetails)] [ProducesResponseType(typeof(MeetingDetailsDto), StatusCodes.Status200OK)] public async TaskIActionResult GetMeetingDetails(Guid meetingId) { var meetingDetails await _meetingsModule.ExecuteQueryAsync(new GetMeetingDetailsQuery(meetingId)); return Ok(meetingDetails); } [HttpPost()] [HasPermission(MeetingsPermissions.CreateNewMeeting)] [ProducesResponseType(StatusCodes.Status200OK)] public async TaskIActionResult CreateNewMeeting([FromBody] CreateMeetingRequest request) { await _meetingsModule.ExecuteCommandAsync(new CreateMeetingCommand( request.MeetingGroupId, request.Title, request.TermStartDate, request.TermEndDate, request.Description, request.MeetingLocationName, request.MeetingLocationAddress, request.MeetingLocationPostalCode, request.MeetingLocationCity, request.AttendeesLimit, request.GuestsLimit, request.RSVPTermStartDate, request.RSVPTermEndDate, request.EventFeeValue, request.EventFeeCurrency, request.HostMemberIds)); return Ok(); } // ... 其他 Action 遵循同一模式 }要点拆解Command/Query 由模块门面执行ExecuteCommandAsync/ExecuteQueryAsync是门面接口暴露的全部能力见 IMeetingsModule.csController 完全不知道命令内部由哪个 Handler 处理、事务如何开启、领域事件如何派发。请求 DTO 与命令分离入参是 API 层自己的请求对象如 CreateMeetingRequest.cs、ChangeMeetingMainAttributesRequest.csController 负责将其映射为模块应用层的 Command。权限声明式通过[HasPermission(...)]特性声明端点所需权限如MeetingsPermissions.GetMeetingDetails、CreateNewMeeting由统一的授权中间件处理不需要每个 Controller 重复实现认证逻辑。一个值得注意的细节是Controller 目录与模块并非严格一一对应API 层的一个 Controller 可以调用多个业务模块的门面。例如 UserRegistrationsController.cs 位于 UserAccess 目录下却同时注入了IRegistrationsModule用于注册新用户、确认注册和IUserAccessModule门面。这正是单一 API 层 目录分组方案优于每模块独立 API 项目的地方——面对跨模块的对外交互场景可以在一个 Controller 内自由编排而不需要跨项目跳转。架构验证架构测试如何守卫这条边界该决策的约束Controller 只应存在于 API Host、不得直接访问其他模块内部实现由仓库的架构测试守护。ArchTests 项目 中的 ApiTests.cs 与 ModuleTests.cs 通过反射扫描程序集验证依赖方向与引用边界对应 0017 号决策实现架构测试。各模块自己的 ArchTests如 Administration 模块 ArchTests、Meetings 模块 ArchTests进一步保证模块内部 Application/Domain/Infrastructure 层的依赖方向正确。这套测试机制让 0005 号决策从文档约定变成可持续执行的纪律即便没有独立 API 项目来物理隔离边界CI 中运行的架构测试依然能在代码层面强制维护模块边界。总结单一 API 模块在 Modular Monolith 中的位置回看 0005 号决策可以提炼出它在整个架构中的三个定位对外唯一的门整个系统只有一个对外暴露层SPA 等客户端只与这一个 Host 交互见 C2 容器图 中的 My Meetings API 容器。内部模块的编排层Controller 是纯粹的委托层通过各模块公开的 Facade 门面执行 Command/Query业务逻辑完全沉淀在模块内部。配置的收敛点认证、Swagger、异常处理、CORS、模块自举等横切配置全部集中在 Host 内符合API 配置更容易、整体复杂度更低的决策预期。代价是 Controller 复杂度的上升与对目录纪律的依赖——但配合 0006 号决策的 Facade 模式、0016 号决策的模块级 IoC 容器 以及 0017 号决策的架构测试这些代价在 MyMeetings 中得到了有效控制。对于正在设计 Modular Monolith 架构的团队而言0005 号决策的取舍逻辑极具参考价值当新增一层只带来结构上的整齐而不能带来独立部署或隔离收益时用目录 纪律代替项目 边界往往才是复杂度更低的答案。【免费下载链接】modular-monolith-with-dddFull Modular Monolith application with Domain-Driven Design approach.项目地址: https://gitcode.com/GitHub_Trending/mo/modular-monolith-with-ddd创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考