DDD分层架构实战:解决微服务分布式单体问题
2026/9/24 22:06:38 网站建设 项目流程

1. 从一次重构事故说起:为什么我又把DDD翻了出来

去年年底,我接手了一个跑了三年多的订单系统。表面上看它是个微服务架构,拆了七八个服务,每个服务独立部署、独立数据库,CI/CD流水线也跑得挺顺。但真正进去改需求的时候,我整个人是懵的:一个简单的"下单送积分"逻辑,代码从订单服务跳到积分服务,又从积分服务回调订单服务,中间还夹着一个"公共工具包"里不知道谁写的OrderHelper,里面塞了两千多行代码,订单、库存、支付、积分的逻辑全搅在一起。改一个字段,三个服务跟着编译报错。

这不是微服务,这是分布式单体

我花了大概两周时间做重构,核心思路就是把领域驱动设计(DDD)的分层架构真正落地进去。重构完之后,同样的需求改动,从原来平均两天缩短到半天,服务之间的依赖关系从一张蜘蛛网变成了一棵清晰的树。这篇文章就是那次重构的完整复盘,我会把DDD分层架构的核心思路、每一层的职责边界、依赖倒置怎么用、代码怎么组织、踩过哪些坑,全部摊开讲清楚。

如果你正在做微服务拆分,或者手上的微服务已经出现了"改一处动全身"的症状,又或者你听说过DDD但一直没搞明白它到底怎么落到代码里,那这篇内容应该能帮到你。我不打算讲太多学院派的理论,而是从一个一线开发者的角度,把DDD分层架构拆成可以直接抄的工程实践。

2. DDD分层架构到底在解决什么问题

2.1 微服务拆分的常见误区:按技术分层而不是按业务分

很多人做微服务拆分的时候,第一反应是按技术职责来切:一个服务管数据库访问,一个服务管业务逻辑,一个服务管对外接口。这种切法在项目初期看起来挺整齐,但它有个致命问题——业务逻辑被切碎了

举个例子,"用户下单"这个业务动作,它天然需要校验库存、计算价格、生成订单、扣减余额。如果你按技术分层拆,校验库存在数据服务、计算价格在逻辑服务、生成订单在业务服务,那一个完整的业务动作就被拆到了三个服务里。每次改需求,你都得同时改三个服务,还要保证它们之间的调用顺序不出错。这就是典型的"分布式单体"——物理上拆开了,逻辑上还绑在一起。

DDD的思路完全相反:按业务能力拆分,每个服务是一个完整的业务闭环。订单服务就管订单相关的所有事情,从接收请求到落库,全在它自己内部完成。它需要库存信息?通过定义好的接口去问库存服务要,而不是把库存的逻辑搬到自己这里。

2.2 分层架构的本质:让变化被隔离在最小范围内

DDD分层架构最核心的价值,用一句话概括就是:让每一层只关心自己的事,变化被隔离在最小范围内

传统的三层架构(Controller-Service-DAO)其实也有分层,但它的分层是按技术职责切的,业务逻辑全部堆在Service层。结果就是Service层越来越臃肿,最后变成一个什么都往里塞的"上帝类"。

DDD的分层不一样,它把系统分成四层:

层次职责变化频率依赖方向
用户接口层接收请求、参数校验、返回响应依赖应用层
应用层编排业务流程、事务控制依赖领域层
领域层核心业务逻辑、领域模型不依赖任何层
基础设施层数据库、消息队列、外部服务实现领域层定义的接口

关键点在于领域层不依赖任何其他层。它是整个系统的核心,包含了最纯粹的业务逻辑。其他所有层都是为它服务的。这样设计的好处是:当数据库从MySQL换成PostgreSQL,或者消息队列从RabbitMQ换成Kafka,领域层的代码一行都不用改。

2.3 依赖倒置:让核心业务不被技术细节绑架

依赖倒置原则(Dependency Inversion Principle)是DDD分层架构能成立的关键。它的核心思想是:高层模块不应该依赖低层模块,两者都应该依赖抽象

在传统架构里,业务逻辑直接调用数据库操作,比如orderService.save(order)直接调orderDao.insert(order)。这意味着业务逻辑依赖了具体的数据库实现。如果哪天要换数据库,业务逻辑就得跟着改。

DDD的做法是:领域层定义一个接口OrderRepository,声明"我需要一个能保存订单的东西",但不关心这个东西具体怎么实现。基础设施层去实现这个接口,用MySQL也好,用MongoDB也好,领域层完全不知道。这就是依赖倒置——抽象不依赖细节,细节依赖抽象

// 领域层:定义接口,不关心实现 public interface OrderRepository { void save(Order order); Order findById(OrderId orderId); } // 基础设施层:实现接口,关心具体技术 @Repository public class OrderRepositoryImpl implements OrderRepository { @Autowired private OrderJpaMapper orderJpaMapper; @Override public void save(Order order) { OrderPO po = convertToPO(order); orderJpaMapper.insert(po); } @Override public Order findById(OrderId orderId) { OrderPO po = orderJpaMapper.selectById(orderId.getValue()); return convertToDomain(po); } }

这段代码看起来简单,但它带来的灵活性是巨大的。测试的时候,我可以给领域层注入一个内存实现的OrderRepository,不需要启动数据库就能跑单元测试。换数据库的时候,只需要写一个新的实现类,领域层纹丝不动。

3. 四层架构的职责边界与代码组织

3.1 用户接口层:只做转换,不做业务

用户接口层(User Interface Layer)的职责非常明确:接收外部请求,转换成应用层能理解的输入,然后把应用层的输出转换成外部需要的格式返回

这一层最容易犯的错误是把业务逻辑写进来。比如在Controller里直接判断"如果订单金额大于100就免运费",这就是把业务逻辑泄露到了接口层。正确的做法是把这个判断放到领域层,Controller只负责把请求参数传给应用层。

@RestController @RequestMapping("/orders") public class OrderController { @Autowired private OrderApplicationService orderApplicationService; @PostMapping public Result<OrderDTO> createOrder(@RequestBody CreateOrderRequest request) { // 只做参数转换,不写业务逻辑 CreateOrderCommand command = new CreateOrderCommand( request.getUserId(), request.getItems(), request.getAddress() ); OrderDTO orderDTO = orderApplicationService.createOrder(command); return Result.success(orderDTO); } }

这一层还需要处理一些横切关注点,比如参数校验、异常处理、日志记录。但这些都应该通过注解或AOP来完成,而不是把代码写进业务方法里。

3.2 应用层:编排流程,不写业务规则

应用层(Application Layer)是很多人容易搞混的一层。它的职责是编排,不是实现。它负责把领域层的多个领域对象组合起来,完成一个完整的用例。

举个例子,"创建订单"这个用例,应用层需要做的是:

  1. 调用领域服务创建订单对象
  2. 调用库存服务扣减库存
  3. 调用支付服务发起支付
  4. 保存订单
  5. 发布订单创建事件

注意,应用层只是调用这些操作,具体的业务规则(比如订单金额怎么算、库存够不够)都在领域层实现。

@Service public class OrderApplicationService { @Autowired private OrderDomainService orderDomainService; @Autowired private InventoryService inventoryService; @Autowired private PaymentService paymentService; @Autowired private OrderRepository orderRepository; @Autowired private DomainEventPublisher eventPublisher; @Transactional public OrderDTO createOrder(CreateOrderCommand command) { // 1. 调用领域服务创建订单 Order order = orderDomainService.createOrder( command.getUserId(), command.getItems(), command.getAddress() ); // 2. 扣减库存 inventoryService.deduct(order.getItems()); // 3. 发起支付 paymentService.pay(order); // 4. 保存订单 orderRepository.save(order); // 5. 发布领域事件 eventPublisher.publish(new OrderCreatedEvent(order.getId())); return convertToDTO(order); } }

应用层还有一个重要职责是事务控制。在上面的代码里,@Transactional注解加在应用层方法上,因为只有应用层才知道一个完整的用例包含哪些操作,需要保证哪些操作在同一个事务里。

3.3 领域层:业务逻辑的唯一归属地

领域层(Domain Layer)是整个系统的核心,包含了所有的业务规则和业务逻辑。这一层的代码应该是最纯粹的,不依赖任何框架、任何技术细节。

领域层通常包含以下几类元素:

实体(Entity):有唯一标识的业务对象,比如订单、用户、商品。实体的状态会变化,但标识不变。

public class Order { private OrderId id; private UserId userId; private List<OrderItem> items; private OrderStatus status; private Money totalAmount; // 业务方法:计算总金额 public Money calculateTotalAmount() { return items.stream() .map(OrderItem::getSubTotal) .reduce(Money.ZERO, Money::add); } // 业务方法:确认订单 public void confirm() { if (this.status != OrderStatus.CREATED) { throw new OrderStatusException("只有已创建的订单才能确认"); } this.status = OrderStatus.CONFIRMED; } // 业务方法:取消订单 public void cancel() { if (this.status == OrderStatus.SHIPPED) { throw new OrderStatusException("已发货的订单不能取消"); } this.status = OrderStatus.CANCELLED; } }

值对象(Value Object):没有唯一标识,通过属性值来判断相等性的对象,比如金额、地址、颜色。

public class Money { private final BigDecimal amount; private final String currency; public Money(BigDecimal amount, String currency) { this.amount = amount; this.currency = currency; } public Money add(Money other) { if (!this.currency.equals(other.currency)) { throw new IllegalArgumentException("不能对不同币种进行加法运算"); } return new Money(this.amount.add(other.amount), this.currency); } // 值对象是不可变的,所有修改操作都返回新对象 }

领域服务(Domain Service):当某个业务逻辑不属于任何一个实体或值对象时,把它放到领域服务里。比如"创建订单"这个操作,它需要协调多个对象,不适合放在Order实体里。

领域事件(Domain Event):领域中发生的、有意义的事情,比如"订单已创建"、"支付已完成"。领域事件可以用来解耦不同的领域对象。

3.4 基础设施层:技术细节的实现者

基础设施层(Infrastructure Layer)负责所有技术相关的实现:数据库访问、消息队列、缓存、外部服务调用等。它的核心原则是实现领域层定义的接口,而不是被领域层调用

这一层最常见的实现是Repository模式。领域层定义Repository接口,基础设施层提供具体实现。除了Repository,基础设施层还包括:

  • 消息队列的发送和接收
  • 缓存的读写
  • 外部服务的HTTP调用
  • 文件存储
  • 定时任务
@Repository public class OrderRepositoryImpl implements OrderRepository { @Autowired private OrderMapper orderMapper; @Autowired private OrderItemMapper orderItemMapper; @Override public void save(Order order) { OrderPO orderPO = OrderConverter.toPO(order); orderMapper.insert(orderPO); List<OrderItemPO> itemPOs = order.getItems().stream() .map(OrderConverter::toPO) .collect(Collectors.toList()); orderItemMapper.batchInsert(itemPOs); } @Override public Order findById(OrderId orderId) { OrderPO orderPO = orderMapper.selectById(orderId.getValue()); if (orderPO == null) { return null; } List<OrderItemPO> itemPOs = orderItemMapper.selectByOrderId(orderId.getValue()); return OrderConverter.toDomain(orderPO, itemPOs); } }

注意这里的转换逻辑:数据库的PO对象和领域的Order对象是分开的。这样做的好处是领域对象不需要关心数据库表结构,数据库表结构变化时只需要改Converter,领域对象不受影响。

4. 从零搭建一个DDD分层架构的订单服务

4.1 项目结构规划:按层分包还是按业务分包

在动手写代码之前,先要决定项目结构怎么组织。常见的做法有两种:

按层分包:所有实体放一个包,所有Repository放一个包,所有Service放一个包。

com.example.order ├── interfaces │ ├── controller │ └── dto ├── application │ ├── service │ └── command ├── domain │ ├── entity │ ├── valueobject │ ├── service │ └── repository └── infrastructure ├── persistence └── messaging

按业务分包:每个业务模块一个包,包内再分层。

com.example.order ├── order │ ├── interfaces │ ├── application │ ├── domain │ └── infrastructure ├── payment │ ├── interfaces │ ├── application │ ├── domain │ └── infrastructure

我的建议是:单体应用按层分包,微服务按业务分包。因为微服务本身已经是一个业务边界了,服务内部再按业务分包意义不大。而且按层分包更符合DDD的分层理念,依赖关系一目了然。

4.2 领域层建模:先画事件风暴,再写代码

领域层建模是整个DDD落地过程中最关键的一步。我的做法是先组织一次事件风暴(Event Storming),把业务专家和开发人员拉到一起,用便利贴把业务流程贴出来。

事件风暴的核心产出是:

  • 领域事件:业务中发生的关键事情,用过去式命名,比如"订单已创建"、"库存已扣减"
  • 命令:触发领域事件的动作,比如"创建订单"、"扣减库存"
  • 聚合:一组紧密相关的领域对象的集合,比如"订单"聚合包含Order和OrderItem
  • 限界上下文:业务边界的划分,每个上下文对应一个微服务

以订单服务为例,事件风暴的产出可能是这样的:

命令聚合领域事件
创建订单订单订单已创建
确认订单订单订单已确认
取消订单订单订单已取消
支付订单订单订单已支付

有了这些,领域层的代码就有了骨架。每个聚合对应一个实体类,每个命令对应一个领域方法,每个领域事件对应一个事件类。

4.3 聚合根设计:一致性边界怎么划

聚合(Aggregate)是DDD里最重要的概念之一。一个聚合是一组相关对象的集合,它们作为一个整体被修改。每个聚合有一个聚合根(Aggregate Root),外部只能通过聚合根来访问聚合内的对象。

聚合设计的核心是一致性边界:哪些对象必须在同一个事务里保持一致?这些对象就应该放在同一个聚合里。

以订单为例,Order和OrderItem必须在同一个事务里保持一致——订单创建时,订单项也必须同时创建。所以Order是聚合根,OrderItem是聚合内的实体。

public class Order { private OrderId id; private List<OrderItem> items; private OrderStatus status; // 聚合根负责维护聚合内的一致性 public void addItem(ProductId productId, int quantity, Money price) { if (this.status != OrderStatus.CREATED) { throw new OrderStatusException("只有已创建的订单才能添加商品"); } OrderItem item = new OrderItem(productId, quantity, price); this.items.add(item); } public void removeItem(OrderItemId itemId) { if (this.status != OrderStatus.CREATED) { throw new OrderStatusException("只有已创建的订单才能删除商品"); } this.items.removeIf(item -> item.getId().equals(itemId)); } }

聚合设计的一个常见误区是聚合过大。有人把User、Order、Product都放在一个聚合里,结果就是每次操作都要加载大量数据,性能极差。正确的做法是:聚合尽量小,只包含必须保持一致的对象。Order和User之间通过ID关联,而不是对象引用。

4.4 仓储实现:领域层定义接口,基础设施层实现

仓储(Repository)是领域层和基础设施层之间的桥梁。领域层定义接口,基础设施层实现。这个模式看起来简单,但有几个细节需要注意。

第一,仓储的接口应该用领域语言。不要用insertupdatedelete这种数据库术语,而应该用savefindByIdremove这种领域术语。

第二,仓储的返回值应该是领域对象,而不是数据库的PO对象。领域层不应该知道PO的存在。

第三,仓储的实现要考虑性能。比如查询订单时,是否需要同时加载订单项?如果每次查询都加载所有订单项,性能会很差。我的做法是提供两个方法:findById加载完整聚合,findSummaryById只加载订单基本信息。

// 领域层接口 public interface OrderRepository { void save(Order order); Order findById(OrderId orderId); OrderSummary findSummaryById(OrderId orderId); List<Order> findByUserId(UserId userId); } // 基础设施层实现 @Repository public class OrderRepositoryImpl implements OrderRepository { @Override public Order findById(OrderId orderId) { OrderPO orderPO = orderMapper.selectById(orderId.getValue()); List<OrderItemPO> itemPOs = orderItemMapper.selectByOrderId(orderId.getValue()); return OrderConverter.toDomain(orderPO, itemPOs); } @Override public OrderSummary findSummaryById(OrderId orderId) { OrderPO orderPO = orderMapper.selectById(orderId.getValue()); return OrderConverter.toSummary(orderPO); } }

4.5 应用服务编排:事务边界与领域事件发布

应用服务是编排层,它负责把领域层的操作组合成一个完整的用例。在编排过程中,有两个关键点需要处理好:事务边界领域事件发布

事务边界应该划在应用层,因为只有应用层知道一个用例包含哪些操作。在上面的createOrder方法里,@Transactional注解保证了创建订单、扣减库存、保存订单这三个操作在同一个事务里。

领域事件发布有两种时机:事务提交前事务提交后。事务提交前发布的问题是,如果事务回滚了,事件已经发出去了,会导致数据不一致。事务提交后发布的问题是,如果发布失败,事件就丢了。

我的做法是使用事务同步机制,在事务提交后发布事件:

@Service public class OrderApplicationService { @Autowired private ApplicationEventPublisher eventPublisher; @Transactional public OrderDTO createOrder(CreateOrderCommand command) { Order order = orderDomainService.createOrder(command); orderRepository.save(order); // 注册事务同步回调,事务提交后发布事件 TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCommit() { eventPublisher.publishEvent(new OrderCreatedEvent(order.getId())); } } ); return convertToDTO(order); } }

5. 依赖倒置在微服务架构中的实战应用

5.1 服务间调用:定义防腐层,隔离外部变化

在微服务架构里,服务之间需要互相调用。如果直接在订单服务里写调用库存服务的代码,订单服务就依赖了库存服务的具体实现。库存服务的接口一变,订单服务就得跟着改。

DDD的解决方案是防腐层(Anti-Corruption Layer)。订单服务定义一个InventoryService接口,声明"我需要一个能扣减库存的东西",然后在基础设施层实现这个接口,去调用库存服务的HTTP接口。

// 领域层:定义接口 public interface InventoryService { void deduct(List<OrderItem> items); void restore(List<OrderItem> items); } // 基础设施层:实现接口,调用外部服务 @Component public class InventoryServiceImpl implements InventoryService { @Autowired private InventoryFeignClient inventoryFeignClient; @Override public void deduct(List<OrderItem> items) { DeductRequest request = new DeductRequest(); request.setItems(items.stream() .map(item -> new DeductItem(item.getProductId(), item.getQuantity())) .collect(Collectors.toList())); Result<Void> result = inventoryFeignClient.deduct(request); if (!result.isSuccess()) { throw new InventoryDeductException(result.getMessage()); } } }

这样设计的好处是:库存服务的接口变了,只需要改InventoryServiceImpl,领域层和应用层完全不受影响。而且测试的时候,可以给领域层注入一个Mock实现,不需要启动库存服务就能跑测试。

5.2 领域事件驱动:用事件解耦跨服务协作

除了直接调用,微服务之间还可以通过领域事件来协作。比如订单创建后,需要通知积分服务给用户加积分。如果直接在订单服务里调用积分服务,订单服务就依赖了积分服务。用事件驱动的方式,订单服务只需要发布一个"订单已创建"事件,积分服务订阅这个事件,自己决定怎么处理。

// 订单服务:发布事件 @Service public class OrderApplicationService { @Autowired private DomainEventPublisher eventPublisher; @Transactional public OrderDTO createOrder(CreateOrderCommand command) { Order order = orderDomainService.createOrder(command); orderRepository.save(order); eventPublisher.publish(new OrderCreatedEvent( order.getId().getValue(), order.getUserId().getValue(), order.getTotalAmount() )); return convertToDTO(order); } } // 积分服务:订阅事件 @Component public class OrderCreatedEventListener { @Autowired private PointApplicationService pointApplicationService; @EventListener public void handle(OrderCreatedEvent event) { pointApplicationService.addPoints( event.getUserId(), calculatePoints(event.getTotalAmount()) ); } }

事件驱动的好处是解耦:订单服务不需要知道积分服务的存在,积分服务挂了也不影响订单创建。但代价是最终一致性:订单创建成功后,积分可能过几秒才到账。这个 trade-off 需要根据业务场景来权衡。

5.3 接口定义的艺术:用领域语言而不是技术语言

依赖倒置的关键是定义好接口。接口定义得好,系统就灵活;定义得不好,还不如直接调用。

我的经验是:接口要用领域语言,而不是技术语言。比如:

  • 好的接口:inventoryService.deduct(items)——用业务术语"扣减库存"
  • 不好的接口:inventoryService.updateStock(productId, quantity)——用技术术语"更新库存"

再比如:

  • 好的接口:paymentService.pay(order)——用业务术语"支付"
  • 不好的接口:paymentService.createPaymentRecord(orderId, amount)——用技术术语"创建支付记录"

用领域语言定义接口的好处是,接口的语义更清晰,而且不容易被技术实现绑架。如果哪天支付方式从微信支付换成支付宝,pay这个接口不需要变,只需要换一个实现。

6. 踩坑实录:DDD落地过程中最常见的五个问题

6.1 贫血模型:把领域层写成了数据容器

这是DDD落地过程中最常见的问题。很多人把领域层写成了这样:

// 贫血模型:只有getter/setter,没有业务逻辑 public class Order { private Long id; private Long userId; private List<OrderItem> items; private Integer status; // 只有getter/setter }

所有的业务逻辑都写在了Service里,领域对象变成了纯粹的数据容器。这就是贫血模型,它违背了DDD的初衷。

正确的做法是把业务逻辑放到领域对象里:

// 充血模型:业务逻辑在领域对象里 public class Order { private OrderId id; private UserId userId; private List<OrderItem> items; private OrderStatus status; public void confirm() { if (this.status != OrderStatus.CREATED) { throw new OrderStatusException("只有已创建的订单才能确认"); } this.status = OrderStatus.CONFIRMED; } public Money calculateTotalAmount() { return items.stream() .map(OrderItem::getSubTotal) .reduce(Money.ZERO, Money::add); } }

判断一个领域对象是不是充血模型,有个简单的标准:如果把这个对象的所有getter/setter去掉,它还剩下什么?如果什么都不剩,那就是贫血模型。

6.2 聚合过大:一个聚合加载了整张数据库表

聚合设计的一个常见错误是聚合过大。有人把Order、OrderItem、OrderLog、OrderAttachment都放在一个聚合里,结果每次加载订单都要加载所有关联数据,性能极差。

聚合的设计原则是:只把必须保持一致的对象放在同一个聚合里。OrderLog和OrderAttachment不需要和Order保持强一致,它们可以独立存在,通过OrderId关联。

// 不好的设计:聚合过大 public class Order { private OrderId id; private List<OrderItem> items; private List<OrderLog> logs; // 不需要强一致 private List<OrderAttachment> files; // 不需要强一致 } // 好的设计:聚合精简 public class Order { private OrderId id; private List<OrderItem> items; // 必须和Order强一致 } // OrderLog和OrderAttachment独立存在 public class OrderLog { private OrderId orderId; // 通过ID关联 // ... }

6.3 领域事件滥用:把事件当成了异步调用的万能药

领域事件是个好东西,但滥用会带来问题。有人把所有的跨服务调用都改成了事件驱动,结果系统变得难以调试:一个请求发出去,不知道会触发哪些事件,也不知道事件的处理顺序。

我的经验是:只有真正需要解耦的场景才用事件。比如:

  • 订单创建后通知积分服务加积分——适合用事件,因为积分到账可以延迟
  • 订单创建前校验库存——不适合用事件,因为库存校验必须同步完成

另外,事件驱动会带来最终一致性问题。如果业务不能接受最终一致,就不要用事件。

6.4 仓储泄露:领域层直接依赖了JPA

这个问题在Spring Data JPA项目里特别常见。有人直接在领域层定义JpaRepository,结果领域层依赖了Spring Data JPA。

// 不好的设计:领域层依赖JPA public interface OrderRepository extends JpaRepository<OrderPO, Long> { // ... }

正确的做法是领域层定义纯粹的接口,基础设施层实现:

// 领域层:纯粹接口 public interface OrderRepository { void save(Order order); Order findById(OrderId orderId); } // 基础设施层:JPA实现 @Repository public class OrderRepositoryImpl implements OrderRepository { @Autowired private OrderJpaRepository orderJpaRepository; @Override public void save(Order order) { OrderPO po = OrderConverter.toPO(order); orderJpaRepository.save(po); } }

6.5 过度设计:小项目硬套DDD

DDD不是银弹,不是所有项目都适合。如果一个项目只有几个简单的CRUD接口,硬套DDD只会增加复杂度。

我的判断标准是:如果业务逻辑复杂到需要和业务专家反复沟通才能理清,那就适合DDD;如果业务逻辑简单到看一眼需求文档就能写代码,那就不需要DDD

对于简单项目,传统的三层架构反而更高效。DDD的价值在于管理复杂性,如果本身不复杂,DDD就是过度设计。

7. 一些实操心得和工具推荐

7.1 从单体到微服务的渐进式拆分策略

如果你手上是一个单体应用,想往DDD微服务架构迁移,我的建议是渐进式拆分,不要一次性全拆。

第一步:在单体应用内部按DDD分层重构,把业务逻辑从Service层下沉到领域层。这一步不需要拆服务,但能让代码结构变清晰。

第二步:识别限界上下文,把关联最紧密的模块先拆出来。比如订单和订单项关联紧密,先拆订单服务;用户和权限关联紧密,先拆用户服务。

第三步:服务拆出来后,通过防腐层隔离服务间的依赖。先用同步调用,等稳定了再考虑事件驱动。

第四步:持续重构,逐步把剩下的模块拆完。

7.2 代码审查清单:怎么判断DDD落地是否到位

每次代码审查的时候,我会用下面这个清单来检查DDD落地情况:

检查项合格标准常见问题
领域层依赖不依赖任何框架领域层出现@Autowired
业务逻辑位置在领域对象里业务逻辑写在Service里
聚合大小只包含强一致对象聚合加载了整张表
仓储接口用领域语言命名用insert/update命名
领域事件只在需要解耦时使用所有调用都改成事件
应用层职责只编排,不实现应用层写了业务规则

7.3 测试策略:领域层单元测试怎么写

DDD分层架构的一个好处是领域层可以独立测试。因为领域层不依赖任何框架,测试的时候不需要启动Spring容器,直接new对象就能测。

public class OrderTest { @Test public void should_throw_exception_when_confirm_shipped_order() { // given Order order = new Order(new OrderId(1L), new UserId(1L)); order.confirm(); order.ship(); // when & then assertThrows(OrderStatusException.class, () -> order.confirm()); } @Test public void should_calculate_correct_total_amount() { // given Order order = new Order(new OrderId(1L), new UserId(1L)); order.addItem(new ProductId(1L), 2, new Money(10, "CNY")); order.addItem(new ProductId(2L), 1, new Money(20, "CNY")); // when Money total = order.calculateTotalAmount(); // then assertEquals(new Money(40, "CNY"), total); } }

这种测试跑起来非常快,几百个测试几秒钟就能跑完。而且因为不依赖数据库,测试结果很稳定,不会出现"在我机器上能跑"的问题。

7.4 常用工具和框架推荐

最后推荐几个我在DDD落地过程中常用的工具:

建模工具:Event Storming用便利贴就行,远程协作可以用Miro或Excalidraw。

代码框架:Spring Boot + Spring Data JPA是主流选择。如果追求更纯粹的DDD,可以考虑Axon Framework,它内置了聚合、事件溯源等DDD概念。

架构守护:ArchUnit可以用来做架构约束检查,比如"领域层不能依赖基础设施层"这种规则可以用ArchUnit写成测试用例,CI的时候自动检查。

@AnalyzeClasses(packages = "com.example.order") public class ArchitectureTest { @ArchTest public static final ArchRule domain_should_not_depend_on_infrastructure = noClasses().that().resideInAPackage("..domain..") .should().dependOnClassesThat().resideInAPackage("..infrastructure.."); @ArchTest public static final ArchRule domain_should_not_depend_on_spring = noClasses().that().resideInAPackage("..domain..") .should().dependOnClassesThat().resideInAPackage("org.springframework.."); }

这两个规则加上之后,CI会自动拦截违反分层架构的代码提交。我实测下来很稳,团队里再也没人把@Autowired写到领域层了。

事件溯源:如果业务需要完整的审计日志,可以考虑事件溯源(Event Sourcing)。不过事件溯源会显著增加系统复杂度,除非业务明确需要,否则不建议一开始就上。

8. 最后再分享几个小技巧

关于领域对象的ID设计,我踩过一个坑。一开始我用数据库自增ID作为领域对象的ID,结果领域层就依赖了数据库。后来改成用UUID或者雪花算法生成的ID,领域层就完全独立了。如果你也在用自增ID,建议尽早改成应用层生成的ID。

关于DTO和领域对象的转换,我建议用MapStruct而不是手写Converter。手写Converter代码量大,而且容易漏字段。MapStruct在编译期生成转换代码,性能好,而且字段不匹配的时候编译就会报错。

关于领域事件的命名,我建议用过去式,比如OrderCreatedEvent而不是CreateOrderEvent。过去式表示"已经发生的事情",语义更准确。而且事件是不可变的,不应该有setter。

关于聚合根的引用,我建议用ID引用而不是对象引用。比如Order引用User,应该用UserId而不是User对象。这样聚合之间就是松耦合的,加载Order的时候不需要加载User。

关于应用层的返回值,我建议返回DTO而不是领域对象。领域对象包含业务逻辑,不应该暴露给外部。而且领域对象的结构可能会变化,DTO可以保持稳定。

这些技巧都是我在实际项目中踩坑总结出来的,不一定适用于所有场景,但至少可以帮你少走一些弯路。DDD落地是个持续迭代的过程,不要指望一次就做到完美,先跑起来,再慢慢优化。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询