⭐ 推荐:社区规则条款 V1.0

Java 项目架构设计:DDD 领域驱动设计落地实践

东来东往
东来东往 正式会员正式会员认证极客认证极客
发布于 2026-10-10 06:00 ·3 浏览 ·3 回复

学完这篇,你能把一个"增删改查一把梭"的 Java 项目,按 DDD 的方式重新拆出领域层、应用层和基础设施层,并且知道每一层该写什么、不该写什么。

DDD 落地失败的项目,九成不是理论没学懂,而是一上来就建了 domain、application 一堆包,然后把原来的 Service 代码整包搬了进去——包变了,耦合没变。下面按顺序来,一步一个可检查的动作。

第一步:先划限界上下文,别急着建包

打开你的需求文档或数据库表清单,把业务名词分组。比如电商系统里,「订单」「支付」「库存」「物流」各自是一组,而不是一张表一个类。

分组后画一张上下文映射图,标清楚谁调用谁。电商里通常是:订单上下文通过「支付成功」事件通知库存上下文,而不是订单服务直接调库存 Mapper。

注意:限界上下文是业务边界,不是技术边界。如果你发现两个上下文共享同一张表,说明边界划错了,回头重新分。

第二步:给每个上下文搭四层骨架

以订单上下文为例,一个上下文的包结构长这样:

order/
├── domain/            # 领域层:实体、值对象、聚合、领域服务、仓储接口
│   ├── model/
│   ├── repository/    # 只有接口
│   └── event/
├── application/       # 应用层:用例编排、事务边界
│   ├── service/
│   └── dto/
├── infrastructure/    # 基础设施层:仓储实现、Mapper、RPC、MQ
└── interfaces/        # 接口层:Controller、定时任务入口

依赖方向只有一条:interfaces → application → domain,infrastructure → domain。domain 层不依赖任何其他层,也不依赖 Spring。

注意:先在 pom.xml 里用 Maven 模块或 ArchUnit 测试把这条依赖规则钉死,否则三个月后一定有人从 domain 里 import 一个 Mapper。

第三步:用聚合根守住一致性边界

聚合是 DDD 里最容易被忽略、也最值钱的部分。规则很简单:一次事务只修改一个聚合,聚合内部的一致性由聚合根负责。

public class Order {           // 聚合根
    private OrderId id;
    private List<OrderItem> items;  // 内部实体
    private Money total;
    private OrderStatus status;

    public void pay(Money paid) {
        if (status != OrderStatus.CREATED) {
            throw new OrderStatusException("订单状态不允许支付");
        }
        if (!paid.equals(total)) {
            throw new AmountMismatchException();
        }
        this.status = OrderStatus.PAID;
        registerEvent(new OrderPaidEvent(id));
    }
}

Money、OrderId 用值对象(不可变、通过值相等),别用裸的 BigDecimal 和 Long——这是最省事的防错手段。

注意:订单和订单项可以在一个聚合里,但订单和用户不行。如果两个对象需要强一致,才放同一个聚合;否则用 ID 引用,靠领域事件最终一致。

第四步:仓储接口放领域层,实现在基础设施层

domain 层只声明接口,用领域语言命名:

public interface OrderRepository {
    Order findById(OrderId id);
    void save(Order order);
}

infrastructure 层用 MyBatis 或 JPA 实现它,负责对象与表结构的转换。领域层完全不知道数据库长什么样。

注意:仓储的粒度是"聚合",不是"表"。一个聚合对应一个 Repository,不要给 OrderItem 单独建仓储。

第五步:应用服务只做编排,不写业务

应用服务的职责是:加载聚合 → 调用领域方法 → 保存聚合 → 发事件。它应该是"薄"的。

@Service
public class OrderApplicationService {
    @Transactional
    public void pay(PayCommand cmd) {
        Order order = orderRepository.findById(new OrderId(cmd.getOrderId()));
        order.pay(Money.of(cmd.getAmount()));
        orderRepository.save(order);
        eventPublisher.publish(order.pullEvents());
    }
}

如果这个方法里出现了 if (status == ...) 这类状态判断,说明业务逻辑漏出到应用层了,搬回聚合根。

第六步:加一层防腐层隔离外部系统

调用第三方支付、老系统接口时,在 infrastructure 层写一个 Adapter,把对方的数据结构翻译成你领域内的对象。对方的字段改名、接口升级,只改这一个类。

第七步:用领域事件解耦跨上下文

OrderPaidEvent 发布后,库存上下文订阅并扣减库存。这样订单上下文不需要知道库存的存在。

注意:本地事件用 @TransactionalEventListener 绑定事务提交后触发,避免事务回滚了事件已经发出去。跨进程才上 MQ。

小结

  • 先划限界上下文,再建包,顺序反了必返工
  • 依赖方向单向:interfaces → application → domain ← infrastructure
  • 聚合根负责不变量,一次事务只改一个聚合
  • 仓储接口在领域层,实现在基础设施层,粒度按聚合
  • 应用服务只编排,出现业务判断就是漏了
  • 跨上下文用领域事件,别直接互调 Mapper
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-772.html
转载请注明出处,版权归原作者所有。

全部回复 3

runyu
runyu 正式会员正式会员认证极客认证极客 1楼 2026-10-10 06:05

这套拆分顺序是对的,尤其"先划上下文再建包"这一步,能省掉后面半年返工——大部分 DDD 项目死就死在第三步才开始想边界。

补几个落地时最容易翻车的点。

领域事件别在 domain 里直接发 MQ。 聚合根 registerEvent 只是把事件挂在自己身上,真正的投递必须在事务提交之后。常见错法是应用层拿到事件就 rocketMQTemplate.send(),结果消息发出去了、事务回滚了,下游扣了库存订单却没落库。正确姿势是应用层存完聚合后,由 infrastructure 层的发布器用 @TransactionalEventListener(phase = AFTER_COMMIT) 投递,或者干脆落一张 outbox 表让定时任务扫——后者更稳,能扛进程崩溃。

依赖约束建议两层都上。 单模块 + ArchUnit 起步就够(跑得快、能进 CI),等上下文稳定、团队超过两三个人再拆 Maven 模块。一上来拆多模块,改一个字段要动四个 pom,多数团队撑不过两周就把拆出来的模块又合并了。再加一招:domain 里的类构造器设成包级私有,外部只能通过领域方法改状态,这比 ArchUnit 更"物理"。

仓储接口放 domain 没错,但要防它退化成 DAO。 签名应该只返回聚合根、只接受聚合根,不该出现 Page<OrderDO>、QueryWrapper 这类东西。列表页、报表这类复杂查询直接走 application 层查读模型,别硬塞进仓储,否则聚合根会被查询需求拖成贫血对象。

最后,registerEvent(new 后面断了,方便的话补完整——这段正好是别人最想抄的部分。

延伸一句:如果你现在项目只有一个聚合根在真正维护一致性,别为了"四层完整"硬凑 domain service 和 event,先跑通再长出来,能省一堆没必要的抽象。

不语
不语 正式会员正式会员认证极客认证极客 #617 2楼 2026-10-10 06:10
runyu:这套拆分顺序是对的,尤其"先划上下文再建包"这一步,能省掉后面半年返工——大部分 DDD 项目死就死在第三步才开始想边界。 补几个落地时最容易翻车的点。 *…

你补的这几点基本都是踩过才知道的,尤其 outbox 那条——我补两个细节,再把你说的那段断码补完。

outbox 必须和业务同库同事务,这点容易漏:把 outbox 写到另一个库(或者直接发 MQ)就等于白做。扫描投递那侧要做幂等,消费方得有去重表或唯一键,否则进程重启后重复扫描会二次扣库存。@TransactionalEventListener 也有个坑——默认 fallbackExecution=false,调用路径上没事务时事件直接静默丢失,单测里特别容易踩,本地跑通了上生产才发现不发。

ArchUnit 那条建议直接落成一行,进 CI 比什么文档都管用:

noClasses().that().resideInAPackage("..domain..")
    .should().dependOnClassesThat()
    .resideInAnyPackage("org.springframework..", "..infrastructure..");

包级私有构造器那招我加一句:配合 protected 的 registerEvent,domain 层就真的"物理封闭"了。

补上断掉的那段,聚合根内部收事件、应用层拉取:

private final transient List<DomainEvent> events = new ArrayList<>();

protected void registerEvent(DomainEvent e) { events.add(e); }

public List<DomainEvent> pullEvents() {
    var snapshot = List.copyOf(events);
    events.clear();          // 必须清,否则 afterCommit 重发
    return snapshot;
}

transient 也别省,序列化时会出事。

最后顺着你"别硬凑 domain service"说一句判断标准:只有跨多个聚合、或者这个概念确实不属于任何一个实体(比如"计价策略")时才建,否则一律是聚合根的方法。为凑层数造出来的 service,最后都会变成穿了马甲的 Manager。

真正容易翻车的是 pullEvents() 被调两次或者忘了 clear()——这类 bug 不报错、只在特定时序下重复投递,比依赖违规难查得多,建议应用层只在一处调用它。

小易先生
小易先生 见习用户见习用户 #618 3楼 2026-10-10 06:18
不语:你补的这几点基本都是踩过才知道的,尤其 outbox 那条——我补两个细节,再把你说的那段断码补完。 **outbox 必须和业务同库同事务**,这点容易漏:…

pullEvents() 这层其实有现成轮子:让聚合根继承 Spring Data 的 AbstractAggregateRoot,用 @DomainEvents 暴露事件、@AfterDomainEventPublication 负责清空,仓储 save() 时框架自动接管发布时机,手写 clear() 的风险直接归零。不想引 Spring Data 也行,那就把 pullEvents() 收成包级私有、只让 infrastructure 的发布器调用——只要它是 public,迟早会有人从第二个地方调,你说这类 bug 不报错还只在特定时序复现,这点我完全同意。

多实例扫 outbox 再补一句:别用 status = 'NEW' 裸查,两个节点必然抢同一条。要么 SELECT ... FOR UPDATE SKIP LOCKED 取批,要么状态机走 NEW → SENDING → SENT 并加超时回捞——SENDING 卡住超过 N 分钟放回 NEW,否则进程一崩消息就永久停在中间态,比重复投递更难发现。

domain 层还有个比 ArchUnit 更直观的验收标准:domain 的单元测试跑起来不该需要 Spring 上下文、不该连数据库。哪天有人给 domain 测试加了 @SpringBootTest,说明依赖已经漏进去了,比盯包名可靠得多,而且这个信号是自动的、不用维护规则文件。

你那条 domain service 判断标准我加个反向信号:如果它注入了三个以上仓储,八成是编排逻辑,该上移到 application 层——domain service 可以跨聚合,但跨太多就是用例了。

最后顺一句 outbox 的收尾:去重键用业务 eventId(UUID/雪花)而不是自增 ID,跨库对齐时才对得上;已 SENT 的记录要定期归档,不然一年后几千万行扫表会越来越慢,这个坑通常等监控报警才被发现。