
Modular Monolith with DDD 之读取架构决策基于 ADR-0009 的两层读模型实践解析【免费下载链接】modular-monolith-with-dddFull Modular Monolith application with Domain-Driven Design approach.项目地址: https://gitcode.com/GitHub_Trending/mo/modular-monolith-with-ddd导读本文围绕本仓库架构决策日志中的 ADR 0009Use 2 layered architectural style for reads深入讲解 Modular Monolith模块化单体项目中查询读请求的架构选型为什么读取侧只需要API 层 应用服务层两层以及这一决策在 MyMeetings 各业务模块Meetings、Administration、Payments、UserAccess、Registrations中的真实落地方式。读完本文你将掌握查询对象Query从 HTTP 请求到 SQL 执行的完整调用链、读取侧不抽象数据库而直接使用 Dapper 与数据库视图的实现范式以及该决策带来的性能与简单性收益。ADR-0009 决策原文回顾两层架构的由来在采用 CQRS 架构风格见 ADR 0007Use CQRS architectural style之后系统里出现了两个方向截然不同的请求处理路径读取请求需要以关系/扁平化的形式返回数据表格、列表、字典适合直接查询数据库写入请求需要操作对象图graph of objects完成校验、业务规则检查、计算等复杂工作走的是 DDD 领域模型路径。ADR-0009 于 2019-07-01 制定、2019-11-04 记录状态为Accepted。它的核心决策是查询处理采用两层架构API 层负责根据 HTTP 请求创建 Query 对象模块 Application 层负责查询处理。由于已经应用 CQRS 并建立了独立的读模型read model查询处理足够简单直接两层即可满足需求。文档同时给出了 5 条后果Consequences这些是理解本决策价值的关键全部查询处理逻辑位于 Application Service 层Application Service 层与数据库和查询框架Dapper耦合方案简单、易于理解不对数据库做抽象不引入 Repository 层性能更好层与层之间没有对象映射几乎是直接查询数据库。读取侧的真实落地从 Controller 到 SQL 的完整调用链ADR-0009 的决策在代码中有非常一致的体现。以 Meetings 模块的获取全部会议小组功能为例可以完整还原读取请求的流转路径API 层Controller接收 HTTP GET 请求创建 Query 对象并交给模块门面。模块门面Module FacadeExecuteQueryAsync进入模块组合根。MediatR将 Query 路由到对应的 QueryHandler。QueryHandler应用服务层通过 Dapper 直接查询数据库视图返回 DTO。数据库SQL 视图提供扁平化读模型。第一步API 层——HTTP 请求 → Query 对象在 MeetingGroupsController.cs 中GET 端点只做一件事把请求参数组装成 Query 对象然后调用模块门面[HttpGet(all)] [HasPermission(MeetingsPermissions.GetAllMeetingGroups)] [ProducesResponseType(typeof(ListMeetingGroupDto), StatusCodes.Status200OK)] public async TaskIActionResult GetAllMeetingGroups() { var meetingGroups await _meetingsModule.ExecuteQueryAsync(new GetAllMeetingGroupsQuery()); return Ok(meetingGroups); }注意这里的约束门面方法只接受 Command 或 Query 对象作为参数——这正是 ADR-0007 后果中Façade method of each module should take as parameter only Command or Query object的落实。API 层不做任何数据访问也不承载任何查询逻辑纯粹是请求 → 参数对象的翻译器。第二步模块门面——进入组合根的唯一切口MeetingsModule.cs 是 API 层能看到的全部后端。查询执行时会开启一个新的 Autofac 生命周期作用域并通过 MediatR 分发public async TaskTResult ExecuteQueryAsyncTResult(IQueryTResult query) { using (var scope MeetingsCompositionRoot.BeginLifetimeScope()) { var mediator scope.ResolveIMediator(); return await mediator.Send(query); } }门面契约定义在 IMeetingsModule.cs只暴露三种操作ExecuteCommandAsyncTResult、ExecuteCommandAsync、ExecuteQueryAsyncTResult。Administration、Payments、UserAccess、Registrations 模块的门面结构完全相同这保证了整个系统的读取入口是统一的。第三步Query 与 QueryHandler 的约定每个 Query 都必须实现IQueryTResult契约见 IQuery.cs实际使用中通常继承QueryBaseTResult见 QueryBase.cs后者为每个查询实例分配一个Id使查询本身可被序列化、记录呼应 ADR-0007 中Command/Query 是对象可以轻松序列化并保存/记录的收益。最简单的查询对象甚至不携带任何字段public class GetAllMeetingGroupsQuery : IQueryListMeetingGroupDto { }而带参数的查询则把过滤条件直接作为属性public class GetMeetingGroupDetailsQuery : IQueryMeetingGroupDetailsDto { public Guid MeetingGroupId { get; } // ... }对应的处理器继承自IQueryHandlerTQuery, TResult见 IQueryHandler.cs它本质上就是 MediatR 的IRequestHandlerTQuery, TResultpublic interface IQueryHandlerin TQuery, TResult : IRequestHandlerTQuery, TResult where TQuery : IQueryTResult { }第四步应用服务层——Dapper 直查视图零对象映射决策文档声称不对数据库做抽象、无对象映射源码给出了最直接的证据。GetAllMeetingGroupsQueryHandler.cs 中处理器依赖唯一的注入项ISqlConnectionFactory见 ISqlConnectionFactory.cs打开连接后直接执行 Dapper 查询internal class GetAllMeetingGroupsQueryHandler : IQueryHandlerGetAllMeetingGroupsQuery, ListMeetingGroupDto { private readonly ISqlConnectionFactory _sqlConnectionFactory; public async TaskListMeetingGroupDto Handle(GetAllMeetingGroupsQuery request, CancellationToken cancellationToken) { var connection _sqlConnectionFactory.GetOpenConnection(); const string sql $ SELECT [MeetingGroup].[Id] as [{nameof(MeetingGroupDto.Id)}], [MeetingGroup].[Name] as [{nameof(MeetingGroupDto.Name)}], [MeetingGroup].[Description] as [{nameof(MeetingGroupDto.Description)}], [MeetingGroup].[LocationCountryCode] as [{nameof(MeetingGroupDto.LocationCountryCode)}], [MeetingGroup].[LocationCity] as [{nameof(MeetingGroupDto.LocationCity)}] FROM [meetings].[v_MeetingGroups] AS [MeetingGroup] ; var meetingGroups await connection.QueryAsyncMeetingGroupDto(sql); return meetingGroups.AsList(); } }这段代码印证了决策中的每一条查询逻辑全部在 Application 层没有独立的 Repository、没有 Data Access 层与数据库和查询框架强耦合SQL 字符串、Dapper 扩展方法直接出现在处理器里不做数据库抽象ISqlConnectionFactory只是获取连接的工具不隐藏 SQL性能更好QueryAsyncMeetingGroupDto由 Dapper 直接把结果集映射为 DTO中间没有任何额外的对象映射层。读模型的载体数据库视图Read Model查询几乎是直连数据库之所以成立前提是读取侧有专门定制的读模型。本仓库的做法是在数据库层建立视图View把复杂的表关系预先拍平成查询友好的形态。例如 v_MeetingGroups.sqlCREATE VIEW [meetings].[v_MeetingGroups] AS SELECT [MeetingGroup].Id, [MeetingGroup].[Name], [MeetingGroup].[Description], [MeetingGroup].[LocationCountryCode], [MeetingGroup].[LocationCity] FROM meetings.MeetingGroups AS [MeetingGroup] GOMeetings 模块的 Schema 目录下共维护了 11 个视图见 Structure/meetings/ViewsAdministration、registrations、users 等模块也有各自的 Views 目录。视图与 CQRS 的读模型即时一致immediately consistent要求天然契合——查询直接打在视图上永远读到的是当前数据库的实时状态。当单个查询需要补充聚合信息时处理器也会组合多个视图。以 GetMeetingGroupDetailsQueryHandler.cs 为例主查询读取v_MeetingGroups随后用ExecuteScalarAsync从v_MemberMeetingGroups统计活跃成员数var meetingGroup await connection.QuerySingleAsyncMeetingGroupDetailsDto( sql, new { query.MeetingGroupId }); meetingGroup.MembersCount await GetMembersCount(query.MeetingGroupId, connection);这种视图 Dapper DTO的组合就是整个读取侧的完整技术栈简单、直接、可预测。与写入侧Clean Architecture的对照为什么读取侧可以只有两层要真正理解 ADR-0009必须把它和写入侧的决策放在一起看。仓库的另一份决策 ADR 0010Use clean architecture for writes 明确写入路径采用 Clean Architecture因为写入要承载领域模型、业务规则与不变式而 ADR 0012Use domain-driven design tactical patterns 要求通过聚合Aggregate、实体Entity、值对象Value Object保护业务不变式。读取侧之所以能瘦身成两层本质原因在于读请求不修改状态无需经过领域模型的业务规则校验读模型是扁平化 DTO直接面向屏幕/接口展示不需要对象图读性能敏感跳过中间映射层收益直接。因此项目里形成了清晰的双轨制写路径复杂Clean Architecture DDD读路径极简2 层 Dapper 视图。两者通过模块门面统一收口互不干扰这正是本仓库模块化单体架构的精髓之一。架构全貌可参考 docs/Images/Architecture_high_level.png模块划分见 ADR 0004divide-the-system-into-4-modules。决策后果的验证与权衡ADR-0009 的 5 条后果在本仓库中均可逐一验证决策声明的后果源码证据查询逻辑全部在 Application 层所有*QueryHandler.cs位于各模块Application/目录下如 GetAllMeetingGroupsQueryHandler.csApplication 层与数据库、查询框架耦合Handler 直接using Dapper;并编写原生 SQL方案简单易理解从 Controller 到 SQL 只有 4 个环节Controller → Facade → MediatR → Handler不对数据库做抽象没有查询侧 RepositoryISqlConnectionFactory仅负责提供连接性能更好QueryAsyncMeetingGroupDto直接映射无对象映射层当然任何决策都有代价。两层读架构把 SQL 直接暴露在 Application 层意味着SQL 变更无法被编译期捕获列名、视图结构变动需要靠集成测试兜底读模型与视图耦合数据库 schema 演进时必须同步维护视图若未来出现跨库聚合、多数据源或复杂的读模型重建如 Event Sourcing 投影两层架构需要演进为更完整的读模型管道。因此该决策的适用边界很清晰当读取是即时一致 关系型扁平数据时两层架构是最优解当读取演进为异步投影、物化视图或独立查询服务时则需要在 Application 层内部进一步分层。相关决策索引本决策是仓库 架构决策日志 体系中的一环与之直接相关的前后决策包括ADR 0007Use CQRS architectural style——本决策的出发前提确立读写分离与独立读模型ADR 0008allow-return-result-after-command-processing——命令处理后返回结果的约定ADR 0010Use clean architecture for writes——写入侧采用 Clean Architecture与读取侧形成对照ADR 0011create-rich-domain-models 与 ADR 0012——领域模型只服务于写入侧。小结ADR-0009 用一次简洁的架构决策为 MyMeetings 的读取侧定下了基调在 CQRS 建立读模型之后查询路径不需要更多层次——API 层组装 QueryApplication 层用 Dapper 直查数据库视图两行代码即可完成一次读请求的处理。这种该复杂的地方复杂、该简单的地方简单的克制正是模块化单体 DDD 项目值得借鉴的设计取舍。如果你想在真实代码中体验这条调用链可以从 MeetingGroupsController.cs 出发依次阅读 MeetingsModule.cs、GetAllMeetingGroupsQueryHandler.cs 与 v_MeetingGroups.sql四份文件即可完整覆盖该架构决策的全部实现细节。【免费下载链接】modular-monolith-with-dddFull Modular Monolith application with Domain-Driven Design approach.项目地址: https://gitcode.com/GitHub_Trending/mo/modular-monolith-with-ddd创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考