分层、应用服务与仓储:业务规则应该放在哪里
很多项目的「DDD 改造」是把原来的
Service拆成ApplicationService和DomainService,业务判断仍然写在应用层。判断一段逻辑放在哪里,看的不是类名,而是谁拥有这条规则。
聚合边界 确定了场次聚合守护容量、重复和候补顺序。接下来要把它放进一个能运行的服务:HTTP 请求怎样变成命令,事务从哪里开始,聚合怎样被加载和保存,事件什么时候发出去,失败时各层返回什么。本文用一条「报名某个场次」的用例,按 adapter / application / domain / infrastructure 四层实现,用 ArchUnit 1.5.0 把依赖规则写成测试,并记录这条用例实际经过的每一步。
四层只是一种组织方式。本文的重点是每一层的职责和依赖方向,而不是目录名。
一、先说结论
- 业务规则只在领域层:实测成功路径依次经过适配器、应用服务、仓储、
Session.register、带版本号的 UPDATE、提交、发布事件,容量和重复检查只出现在Session.register一处。 - 应用服务描述用例,不实现规则:转换命令、开启事务、加载聚合、调用行为、保存、提交后交出事件,这些是它的全部工作。
- 规则优先放在拥有信息的领域对象上:只有跨多个聚合、又没有自然归属的规则,才放进领域服务。
- 仓储像集合一样存取聚合:一个聚合一个仓储,按版本号保存;报表查询不走仓储。
- 对象转换是边界的一部分:请求、命令、聚合、持久化对象、响应之间的转换要显式写出并测试。实测往返检查发现了一个漏掉候补表的转换器,不一致的样本数正好等于有候补的场次数。
- 依赖规则要能执行:三个违规写法都被 ArchUnit 规则拦下;但「适配器不能碰领域对象」这类约定是项目的选择,不是 DDD 的要求。
二、从建模产物到代码对象
事件风暴和建模留下的每一种产物,都应该在代码里有一个明确的去处:
| 建模产物 | 代码中的形态 | 放在哪一层 | 判断依据 |
|---|---|---|---|
| 命令 | RegisterCommand:表达意图的用例输入 | application | 不带 HTTP、ORM 的细节 |
| 领域事件 | RegistrationConfirmed:过去式命名的业务事实 | domain | 由聚合登记 |
| 集成事件 | registration.confirmed/v1:稳定、带版本的外部契约 | application 或专门的发布模块 | 不直接序列化聚合,见 上下文集成 |
| 实体、值对象 | Session、Attendee、Phone | domain | 有身份、有规则 |
| 聚合 | Session 作为根,内部名单只读 | domain | 由根守护不变量 |
| 外部能力 | SessionRepository:领域声明的端口 | domain(接口)、infrastructure(实现) | 依赖指向领域 |
| 业务策略 | 领域服务、策略对象 | domain | 没有自然归属时才抽出 |
| 查询需求 | 查询服务、读模型 | application 或独立的查询模块 | 不强迫报表穿过聚合,见 CQRS |
目录结构是这张表的结果。先写目录再往里塞类,通常会得到一个名为 domain 却只有 getter/setter 的包。
三、一条用例的运行视图
实验中的四层结构:
labs.ddd.layered
├── Bootstrap 组合根:唯一同时认识四层的地方
├── adapter RegisterRequest → RegisterCommand;失败 → HTTP 状态码
├── application RegisterForSession:转换命令、事务入口、提交后交出事件
├── domain Session、Phone、领域事件、SessionRepository 端口
└── infrastructure SessionRecord、SessionConverter、带事务的表、仓储实现应用服务的全部代码:
public RegistrationResult handle(RegisterCommand command) {
Attendee attendee;
try {
attendee = new Attendee(command.attendeeId(), new Phone(command.phone())); // 命令转换:非法输入在这里拒绝
} catch (IllegalArgumentException e) {
throw new UseCaseFailure.InvalidInput(e.getMessage());
}
try {
return transactions.inTransaction(() -> {
Session session = sessions.find(new SessionId(command.sessionId())).orElseThrow();
session.register(attendee); // 规则都在这里面
sessions.save(session); // 带版本号保存
List<DomainEvent> events = session.drainEvents();
transactions.afterCommit(() -> events.forEach(publisher::publish)); // 提交后才交出事件
return RegistrationResult.from(events);
});
} catch (RegistrationRefused e) {
throw new UseCaseFailure.Rejected(e.getMessage());
} catch (SessionRepository.ConcurrentModification e) {
throw new UseCaseFailure.Conflict();
}
}实际运行时记录下来的成功路径:
adapter 收到 POST /sessions/S-1/registrations
infrastructure BEGIN
application 开始用例 RegisterForSession
infrastructure SELECT session S-1
domain Session.register 通过容量与重复检查
infrastructure UPDATE session ... WHERE version=3:1 行
infrastructure COMMIT
infrastructure 发布 RegistrationConfirmed
adapter 返回 201 CONFIRMED三种失败分别停在不同的地方:
| 失败 | 在哪里被发现 | 结果 |
|---|---|---|
| 手机号格式不对 | 应用服务转换命令时 | 400,没有开启事务 |
| 同一个人重复报名 | 领域层 Session.register | 事务回滚,422 |
| 加载之后另一个请求先提交 | 仓储保存时版本不匹配 | 事务回滚,409,没有发布事件 |
四、规则放在哪里
一条规则应该由最有资格做这个决定的对象负责,判断顺序如下:
- 值对象:规则只和一个值有关,例如手机号格式、金额不能为负、时间段开始早于结束。
- 实体或聚合根:规则需要一个聚合内部的状态,例如容量、重复报名、候补顺序。
- 领域服务:规则需要多个聚合的信息,又不属于其中任何一个。例如「同一个人同一天不能报两个时间重叠的场次」,它涉及多个场次聚合,放在任何一个场次里都不合适。领域服务只做判断,通过端口取得需要的数据,不开启事务。
- 应用服务:不放规则。它可以检查权限、做幂等、协调多个端口,但这些是用例的流程,不是业务规则。
工厂也按同样的标准判断:如果创建一个合法的对象需要多个步骤、多个来源的数据,或者要在多个实现之间选择,就用工厂封装;Session.open(id, capacity) 这种一行就能保证合法的创建,不需要再包一层。
方法名使用业务动作:register、cancel、closeRegistration。setStatus(3) 需要注释才能看懂,而且任何人都可以用它绕过规则。
五、对象转换链
一次请求在各层之间会变换几次形态:
RegisterRequest(HTTP 请求体,字段都是字符串)
→ RegisterCommand(用例输入,表达意图)
→ Session / Attendee / Phone(领域对象,构造即合法)
↔ SessionRecord(持久化形状,字段公开、可以为任何值)
→ RegistrationResult(用例输出)→ HttpResponseDTO、DO、PO、VO 这些名字不重要,重要的是每一次转换都回答了下面的问题:
- 哪一侧拥有字段的定义?请求体的字段由 API 契约决定,领域对象的字段由规则决定。
- 丢失字段是否允许?
- 新旧版本怎样兼容?
- 非法输入在哪一层被拒绝?
- 从持久化对象重建聚合时,会不会误触发创建规则或登记事件?
持久化方向的转换最容易出错,因为漏掉一个字段不会报错,只会悄悄丢数据。实验用往返检查防止这一点:200 个随机场次做「记录 → 聚合 → 记录」,结果必须与原记录完全相等。
完整转换器:不一致 0 个
漏掉候补表的转换器:不一致 97 个(等于有候补的场次数 97)站内的 Domain Driven Kit 要求 Entity ↔ PO 显式注册双向映射器,也是出于同样的考虑;自动映射工具在字段同名时很方便,但复杂聚合的映射最好手写,理由见 对象映射方案对比。
六、仓储
仓储(Repository)让领域层觉得聚合存放在一个集合里:
public interface SessionRepository {
Optional<Session> find(SessionId id);
void save(Session session); // 按加载时的版本保存;版本已被推进时抛出 ConcurrentModification
}几个约定:
- 一个聚合一个仓储,按聚合根存取整个聚合,不提供修改单个子对象的方法;
- 接口在领域层,实现在基础设施层,领域层不知道背后是 MySQL 还是别的存储;
- 版本号属于聚合的并发语义,保存时用
UPDATE ... WHERE version = ?,影响 0 行就是冲突; - 报表查询不走仓储,否则仓储会长出几十个
findByXxxAndYyy,每一个都要加载完整聚合。
对照 DDK:它的 GenericRepository 按加载时的版本保存,影响 0 行时抛出 ConcurrentUpdateException,仓储契约也可以直接使用 UserId 这样的类型化标识;但它假设一个聚合对应一张表,带子表的复杂聚合仍需要手写仓储,见 DDK 通用仓储。
保存和重建的更多细节,例如重建为什么不能走创建规则、子表怎样保存,见 持久化与重建。
七、让依赖方向可以执行
写在文档里的分层只是建议。实验用 ArchUnit 写了三条规则:
| 规则 | 约束 |
|---|---|
| 分层 | 适配层只能访问应用层;领域层只能被应用层和基础设施层访问;没有层可以访问适配层和基础设施层 |
| 领域无框架 | 领域层不依赖 Spring、JPA、Jackson、MyBatis |
| 领域不依赖外层 | 领域层不依赖适配层、应用层、基础设施层 |
主代码 30 个类满足这三条规则。三个故意写错的夹具都被拦下:
领域对象持有持久化对象:被分层规则与「领域不依赖外层」拦下
Field <...domainleak.domain.Session.state> has type <...domainleak.infrastructure.SessionPO>
领域对象标注 @Component:被「领域无框架」拦下
Class <...framework.domain.Session> is annotated with <org.springframework.stereotype.Component>
控制器跳过应用层直接调用仓储:被分层规则拦下
Constructor <...bypass.adapter.SessionController.<init>(...bypass.domain.SessionRepository)> has parameter of type <...>这三条规则与 DDK ArchGuard 中的分层、领域无框架、领域不依赖外层三条规则对应。
7.1 严格分层的代价
写这组代码时,第一版控制器直接构造了领域的 Phone、SessionId,并捕获领域异常转换成 HTTP 状态码,被分层规则拦下。最终的写法是让命令只带原始字段,由应用服务转换成领域类型,再用 UseCaseFailure 把领域异常翻译给适配器。
这多出了一层翻译代码。它换来的是适配器完全不认识领域类型,领域模型可以自由重构;代价是简单的字段也要经过应用层转一次。另一种常见的约定是允许适配器使用领域的值对象和异常,只禁止它调用仓储和修改聚合,只需要把分层规则中的 mayOnlyBeAccessedByLayers 放宽一层。
两种约定都成立。DDD 要求的是领域层不依赖外部细节,至于外层之间可以跨几层,是团队按项目规模和变化频率做的选择。选定以后写成规则,比在评审里反复争论更有效。
八、常见误区
- 「有 domain 包就是 DDD」:领域层里只有 getter/setter、规则都在应用服务里,是换了目录的事务脚本。
- 「领域服务越多越好」:能放进实体和值对象的规则放进领域服务,会让实体变成数据容器。
- 「应用服务可以做一点业务判断」:只要有第二个入口(批量导入、消息消费)绕过它,规则就失效了。
- 「仓储就是 DAO」:DAO 按表操作,仓储按聚合操作;报表查询不应该放进仓储。
- 「DTO 必须放在应用层」:DTO 的位置取决于它代表传输契约还是用例输入;关键是领域层不依赖它。
- 「四层是 DDD 的标准结构」:六边形、洋葱、整洁架构表达的都是同一件事:业务核心不依赖外部细节。四层只是其中一种画法。
小结
分层要回答的是「规则在哪里」:值对象和聚合根守护它们拥有信息的规则,领域服务处理跨聚合又没有归属的规则,应用服务只编排用例、开启事务、在提交后交出事件,仓储按聚合存取并用版本号检测冲突。对象在层与层之间的转换要显式写出并做往返检查,依赖方向要写成会失败的测试。边界落进代码之后,下一篇讨论它和数据、团队、部署单元的关系:DDD 的边界不止一个。
配套实验
- codesphere-labs/ddd/layered-architecture:四层结构与三条 ArchUnit 规则、三个违规夹具、一条报名用例的调用顺序与三种失败、200 个样本的转换器往返检查(验证记录)
参考资料
- Eric Evans,《Domain-Driven Design: Tackling Complexity in the Heart of Software》第 4 章(分层架构)、第 5 章(服务)、第 6 章(工厂、仓储)
- Alistair Cockburn,Hexagonal Architecture
- Martin Fowler,AnemicDomainModel
- ArchUnit User Guide:Layer Checks