见过太多代码出问题不是业务难,而是业务逻辑全堆在 Service 里的项目。二十万行代码里一个 Service 类七八千行,一个方法能同时干五件事:参数校验、查库、算价格、调外部接口、组装返回结果。改一个促销规则要在三个模块里找代码,稍不留神就漏改一个入口。这种问题不是某个人代码水平差,而是架构上根本没有把业务复杂度拆开。DDD 分层就是专门解决这种问题的思路,这套方法的核心不是教你多写几个包,而是重新划分代码的职责边界,让业务规则有一个稳定的归属地。
这篇文章是 DDD 学习系列的第 1 课,主题是项目介绍和 DDD 分层架构。我会用真实项目的视角,把四层架构的职责边界、依赖规则、落地顺序讲清楚,并附上完整的订单场景示例。适合正在从传统三层架构往 DDD 转型的后端开发,也适合刚接触 DDD、想知道分层到底是什么的读者。看完之后你至少能回答三个问题:为什么要分这几层、每层该写什么不该写什么、怎么在项目里真正落下去。
1. 这个学习项目到底在学什么
1.1 DDD 要解决的真实痛点
领域驱动设计(Domain-Driven Design,简称 DDD)最早是 Eric Evans 在 2004 年提出的,但这两年又火起来,不是因为大家突然爱学习了,而是微服务拆到一定程度后,很多人发现服务内部还是一团乱麻。微服务只是解决了系统之间的边界问题,服务内部的代码质量反而更容易失控。
举个很常见的例子:一个订单模块,传统写法通常是 Controller->Service->DAO 三层。Controller 拿到请求,调 Service 里的 createOrder 方法,Service 里先校验用户、查商品、算优惠、扣库存、生成订单号、再插入数据库。听上去没问题,但业务规则一变就麻烦了。促销规则从“满 100 减 20”改成“满 100 打 8 折且限购 5 件”,你得在 Service 里找到那段算价逻辑,小心翼翼地改掉,还要祈祷没有别的调用方也在用这个字段。如果再来个“部分地区不参与活动”,Service 的判断条件会越来越长,最后变成没人敢动的屎山。
DDD 的思路是:不要在 Service 里堆业务逻辑,而是把业务的核心规则放到领域模型里。所谓“领域模型”,就是把某个业务概念(比如订单、商品、库存)建模成代码对象,对象自身就知道该怎么校验、怎么计算、怎么改变状态。Service 退化成只做流程编排,不负责业务判断。这个过程落到代码结构上,就是 DDD 分层。
1.2 第 1 课的学习边界与整体路线
DDD 本身是个很大的知识体系,光战术设计就有实体、值对象、聚合、仓储、领域服务、领域事件一堆概念,更不用说战略设计里的限界上下文、上下文映射、事件风暴这些。第 1 课不贪多,只专注于两件事:项目介绍和分层架构。项目介绍让你知道这套系列课会用一个贯穿始终的实战项目来演示,不是讲完理论就散;分层架构则把代码的物理结构搭出来,让后续的每个 DDD 概念都有地方安放。
打个比方,分层架构是盖房子的框架,实体、值对象是砖块,聚合是预制板,领域事件是门窗。第 1 课先把框架立起来,后面往框架里塞砖块就顺理成章了。如果一上来就讲聚合和领域事件,没有分层做承载,听的时候全懂,写代码的时候还是不知道放到哪个包、依赖谁、被谁依赖。
学习路线后面的安排大致是这样的:第 2 课讲实体与值对象,第 3 课讲聚合与仓储,第 4 课讲领域服务与应用服务怎么拆分,第 5 课讲领域事件与集成。每一课都会回到第 1 课搭好的分层骨架里,往里面填东西。所以这一课的分层不是一次性讲完就丢掉,而是贯穿整个系列的骨架。
1.3 哪些人适合先看这一课
如果你是写后端三年左右的开发者,日常工作是 CRUD 加简单业务逻辑,觉得现在代码还“能跑”,但总隐隐觉得不对劲,开始关注分层和架构,那这一课非常适合你。如果你已经在团队里尝试过 DDD,但是把领域对象只当成只有 getter/setter 的 POJO,业务逻辑还是写在 Service 里,那这一课刚好帮你纠正方向。如果你是技术负责人,想评估 DDD 能否引入团队,可以先通过分层部分判断落地成本,这一课的实操场景可以直接拿去做内部分享的素材。
如果你是完全没有后端经验的新人,这一课也能看,但建议先补一下 Spring 和数据库的基础,否则代码示例部分会有点吃力。DDD 不会降低编码门槛,它只是改变了组织代码的方式。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. DDD 分层架构的设计思路
2.1 为什么传统三层架构会失控
传统三层架构把系统分成 Controller、Service、DAO 三个层次,看起来清晰明了,但随着业务增长,Service 层会越来越膨胀。原因是三层架构只规定了“调用关系”,没有规定“职责边界”。Service 里既承担了业务流程编排,又承担了业务规则校验,还承担了多表关联查询,甚至偶尔还要组装数据返回给前端。所有不属于它该干的事,因为没有明说它不该干,最后都压到它头上。
真正的失控点是“业务规则散落”。同一个需求,比如“订单总额超过 1000 元需要走人工审核”,可能在 Controller 里有一份判断、Service 里有一份判断、某个定时任务里又写了一份判断。当规则改成 2000 元的时候,你根本不知道要改几处。三层架构没有提供一个“唯一放业务规则”的地方,所以规则就被到处复制。
DDD 分层的第一步,就是强制规定:业务规则只放在领域层。其他层可以调用领域层,但不得自己实现核心业务规则。这个约束比“分层”本身更重要。
2.2 分层的本质是边界与依赖规则
DDD 经典四层架构从上到下分别是:用户接口层(Interface)、应用层(Application)、领域层(Domain)、基础设施层(Infrastructure)。这里要特别注意,网上很多图把基础设施层画在最上方,也有画在最下方的,方向不是重点,重点在于依赖关系是自上而下还是自下而上。
严格按 DDD 的依赖规则,用户接口层依赖应用层,应用层依赖领域层,基础设施层也依赖领域层,但领域层不依赖任何其他层。换句话说,领域层是独立于技术细节的纯业务模块,这也是 DDD 最核心的一条依赖规则。现实中常用 Spring 框架,领域层往往不引入 Spring 的注解,也不直接依赖 MyBatis/JPA 的注解,就是为了保持纯净。
这条规则的直接好处是:业务规则可以脱离数据库和框架独立测试。你想验证“订单金额满 1000 需要人工审核”,只需要 new 一个 Order 对象,调用领域方法,断言返回值,不需要启动 Spring 容器,也不需要连数据库。测试速度从秒级降到毫秒级,开发反馈循环大大缩短。
2.3 依赖倒置:让领域层成为系统的中心
如果领域层不依赖基础设施层,那数据库操作怎么办?答案是用接口隔离。在领域层定义一个仓储接口(Repository),比如 OrderRepository,里面声明 save(Order order) 和 findById(OrderId id) 方法。基础设施层提供该接口的实现类,真正去操作数据库、调用 MyBatis 的 Mapper。领域层只依赖接口,不关心实现。
这就是依赖倒置原则(DIP,Dependency Inversion Principle)在 DDD 里的具体应用。依赖倒置讲的是:抽象不应该依赖细节,细节应该依赖抽象。放在 DDD 分层里,仓储接口是抽象,数据库操作是细节,接口定义在领域层,实现放在基础设施层,所以基础设施层依赖领域层。
刚接触这套思想的人容易抵抗:“这么绕一圈,数据操作不还是要写吗?”没错,代码量并不会减少,但依赖方向变了。以前是业务代码依赖数据库技术,现在是数据库技术依赖业务接口,这个“颠倒”带来的好处是业务层完全不用关心数据库选型,你可以用 MySQL、PostgreSQL、内存数据库或者一个假的 Mock 实现来测试业务,业务代码一行都不用改。
3. 四层架构核心职责逐一拆解
3.1 用户接口层:只做协议的翻译
用户接口层负责接收外部输入、返回外部输出,常见表现形态是 RESTful Controller、GraphQL Resolver、消息监听器。这一层只做协议转换和参数校验,不做业务判断。比如前端传一个 JSON 请求体,Controller 把它转成应用层能理解的 Command 对象,再调用应用服务;拿到返回结果后,转成 DTO/Response 对象返回给前端。
哪些代码属于用户接口层?参数格式校验(比如必填字段、长度限制)、返回码封装、请求日志打点、认证信息的初步获取。哪些代码不应该出现在这一层?算价格、校验订单状态、判断用户权限(部分场景可例外,比如简单接口鉴权)。如果你在 Controller 里看到 if (order.getStatus() == OrderStatus.PAID) 这类业务判断,就说明业务规则泄漏到了接口层。
实操中很容易犯的错是在 Controller 里直接操作领域对象。比如把 Order 实体直接作为 HTTP 方法的返回参数,序列化出去。这会导致领域对象跟外部协议绑定,以后内部领域模型改一个字段,对外接口也要跟着变。正确的做法是引入 Assembler 或者 MapStruct,把领域对象转成 DTO,再暴露出去。
3.2 应用层:编排用例,不写规则
应用层是整个系统的“导演”,它负责编排一次业务请求需要做的事情。比如创建订单这个用例,应用服务要做的是:接收 Command,创建领域对象,调用仓储接口保存,协调外部服务校验库存,最后返回结果。它只负责“流程”,不负责“决策”。
有个判断标准很实用:如果一句话里包含“该不该”“能不能”“值多少”这类需要业务知识才能回答的问题,那这句话的逻辑就应该放在领域层;如果一句话是“先做 A,再做 B,最后做 C”这样的顺序安排,那就是应用层的职责。比如“订单超过 1000 元需要人工审核”是领域层的规则,“先查用户,再创建订单,然后扣库存”是应用层的流程。
应用层还负责事务边界和权限控制。一个用例通常对应一个事务,@Transactional 加在应用服务方法上比较合理。权限校验如果只是判断“是否登录用户”,可以放在接口层;如果涉及“该用户是否有权限操作这笔订单”,可能需要在应用层做资源级的权限判断。不过要注意,应用层不要写太多 if else,把大量业务分支逻辑留给领域层。
3.3 领域层:业务规则的家
领域层是 DDD 的心脏,包含实体(Entity)、值对象(Value Object)、聚合(Aggregate)、领域服务(Domain Service)、仓储接口(Repository Interface)以及领域事件(Domain Event)。这一层要放进所有不该丢失的业务规则。简单说,如果这个规则不成立,业务就会出错,那它就属于领域层。
比如一个订单实体会约束:订单创建成功后状态为待支付;已支付订单不能二次支付;取消订单会导致库存释放。这些都是领域不变量,体现在实体方法中。订单的金额不是简单的 double 字段,而应该是一个值对象 Money,内部封装金额计算规则。当一个方法执行后可能导致对象状态不合法时,领域层要抛出业务异常来阻止操作。
领域层还包含一个很有用的东西:领域服务(Domain Service)。有些业务规则不属于某个具体实体,而是多个实体协作的结果。比如拆单逻辑,需要把一个大订单根据商品归属仓库拆成多个子订单,这既不该塞进 Order 实体,也不该塞进应用服务,正确的归属是订单领域服务(OrderDomainService)。判断方法是:业务规则放在哪个对象上都不自然时,就放在领域服务里。
3.4 基础设施层:技术的代理人
基础设施层承载所有技术实现:数据库访问、消息队列发送、远程接口调用、对象存储、缓存操作等。在代码层面表现为 Repository 的实现类、Mapper 接口、异步消息生产者、外部 API 的 Client 封装。它是纯技术层,不包含任何业务规则。
这里有个容易混淆的地方:基础设施层并不是只能依赖领域层。它可以依赖领域层,也可以依赖必要的技术框架,但它不应该反过来被业务层直接依赖。也就是说,OrderRepositoryImpl 实现了 OrderRepository 接口,它是被领域接口“反向控制”的。Controller 不能直接去调 OrderRepositoryImpl,必须经过应用服务,否则就破坏了调用链。
基础设置层的常见代码形态是:在 RepositoryImpl 里把数据库查询返回的 PO 组装成领域对象,或者把领域对象拆成 PO 再持久化。这种转换逻辑放在基础设施层内部,最好是独立出 Assembler/Converter 类,而不是在 Service 里到处写转换。数据表的字段即使和领域对象的字段长得一模一样,也应该保持 PO 和 DO 分离,因为它们的生命周期和演进方向不同。PO 跟随表结构,DO 跟随业务模型,分久必合、合久必分的情况很常见,硬绑在一起早晚出问题。
3.5 一张速查表拎清四层边界
| 层级 | 核心职责 | 可以出现的内容 | 不应该出现的内容 |
|---|---|---|---|
| 用户接口层 | 协议转换,输入输出 | Controller、DTO、参数校验、鉴权拦截 | 业务规则、领域对象直接暴露 |
| 应用层 | 用例编排,事务控制 | ApplicationService、Command、事务注解 | 重业务逻辑、多实体复杂计算 |
| 领域层 | 业务规则,状态变化 | 实体、值对象、聚合、仓储接口、领域服务 | Spring 注解、数据库依赖、外部调用 |
| 基础设施层 | 技术实现 | Repository 实现、Mapper、消息 Producer、HTTP Client | 业务规则、应用服务 |
这张表建议贴在自己的工作笔记里,每次写代码前先想一下“这行代码属于哪一层”,不确定时查这张表,能帮你挡住一半以上的分层错误。
4. 用订单场景把四层架构落下去
4.1 业务场景与规则定义
理论讲了一堆,必须拿代码来落地。我选一个非常典型的电商订单创建场景,包含三条核心业务规则:
- 规则一:订单至少包含一件商品,且商品数量必须大于 0。
- 规则二:订单支持促销活动,促销码有效时,按促销方案计算优惠金额,优惠金额不能超过订单总额。
- 规则三:创建订单时锁定库存,库存不足则下单失败。
这三条规则里,规则一和规则二属于领域层,必须由 Order 实体自己保证;规则三涉及外部库存系统,本质上是资源约束,放在应用层做协调比较合适。用这个场景正好能看出“领域层不能做的事”和“应用层必须做的事”之间的界限。
技术选型假设用 Spring Boot 3 + MyBatis Plus,Java 语言版本 17 以上。数据库表设计从简:订单主表 order、订单商品明细表 order_item、促销活动表 promotion。
4.2 包结构与依赖关系
先不急着写代码,把包结构定下来。我习惯按业务模块分包,模块底下再按四层分包,这样既能体现限界上下文,又能体现分层:
code复制com.example.order/
├── interfaces/
│ ├── controller/OrderController.java
│ └── dto/CreateOrderRequest.java
├── application/
│ ├── service/OrderApplicationService.java
│ ├── assembler/OrderAssembler.java
│ └── command/CreateOrderCommand.java
├── domain/
│ ├── entity/Order.java
│ ├── valueobject/OrderItem.java
│ ├── valueobject/Money.java
│ ├── repository/OrderRepository.java
│ └── service/PromotionService.java
└── infrastructure/
├── repository/OrderRepositoryImpl.java
├── mapper/OrderMapper.java
├── mapper/OrderItemMapper.java
└── client/InventoryClient.java
包结构上容易犯的错是“按技术分包”,结构长这样:
code复制com.example.order/
├── controller/
├── service/
├── dao/
├── entity/
这种结构把不同业务的类混在一起,订单的 Controller 和用户的 Controller 都躺在 controller 包里,时间一长包必然膨胀。DDD 的包结构应该先按业务划边界,再按层次分内部结构。一个业务模块的代码尽量在同一个顶层包下,内部再拆四层。
依赖方向的约束在包结构上要透明:domain 不依赖 interfaces,不依赖 application,不依赖 infrastructure;infrastructure 可以依赖 domain 和 application;application 依赖 domain;interfaces 依赖 application。谁依赖谁要一清二楚,如果代码里出现了 domain 包 import infrastructure 包的情况,说明依赖已经倒置了。
4.3 领域层的代码落地
先定义值对象 Money,金额计算在电商业务里很常见,用 double 做金额会造成精度问题,必须用 BigDecimal:
java复制public record Money(BigDecimal amount) {
public static final Money ZERO = new Money(BigDecimal.ZERO);
public Money add(Money other) {
return new Money(amount.add(other.amount()));
}
public Money subtract(Money other) {
return new Money(amount.subtract(other.amount()));
}
public boolean isGreaterThan(Money other) {
return amount.compareTo(other.amount()) > 0;
}
}
值对象用 record 定义很合适,因为不可变的天性正好符合值对象语义。Money 内部实现了加减比较的方法,外界拿到 Money 不会出现直接改金额的情况。
再定义订单实体:
java复制public class Order {
private final String orderId;
private final String customerId;
private final List<OrderItem> items;
private Money totalAmount;
private Money discountAmount;
private String status;
public Order(String orderId, String customerId, List<OrderItem> items) {
if (items == null || items.isEmpty()) {
throw new IllegalArgumentException("订单必须包含至少一件商品");
}
this.orderId = orderId;
this.customerId = customerId;
this.items = items;
this.totalAmount = calculateTotalAmount();
this.discountAmount = Money.ZERO;
this.status = "CREATED";
}
public void applyPromotion(Money discount) {
if (discount.isGreaterThan(totalAmount)) {
throw new IllegalArgumentException("优惠金额不能超过订单总额");
}
this.discountAmount = discount;
}
private Money calculateTotalAmount() {
Money total = Money.ZERO;
for (OrderItem item : items) {
total = total.add(item.subtotal());
}
return total;
}
public Money payableAmount() {
return totalAmount.subtract(discountAmount);
}
}
Order 的构造方法里已经做了“订单不能为空商品”的校验,应用层或者 Controller 就再也不用重复判断。更隐蔽的是,Order 没有提供 setStatus 这类方法,状态变更必须通过领域方法,后面如果需要改订单状态,就应该在 Order 内部加一个 markPaid() 或 cancel() 的方法,而不是暴露 setter。
仓储接口定义在领域层:
java复制public interface OrderRepository {
Order findById(String orderId);
void save(Order order);
}
这里没有引入 MyBatis Plus 的 BaseMapper 泛型,也没有数据库注解,因为仓储接口是业务契约,不是数据访问契约。
4.4 应用层的代码落地
应用服务负责编排整个下单流程:
java复制@Service
public class OrderApplicationService {
private final OrderRepository orderRepository;
private final PromotionService promotionService;
private final InventoryClient inventoryClient;
public OrderApplicationService(
OrderRepository orderRepository,
PromotionService promotionService,
InventoryClient inventoryClient) {
this.orderRepository = orderRepository;
this.promotionService = promotionService;
this.inventoryClient = inventoryClient;
}
@Transactional
public OrderCreateResult createOrder(CreateOrderCommand command) {
Order order = new Order(
UuidUtil.generate(),
command.customerId(),
command.items().stream()
.map(i -> new OrderItem(i.productId(), i.quantity(), i.unitPrice()))
.toList());
Money discount = promotionService.calculateDiscount(order, command.promotionCode());
order.applyPromotion(discount);
if (!inventoryClient.lockStock(order)) {
throw new IllegalStateException("库存不足");
}
orderRepository.save(order);
return new OrderCreateResult(order.orderId(), order.payableAmount());
}
}
application 层做的事很清楚:创建领域对象、调用领域服务算优惠、锁库存、保存订单。这里没有出现“订单总额不能超过多少”“优惠金额怎么算”这类规则,它们都被封装在领域层了。如果说 createOrder 里以后要多一个“发送站内信通知用户”的动作,直接在应用层加一步即可,不涉及领域模型的改动。
锁库存这个动作放在应用层,是因为它涉及外部系统调用,严格来说不产生领域规则。如果库存系统内部本身有复杂的领域规则,那应该由库存系统自己的领域层处理,订单系统只需要通过适配器调用库存系统的接口。
4.5 基础设施层与接口层收尾
基础设施层实现仓储接口:
java复制@Repository
public class OrderRepositoryImpl implements OrderRepository {
private final OrderMapper orderMapper;
private final OrderItemMapper orderItemMapper;
public OrderRepositoryImpl(OrderMapper orderMapper, OrderItemMapper orderItemMapper) {
this.orderMapper = orderMapper;
this.orderItemMapper = orderItemMapper;
}
@Override
public Order findById(String orderId) {
OrderPO po = orderMapper.selectById(orderId);
if (po == null) {
return null;
}
List<OrderItemPO> itemPOs = orderItemMapper.selectByOrderId(orderId);
return OrderAssembler.toDomain(po, itemPOs);
}
@Override
public void save(Order order) {
OrderPO po = OrderAssembler.toPO(order);
orderMapper.insert(po);
order.items().forEach(item -> {
OrderItemPO itemPO = OrderAssembler.toItemPO(order.orderId(), item);
orderItemMapper.insert(itemPO);
});
}
}
RepositoryImpl 做的事情是枯燥的 PO 与领域对象互转,但它把技术细节隔离在了 infrastructure 包内。这里的 OrderAssembler 是基础设施层内部的转换器,要注意不要和应用层里的那个 OrderAssembler 混在一起,两者职责不同:一个做 DTO 转 Command,一个做 PO 转 DO。
接口层最外面是 Controller:
java复制@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderApplicationService orderApplicationService;
public OrderController(OrderApplicationService orderApplicationService) {
this.orderApplicationService = orderApplicationService;
}
@PostMapping
public OrderResponse createOrder(@RequestBody @Valid CreateOrderRequest request) {
CreateOrderCommand command = new CreateOrderCommand(
request.customerId(),
request.items().stream()
.map(i -> new OrderItemCommand(i.productId(), i.quantity(), i.unitPrice()))
.toList(),
request.promotionCode());
OrderCreateResult result = orderApplicationService.createOrder(command);
return new OrderResponse(result.orderId(), result.payableAmount().amount());
}
}
到这里,整个请求链路是:Controller 拿到 JSON -> 转 Command -> 应用服务协调领域对象和外部服务 -> 仓储保存 -> 返回结果给 Controller -> 转 Response。每一步的职责边界一目了然,再加需求时你知道该改哪一层。
5. 分层架构图的画法与要点
5.1 画给谁看,就有什么版本
热搜里很多人搜“分层图绘制”“ddd 架构分层图”,其实分层图有不同的版本。最经典的是概念版四层结构图,四个矩形从上到下排列,标注层间依赖箭头,适合画给产品、项目经理和刚入门的开发看,重点是传达层次关系。第二个版本是依赖关系图,强调领域层在最中心、其他层都指向它,适合架构评审和技术分享,重点是体现依赖倒置。第三个版本是包结构图,把具体代码里的 package 和类名标出来,适合给开发团队当编码规范参考。
刚接触 DDD 的人容易上来就画一个包结构图,类名密密麻麻,根本看不出来分层思路。我的建议是先用概念图讲清楚依赖规则,再画包结构图标出代码落点,必要时画一张时序图展示一次请求跨层的调用顺序。三分图各有用处,不要指望一张图解决所有沟通问题。
5.2 画图的三个核心要点
第一,箭头表示依赖方向,不要表示数据流。很多人把图上的箭头画成数据流动方向,导致依赖方向和数据流方向混在一起,越看越糊涂。数据流是从外层进内层再出外层的循环,依赖方向是上层指向下层,这两个方向确实相反,画图时要明确标注图例。
第二,领域层不要画数据库图标。见过不少人在领域层旁边画一个 MySQL 图标,这会让读者误以为领域层直接操作数据库。实际上领域层知道自己有“订单集合”的抽象即可,数据库是基础设施层的实现细节。画图时数据库要放在基础设施层区域内部。
第三,依赖倒置的“倒”要靠接口体现。只在四个矩形之间画单向箭头,读者看十分钟也理解不了为何叫倒置。比较直观的做法是在领域层声明 OrderRepository 接口,在基础设施层画 OrderRepositoryImpl,再用一条虚线从 Impl 反向指向接口,标注“implements”。这样“领域层定义接口、基础设施层提供实现”的结构清晰可见。
5.3 工具选择与实操提示
工具上,团队协作推荐用 draw.io(免费、可导出 SVG、可以存 Git 仓库里做版本管理),个人快速画图推荐 PlantUML 或者 Excalidraw。Visio 也可以,但跨平台和版本管理体验一般。我见过很多团队用白板直接画,拍照上传文档笔记,效果也不差,关键是内容准确,工具只是辅助。
画图实操时建议把四层横向摆放,从上到下依次为用户接口层、应用层、领域层、基础设施层,每层内部再拆包或拆类。依赖箭头的绘制规则是:Controller 指向 ApplicationService,ApplicationService 指向 Order 和 OrderRepository,OrderRepositoryImpl 有虚线指向 OrderRepository。这样画下来,依赖关系一目了然。画到一半的时候多问自己一句:“这条箭头的方向,是不是我代码里 import 的方向?”如果代码里没有对应的 import,那这张图就画错了。
6. 常见问题与排坑手册
6.1 高频问题速查表
| 问题 | 现象 | 处理方法 |
|---|---|---|
| Domain 层出现 Spring 注解 | 实体上用 @Component @Service 注册 | 领域层保持纯净,不用框架注解,用普通 Java 对象 |
| Application 层写业务规则 | 应用服务里 if 判断订单状态、算价格 | 这些逻辑下沉到领域层,应用服务只留流程 |
| Controller 返回实体对象 | 序列化后字段泄漏,接口变化绑死领域模型 | 用 DTO/Response 对象隔离,通过 Assembler 转换 |
| 仓储接口上没有事务 | 有人习惯在 RepositoryImpl 上加 @Transactional | 事务应该在应用层控制,仓储只做单对象持久化 |
| 基础设施层 import 了 Controller | 出现跨层的反向调用 | 检查调用链,强制让 Controller 只依赖应用服务 |
| 按技术分包 | 包结构是 controller / service / dao | 改为按业务模块分包,模块内部再分四层 |
| 实体用 setter 满天飞 | 任何地方都能改订单状态 | 去掉 setter,用领域方法代替状态变更 |
| PO 和 DO 混用 | 直接从 Mapper 返回 Order 实体 | PO 和 DO 分离,在基础设施层做转换 |
数据仓库也有一个“分层”,常听到四层是 ODS、DWD、DWS、ADS,那是数据加工链路的分层,跟 DDD 应用架构分层完全不是一回事。区别在于:DDD 分层是为了控制业务逻辑复杂度,数据仓库分层是为了数据从原始到专题的加工链路清晰。搜“数据仓库分层4层叫啥”的朋友如果是在找架构分层,别混用概念。
6.2 几个必须强调的细节坑
第一个坑是“领域层被 JPA 注解污染”。很多人用 JPA/Hibernate 时直接在实体上写 @Entity @Table @Column,这在 DDD 里会让领域模型和数据库表结构强耦合。表结构一变,领域模型跟着改,业务逻辑散落在 ORM 映射里,很难维护。折中方案是用 JPA 时保持领域模型和 PO 分离,或者果断用 MyBatis,让 RepositoryImpl 自己处理映射。如果团队规模小、业务简单,按 JPA 实体写也常见,但进入 DDD 学习阶段,建议先接受“PO 和 DO 分离”的思路。
第二个坑是“应用层空转问题”。有些团队为了分层而分层,应用服务方法里只写了两行代码:一行创建实体、一行调用仓储保存,其他逻辑全在 Controller 里。这种“只有骨架没有内脏”的分层,比不分层更麻烦。应用服务的价值在于编排和协调,应该承担跨领域对象、跨外部系统的流程控制,如果业务简单到不需要编排,那这一步可以省略,千万不要为了形式硬凑一个应用服务。
第三个坑是“仓储接口泛化过度”。有人喜欢把仓储定义成 BaseRepository<T>,每个实体都继承一个通用接口,这样做减少了代码量,但把领域对象的具体语义丢了。OrderRepository.save(order) 读起来很清楚,而 baseRepository.save(order) 就没这个效果。DDD 的仓储接口应该面向聚合根书写,每个聚合根有自己专属的仓储接口,不必为了省事而统一泛型。
第四个坑是“事务边界放错位置”。有人习惯在 Controller 方法上加 @Transactional,这样会把一次 HTTP 请求的所有操作都包进事务,连接占用时间很长;也有人把它放在 RepositoryImpl 的方法上,造成事务粒度太细且边界不统一。正确的位置是应用服务的方法上,一个用例一个事务,隔离性好又便于维护。
第五个坑是“忽略基础设施的能力对领域的影响”。虽然领域层不依赖基础设施,但现实场景中技术选型会影响业务实现的可行性。比如分布式事务方案选择、消息可靠投递机制、缓存一致性策略,这些能力边界虽然藏在了基础设施层里,但应用层和领域层要能感知到它们的能力限制。纯理论会把 DDD 讲得很美,落地时还是要考虑技术现实。
个人体会
我自己的踩坑经验是:分层架构最大的价值不是那四个包的目录结构,而是强制你想清楚“一个业务规则的唯一归属点在哪里”。以前写代码,业务规则散落在各个角落,出了问题靠人肉搜索;有了领域层之后,核心规则集中在一个地方,改动时的风险半径小了很多。
这一课先把分层的骨架立起来,后面无论是引入领域事件,还是做聚合设计,都有地方安放。如果你刚开始尝试 DDD,建议先别管事件风暴、CQRS 这些进阶概念,老老实实把每个实体该有的领域方法补齐,把每个仓储接口背后的 PO 转换理顺。骨架稳定了,后面的路自然走得快。
最后分享一个实操小技巧:每次接到新需求,先在代码里回答三个问题——这个需求的业务规则属于哪个领域对象?需要走哪几个用例?有没有必要改基础设施层?如果三个问题都能快速回答,说明你的分层已经没有大问题了。
