


构建领域驱动(DDD)的.NET模块,不破坏分层和公开API
在让AI编写或修改采用领域驱动设计的模块化.NET应用时使用。它会沿用现有做法,保持分层依赖单向,把聚合、仓储和DTO放在正确的层,并在每个支持的数据库上测试改动,不影响现有使用者。
要使用这个AI规则
## 沿用现有做法,保持分层依赖单向
创建代码前,先在代码库中搜索类似实现。除非所请求的更改明确取代它们,否则应遵循既有的项目结构、命名、依赖项、测试和扩展模式。保持差异集中;避免附带的格式调整、重构或依赖项升级。
使用依赖关系单向流动的明确分层:
- **Domain.Shared:** 可供每个层和外部客户端安全使用的常量、枚举、本地化资源、事件契约和共享类型。
- **Domain:** 实体、聚合根、仓储契约、领域服务和业务规则。
- **Application.Contracts:** 应用服务接口和数据传输对象。
- **Application:** 应用服务实现和用例编排。
- **Persistence integrations:** 依赖领域的提供方专用上下文、映射和代码库实现。
- **HTTP API:** 依赖应用契约而非实现的传输适配器。
- **Remote client:** 依赖应用契约的生成或手写代理。
- **Web UI:** 使用HTTP API的表示层代码。
不要让Web、传输、身份验证上下文和ORM相关事项进入领域层。保持应用逻辑独立于Web层。即使某个禁止的引用能缩短实现,也应将其视为架构缺陷。
保留公开API和软件包边界。除非破坏性更改已被明确要求、得到合理说明、提供迁移并有兼容性测试覆盖,否则不得引入。
## 让聚合从创建起就有效,把跨实体规则放进领域服务
在领域层创建实体,并确保其在构造时就有效。主构造函数应强制执行不变量,并初始化所有自有集合。当ORM需要时,可提供受保护的无参数构造函数,但不得削弱正常的创建规则。应从调用方接收标识符,而不是在实体内部生成。
使用非公开setter和能表达意图的领域方法保护一致性。当运行时、代理系统或受支持的扩展模型需要时,使成员可被重写。聚合根应使用一个稳定标识符而不是复合键,保持精简,并通过标识符而非导航属性引用其他聚合根。
将属于模型的跨实体规则放入领域服务。只有在确实需要多个实现、替换或测试隔离时才创建接口。使用`Manager`等清晰的领域导向后缀,接收领域对象和基本类型而非DTO,并在规则失败时抛出带有唯一命名空间错误代码的本地化业务异常。
领域服务可以执行领域特定的查找、规范化、缓存或解析,但简单的持久化查询属于仓储。不要在领域内部读取当前已验证用户;应从应用层传入所需的操作者或tenant值。
## 每个聚合根一个仓储,且不依赖具体数据库
在领域层定义仓储契约,每个聚合根使用一个专用契约。不要为聚合所拥有的内部实体创建仓储。
保持公开仓储契约与提供方无关。优先使用基本或只读的仓储功能,不要向模块使用方暴露`IQueryable`之类的查询提供方抽象。提供方实现可以在内部使用功能更丰富的查询API。
使每项仓储操作都异步执行,并接受可选的取消令牌。对于返回单个聚合的方法,如果调用方通常需要完整聚合,则默认启用详细信息加载。对于列表方法,默认禁用,以避免无限制的join和数据传输。
使用有明确含义且返回聚合的方法,而不是返回临时组合的projection类。只有经过测量的性能要求能够证明必要时,才添加特定于projection的方法。将eager loading逻辑保留在提供方专用的`IncludeDetails`辅助函数中,使应用服务无需了解存储如何组装聚合。
当框架提供规范化辅助函数时,通过该函数传递取消令牌。在内部获取queryable时,确保提供方级数据筛选器、tenancy和soft-delete规则仍然有效。
## 用每个操作单独的DTO设计应用服务接口
每个应用服务在契约软件包中放置一个接口。在边界处使用DTO和基本类型;绝不能接受或返回领域实体。方法应异步,使用一致的`Async`后缀;当服务上下文已经足够明确时,不要重复实体名称。
使用可预测的CRUD形式:标识符参数与更新DTO保持分离;创建和更新操作在有用时返回详细DTO;删除不返回payload;列表查询包含可选的筛选、排序和分页字段,并设有安全的最大页面大小。
为每项操作创建单独的输入DTO。不要仅因字段目前相同就复用输入DTO,并避免输入DTO之间的继承。DTO不应包含业务逻辑,应在边界处验证,并为限制复用共享常量。相比许多重叠形式,应优先使用基本输出DTO及其详细版本。
应用实现负责编排用例。它们应使用专用仓储,加载所需聚合,调用领域方法,并明确持久化更新,而不是假定change tracking会完成此事。不要在应用服务中放置数据查询。通过受支持的mapper约定映射可扩展属性。
不要通过应用契约传递Web类型、上传抽象或stream。由传输适配器将文件转换为稳定的应用层表示。避免调用同一模块内的另一个应用服务;将可复用规则移入领域或目标明确的共享辅助函数。跨模块调用必须通过另一个模块的公开契约。
使公开操作和预期的扩展辅助函数可被重写。当定制属于框架契约时,应优先使用受保护的扩展接缝而非私有辅助函数。
## 按数据库类型分开代码,让每个模块能单独使用
为每个模块及其支持的每种持久化提供方分别提供上下文接口和实现。仅为聚合根暴露set或集合。明确附加模块连接名称,使表或集合前缀可配置,并保留框架所有的前缀。
不要依赖lazy loading。应在命名的model builder扩展中配置映射,而不是用实体细节填满上下文生命周期方法。在模块专用规则之前,对每个实体应用框架的约定映射。
提供方专用的仓储实现应继承提供方的标准基类,以上下文接口为边界,正确传递取消令牌,并以适合提供方的方式实现聚合详细信息加载。文档存储可能自然加载自有集合,而关系型提供方需要明确include;这两者的差异都不应泄露到领域或应用契约。
每个软件包应暴露一个明确声明依赖项的模块组合类。在配置期间注册服务和option;只有真正需要正在运行的应用时,才使用应用初始化hook。模块必须能够独立使用,不得与另一个软件包存在隐藏耦合。
当模块支持多个提供方时,对每项集成运行相同的仓储契约和行为契约。提供方独立性是经过测试的属性,不是命名惯例。
## 预留扩展点,旧API分步弃用而不是直接改坏
按照框架支持的扩展模型设计公开成员和受保护成员。使用可重写方法、可替换的服务注册、位于真实替换点的接口,以及用于配置的option对象。记录扩展点和定制实现必须保持的不变量。
没有弃用周期时,不要移除或重命名公开成员。将旧成员标记为obsolete并提供清晰的迁移指南;添加带默认值的可选参数;尽可能为新的基类成员提供默认实现。应优先添加新接口,而不是破坏现有接口。
将面向用户的字符串保存在共享领域软件包所拥有的本地化资源中。使用命名空间键和本地化业务错误,而不是硬编码文本。在各自所属层中将设置、功能和permission名称定义为常量,并将其组织在稳定的模块命名空间下。
同一process内的通信使用in-process事件总线,跨模块或服务的通信使用distributed事件总线。将分布式事件契约放在共享领域软件包中,保持简单且可序列化,并使用基本类型或嵌套事件契约而不是领域实体。在应用层处理集成事件。
将兼容性、本地化和扩展行为视为公开产品界面。通过针对性测试和迁移说明覆盖相关更改。
## 在所有支持的数据库上测试,并检查最终差异
在重大架构或公开契约更改前,通过项目批准的设计或issue流程达成一致。保持pull request范围明确且便于审查,并包含证明受影响行为所需的测试。
对隔离的领域规则使用单元测试,对仓储、映射、permission、验证和模块组合使用集成测试。当契约声称与提供方无关时,应运行每个受支持的持久化提供方。测试名称应体现操作、场景和预期结果。
根据以下检查清单审查最终差异:
- 依赖方向保持不变,没有Web或持久化相关事项向内泄露。
- 实体强制执行不变量,自有集合已经初始化,聚合引用使用标识符。
- 仓储契约仍然异步、可取消、以聚合为中心且与提供方无关。
- 应用契约使用最少的已验证DTO和稳定的方法形式。
- 应用服务将查询委托给仓储,将变更委托给领域方法。
- 关系型集成和文档集成分别遵循各自的映射和详细信息加载约定。
- 公开API、本地化键、设置、功能、permission和事件保持兼容。
- 测试覆盖permission、验证、失败案例、提供方差异和边界条件。
- 差异中不包含无关重构、生成文件编辑或不必要的API界面。
报告所执行的针对性检查,以及仍未验证的任何提供方或兼容性路径。不要仅凭编译就声称符合架构要求。
