Skip to content

分层、应用服务与仓储:业务规则应该放在哪里 ​

很多项目的「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、Phonedomain有身份、有规则
聚合Session 作为根,内部名单只读domain由根守护不变量
外部能力SessionRepository:领域声明的端口domain(接口)、infrastructure(实现)依赖指向领域
业务策略领域服务、策略对象domain没有自然归属时才抽出
查询需求查询服务、读模型application 或独立的查询模块不强迫报表穿过聚合,见 CQRS

目录结构是这张表的结果。先写目录再往里塞类,通常会得到一个名为 domain 却只有 getter/setter 的包。

三、一条用例的运行视图 ​

实验中的四层结构:

text
labs.ddd.layered
├── Bootstrap        组合根:唯一同时认识四层的地方
├── adapter          RegisterRequest → RegisterCommand;失败 → HTTP 状态码
├── application      RegisterForSession:转换命令、事务入口、提交后交出事件
├── domain           Session、Phone、领域事件、SessionRepository 端口
└── infrastructure   SessionRecord、SessionConverter、带事务的表、仓储实现

应用服务的全部代码:

java
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();
    }
}

实际运行时记录下来的成功路径:

text
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,没有发布事件
adapterapplicationdomaininfrastructure1收到请求2转换命令3BEGIN4加载5register6UPDATE7COMMIT8发布事件9返回 201失败400 非法手机号:未开启事务422 重复报名:领域拒绝,回滚409 版本冲突:回滚,不发布事件
图 1 · 实测记录的顺序:适配器把请求交给应用服务,应用服务在事务里加载场次、调用 Session.register、按版本号保存,提交之后才交出事件。三种失败分别停在命令转换、领域规则和版本检查

四、规则放在哪里 ​

一条规则应该由最有资格做这个决定的对象负责,判断顺序如下:

  1. 值对象:规则只和一个值有关,例如手机号格式、金额不能为负、时间段开始早于结束。
  2. 实体或聚合根:规则需要一个聚合内部的状态,例如容量、重复报名、候补顺序。
  3. 领域服务:规则需要多个聚合的信息,又不属于其中任何一个。例如「同一个人同一天不能报两个时间重叠的场次」,它涉及多个场次聚合,放在任何一个场次里都不合适。领域服务只做判断,通过端口取得需要的数据,不开启事务。
  4. 应用服务:不放规则。它可以检查权限、做幂等、协调多个端口,但这些是用例的流程,不是业务规则。
规则只涉及一个值?只需一个聚合的状态?跨多个聚合?否否值对象手机号格式、金额非负实体 / 聚合根容量、重复、候补顺序领域服务同一天时间重叠的场次应用服务只编排,不放规则是是是工厂只在创建需要多个步骤或多个数据来源时才引入
图 2 · 先问规则只涉及一个值吗,再问是否只需要一个聚合内的状态;跨多个聚合又没有自然归属时才放进领域服务;应用服务不放规则

工厂也按同样的标准判断:如果创建一个合法的对象需要多个步骤、多个来源的数据,或者要在多个实现之间选择,就用工厂封装;Session.open(id, capacity) 这种一行就能保证合法的创建,不需要再包一层。

方法名使用业务动作:register、cancel、closeRegistration。setStatus(3) 需要注释才能看懂,而且任何人都可以用它绕过规则。

五、对象转换链 ​

一次请求在各层之间会变换几次形态:

text
RegisterRequest(HTTP 请求体,字段都是字符串)
→ RegisterCommand(用例输入,表达意图)
→ Session / Attendee / Phone(领域对象,构造即合法)
↔ SessionRecord(持久化形状,字段公开、可以为任何值)
→ RegistrationResult(用例输出)→ HttpResponse

DTO、DO、PO、VO 这些名字不重要,重要的是每一次转换都回答了下面的问题:

  • 哪一侧拥有字段的定义?请求体的字段由 API 契约决定,领域对象的字段由规则决定。
  • 丢失字段是否允许?
  • 新旧版本怎样兼容?
  • 非法输入在哪一层被拒绝?
  • 从持久化对象重建聚合时,会不会误触发创建规则或登记事件?

持久化方向的转换最容易出错,因为漏掉一个字段不会报错,只会悄悄丢数据。实验用往返检查防止这一点:200 个随机场次做「记录 → 聚合 → 记录」,结果必须与原记录完全相等。

text
完整转换器:不一致 0 个
漏掉候补表的转换器:不一致 97 个(等于有候补的场次数 97)

站内的 Domain Driven Kit 要求 Entity ↔ PO 显式注册双向映射器,也是出于同样的考虑;自动映射工具在字段同名时很方便,但复杂聚合的映射最好手写,理由见 对象映射方案对比。

六、仓储 ​

仓储(Repository)让领域层觉得聚合存放在一个集合里:

java
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 个类满足这三条规则。三个故意写错的夹具都被拦下:

text
领域对象持有持久化对象:被分层规则与「领域不依赖外层」拦下
  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 的边界不止一个。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权