摘要:本文从一段典型的 MVC 三层架构下单代码入手,剖析贫血模型导致的四个核心问题:业务规则散落、对象缺乏行为、难以单元测试、修改牵一发动全身。随后用 DDD 战术设计逐步重构,将订单规则归还 Order 实体、用 Money 值对象替代裸 BigDecimal、跨对象规则收进领域服务、编排职责交给应用服务、通过 Repository 接口实现依赖倒置。重构完成后系统梳理实体、值对象、聚合、领域服务与领域事件等核心概念,并对比四层架构与 MVC 的关系。最后从战略设计视角介绍限界上下文与核心域划分,结合业务规则密度分析充血与贫血模型的适用场景,给出常见误区与标准包结构参考。
第一次听到 DDD(Domain-Driven Design,领域驱动设计),我以为是某个新框架,就像当年 Spring 干掉 Struts 那样,要来干掉 MVC 了。
后来发现不是。再后来发现它和 MVC 根本不在同一个维度。然后我就彻底懵了。
市面上大部分 DDD 教程的问题是:一上来就抛出一堆概念——实体、值对象、聚合、限界上下文……每个词都认识,连起来不知道在说什么。概念背完了,回到自己的项目,Service 还是照样写。
所以这篇我换个讲法:先给你看一段你绝对写过的代码,再用 DDD 的思路一步步重构它。 概念在重构过程里自然出现,而不是先背定义再找例子。
读完这篇,你应该能回答三个问题:
我的胖 Service 到底"胖"在哪?
DDD 拆开之后,每一块代码去了哪里?
我的项目到底要不要用 DDD?
一、先看一段你很可能写过的代码
电商下单,用传统三层架构 + 贫血模型写出来,大概长这样(为了篇幅做了精简,但结构你一定眼熟):
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private UserMapper userMapper;
@Autowired
private ProductMapper productMapper;
@Autowired
private CouponMapper couponMapper;
@Autowired
private RabbitTemplate rabbitTemplate;
@Transactional
public Long placeOrder(Long userId, Long productId, Integer quantity, Long couponId) {
// 1. 查用户,校验状态
UserPO user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
}
if (user.getStatus() == 0) {
throw new RuntimeException("用户已被禁用");
}
// 2. 查商品,校验库存
ProductPO product = productMapper.selectById(productId);
if (product == null || product.getStatus() != 1) {
throw new RuntimeException("商品不存在或已下架");
}
if (product.getStock() < quantity) {
throw new RuntimeException("库存不足");
}
// 3. 算价格:会员折扣 + 优惠券 + 满减
BigDecimal total = product.getPrice().multiply(new BigDecimal(quantity));
if (user.getLevel() == 2) { // VIP 打 9 折
total = total.multiply(new BigDecimal("0.9"));
}
if (couponId != null) {
CouponPO coupon = couponMapper.selectById(couponId);
if (coupon.getStatus() != 1) {
throw new RuntimeException("优惠券不可用");
}
if (total.compareTo(new BigDecimal("100")) < 0) { // 满 100 才能用券
throw new RuntimeException("订单金额不足 100 元,无法使用优惠券");
}
total = total.subtract(coupon.getAmount());
}
if (total.compareTo(new BigDecimal("200")) >= 0) { // 满 200 减 30
total = total.subtract(new BigDecimal("30"));
}
if (total.compareTo(BigDecimal.ZERO) < 0) {
total = BigDecimal.ZERO;
}
// 4. 组装订单对象,落库
OrderPO order = new OrderPO();
order.setOrderNo("ORD" + System.currentTimeMillis());
order.setUserId(userId);
order.setProductId(productId);
order.setQuantity(quantity);
order.setTotalAmount(total);
order.setStatus(0); // 0 = 待支付
orderMapper.insert(order);
// 5. 扣库存
product.setStock(product.getStock() – quantity);
productMapper.updateById(product);
// 6. 发消息通知
rabbitTemplate.convertAndSend("order.created", order.getId());
return order.getId();
}
}
这段代码能跑,甚至跑得挺好。但如果你是 MVC 老手,看到它的反应大概是:"这不就是我每天都在写的东西吗?"
对。问题就在这。
二、这段代码到底哪里出了问题
很多人说问题是"太长了"。不对,长只是结果,不是原因。真正的问题有四个:
2.1 业务规则没有"家"
"满 100 才能用券"这条规则写在哪?写在 placeOrder 方法的第 3 段里。那取消订单的时候要退券,规则写在哪?写在 cancelOrder 里。定时任务扫描超时订单,规则写在哪?写在 OrderTimeoutJob 里。
同一条业务规则,散落在 N 个方法里,没有一处是它的"正主"。 改规则的时候全靠全局搜索和记性——搜 new BigDecimal("100"),祈祷没有漏。
2.2 对象只是"数据袋"
OrderPO 有什么?一堆字段 + getter/setter。它不知道"待支付才能支付",不知道"已完成不能取消",它什么都不知道。所有关于订单的知识全在 Service 里。
这样的对象跟一个 Map<String, Object> 没有本质区别。我们把这叫贫血模型——对象有血(数据)无肉(行为)。
2.3 没法单独测业务逻辑
想测"VIP + 优惠券 + 满减叠加时价格算得对不对"?对不起,你得先把 userMapper、couponMapper、rabbitTemplate 全部 mock 掉,才能跑到算价格那几行。业务规则和 MyBatis、RabbitMQ 焊死在了一起。
2.4 改一处,慌全身
下个迭代产品说"满 200 减 30 改成阶梯满减",你要动这个方法;再下个迭代说"加积分抵扣",还是动这个方法。一个方法被十个需求轮流改,merge 冲突家常便饭。
MVC 没有导致这些问题,但 MVC 也没管这些问题。 MVC 只规定了"展示和逻辑分开",至于 Model 内部怎么组织,它一句话都没说。DDD 管的恰恰是这件事。
记住这个定位,后面所有的概念都是围绕它展开的。
三、用 DDD 的思路重构一遍
重构的原则只有一句话:让每条业务规则待在它真正属于的地方。
我们一步步来。
第 1 步:把"订单自己的规则"还给 Order
问自己一个问题:"待支付状态才能支付"这条规则,是属于某个 Service 的,还是属于订单本身的?
显然属于订单本身——不管你从哪个入口进来(App、小程序、定时任务),这条规则都不变。那它就该写在 Order 里:
public class Order {
private Long id;
private String orderNo;
private Long userId;
private List<OrderItem> items;
private Money totalAmount; // 注意这里,下一节讲
private OrderStatus status;
// 规则:待支付才能支付
public void pay() {
if (this.status != OrderStatus.PENDING_PAYMENT) {
throw new BizException("只有待支付状态才能支付");
}
this.status = OrderStatus.PAID;
}
// 规则:已发货、已完成的订单不能取消
public void cancel() {
if (this.status == OrderStatus.SHIPPED || this.status == OrderStatus.COMPLETED) {
throw new BizException("当前状态不允许取消");
}
this.status = OrderStatus.CANCELLED;
}
// 规则:订单总价必须等于明细之和——没人能绕过
public void addItem(OrderItem item) {
this.items.add(item);
recalcTotal();
}
private void recalcTotal() {
this.totalAmount = items.stream()
.map(OrderItem::subtotal)
.reduce(Money.ZERO, Money::add);
}
}
对比一下:原来状态流转的 if (status != 1) throw … 散落在各个 Service 方法里,现在收进了 Order 内部,外部想绕过都绕不过——因为根本没有 setStatus() 给你调。
这就是 DDD 说的充血模型:实体既有数据也有行为,自己守护自己的规则。
注意一个细节:pay() 里没有 orderMapper.update(this)。实体不认识 Mapper,不认识数据库。它只管业务规则。持久化是别人的事——谁的?后面讲。
第 2 步:把"10 块钱"变成 Money,而不是 BigDecimal
原代码里金额计算全是裸的 BigDecimal:multiply、subtract、compareTo(ZERO) 满天飞。这里藏着 bug 温床:
-
谁保证金额不会算出负数?(原代码里特意写了一行 if (total < 0) total = 0,这就是在为裸类型擦屁股)
-
谁保证不会把人民币和美元加在一起?
-
new BigDecimal("0.9") 这种魔法数字散落各处。
DDD 的做法:金额是一个值对象(Value Object),把和钱有关的规则收进 Money 类:
public class Money {
public static final Money ZERO = new Money(BigDecimal.ZERO, "CNY");
private final BigDecimal amount;
private final String currency;
public Money(BigDecimal amount, String currency) {
if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("金额不能为负");
}
this.amount = amount;
this.currency = currency;
}
public Money add(Money other) {
checkCurrency(other);
return new Money(this.amount.add(other.amount), this.currency);
}
public Money subtract(Money other) {
checkCurrency(other);
BigDecimal result = this.amount.subtract(other.amount);
// 减成负数?归零——规则内聚在这里,而不是散落在每个调用点
return result.compareTo(BigDecimal.ZERO) < 0
? ZERO
: new Money(result, this.currency);
}
public Money multiply(BigDecimal factor) {
return new Money(this.amount.multiply(factor), this.currency);
}
public boolean greaterThanOrEqual(Money other) {
checkCurrency(other);
return this.amount.compareTo(other.amount) >= 0;
}
private void checkCurrency(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("币种不一致,不能运算");
}
}
// 值对象没有 id,相等与否看值本身
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Money)) return false;
Money money = (Money) o;
return amount.compareTo(money.amount) == 0 && currency.equals(money.currency);
}
@Override
public int hashCode() {
return Objects.hash(amount.stripTrailingZeros(), currency);
}
}
几个关键点:
-
不可变(immutable):所有字段 final,运算返回新对象。钱不会被"改掉",只会被"换掉"。
-
构造即校验:负数金额在 new 出来的那一刻就死了,不可能流进系统。
-
没有 id:你兜里的 10 块和我兜里的 10 块等价,判断相等看值,不看身份。
这就是实体和值对象的本质区别,先记住代码感觉,第五节再总结判断方法。
第 3 步:跨对象的规则,放进领域服务
第 1 步把订单自己的规则收进了 Order。但"算最终价格"这条规则有点尴尬:它要用到用户等级(User)、商品单价(Product)、优惠券(Coupon)——不属于任何一个单独的对象。
塞给 Order?Order 不该认识 Coupon。塞回应用服务?那又回到老路了。
DDD 给这类规则准备的地方叫领域服务(Domain Service):
// 领域层:只封装纯业务规则,不碰数据库、不碰 MQ
public class PricingDomainService {
private static final Money COUPON_THRESHOLD = new Money(new BigDecimal("100"), "CNY");
private static final Money FULL_REDUCE_THRESHOLD = new Money(new BigDecimal("200"), "CNY");
private static final Money FULL_REDUCE_OFF = new Money(new BigDecimal("30"), "CNY");
public Money calculateFinalPrice(User user, Product product, int quantity, Coupon coupon) {
Money total = product.getPrice().multiply(new BigDecimal(quantity));
if (user.isVip()) {
total = total.multiply(new BigDecimal("0.9"));
}
if (coupon != null) {
coupon.checkUsable(total); // "满100可用"的规则收进 Coupon 自己
total = total.subtract(coupon.getAmount());
}
if (total.greaterThanOrEqual(FULL_REDUCE_THRESHOLD)) {
total = total.subtract(FULL_REDUCE_OFF);
}
return total;
}
}
注意两件事:
"满 100 才能用券"这条规则变成了 coupon.checkUsable(total)——它属于优惠券自己,收进了 Coupon 实体。这就是第 1 步的思路在 Coupon 上的复用。
PricingDomainService 里没有任何 @Autowired、没有 Mapper、没有 MQ。你可以 new 一个出来,直接单测所有价格组合,不用 mock 任何东西。2.3 节说的"没法测"的问题,到这里解决了。
怎么判断一段逻辑该不该放领域服务?一句话:把数据库换了、把框架换了、把 Web 换成命令行,这段逻辑还要不要原样存在?要,它就是业务规则,放领域层。
第 4 步:应用服务只做编排
现在回头看原来那个胖 Service 干的事,拆完三块之后还剩什么?
剩下的是"步骤":先查用户,再查商品,算价,建单,保存,发消息。步骤本身不是业务规则,是流程编排。 这部分交给应用服务(Application Service):
// 应用层:只做流程编排,不含业务规则
@Service
public class OrderAppService {
private final UserRepository userRepository;
private final ProductRepository productRepository;
private final CouponRepository couponRepository;
private final OrderRepository orderRepository;
private final PricingDomainService pricingDomainService;
private final DomainEventPublisher eventPublisher;
// 构造器注入略
@Transactional
public Long placeOrder(PlaceOrderCommand command) {
// 1. 把需要的聚合取出来
User user = userRepository.findById(command.getUserId());
Product product = productRepository.findById(command.getProductId());
Coupon coupon = command.getCouponId() == null
? null
: couponRepository.findById(command.getCouponId());
// 2. 业务规则全部委托给领域对象
product.checkStock(command.getQuantity());
Money finalPrice = pricingDomainService.calculateFinalPrice(
user, product, command.getQuantity(), coupon);
Order order = Order.create(user.getId(), product, command.getQuantity(), finalPrice);
// 3. 持久化 + 发布事件
orderRepository.save(order);
eventPublisher.publish(new OrderCreatedEvent(order.getId()));
return order.getId();
}
}
对比第二节的胖 Service,变化一目了然:
-
"库存不足抛异常"从 if (stock < quantity) 变成了 product.checkStock(…)——规则进了 Product;
-
算价的一整坨 if-else 变成了一行 pricingDomainService.calculateFinalPrice(…)——规则进了领域服务;
-
用户禁用校验没了?不,它在 User 里(比如 userRepository.findById 返回前先校验,或者 Order.create 里调 user.checkActive())。
应用服务变薄了,但逻辑一行没少——只是每条规则都回到了自己家。 这就是 DDD 重构的本质:不是消灭复杂度,而是给复杂度归位。
回答那个 MVC 用户最困惑的问题——"原来的 Service 层去哪了?" 它被拆成了三块:
| 业务规则判断(库存够不够、状态能不能变、券能不能用) | 领域层:实体 / 值对象 / 领域服务 |
| 流程步骤编排(先查什么再调什么) | 应用层:应用服务 |
| 数据库、MQ、第三方接口的技术实现 | 基础设施层 |
第 5 步:Repository——领域层只要接口,不关心实现
你可能注意到了,第 4 步用的是 OrderRepository,不是 OrderMapper。这不是改个名字装高级,区别是实打实的:
// ===== domain 层:只定义接口 =====
public interface OrderRepository {
Order findById(Long id); // 返回的是领域对象,不是 PO
void save(Order order); // 接收的是领域对象
}
// ===== infrastructure 层:具体实现 =====
@Repository
public class MybatisOrderRepository implements OrderRepository {
private final OrderMapper orderMapper; // Mapper 在这里才出现
@Override
public Order findById(Long id) {
OrderPO po = orderMapper.selectById(id);
return OrderConverter.toDomain(po); // PO → 领域对象
}
@Override
public void save(Order order) {
OrderPO po = OrderConverter.toPO(order); // 领域对象 → PO
orderMapper.insertOrUpdate(po);
}
}
对比一下:
-
DAO / Mapper 面向数据库,操作的是表和 PO,说的是"insert、update、select"这套数据库语言;
-
Repository 面向领域,操作的是实体和聚合,说的是"给我一个订单、存下这个订单"这套业务语言。DAO 只是 Repository 内部的一个实现细节。
为什么要多这一层?这就是 DDD 四层架构里最关键的设计原则——依赖倒置:
-
领域层定义 OrderRepository 接口,但它不知道 MyBatis 存在;
-
基础设施层实现这个接口,依赖方向是 infrastructure → domain,反过来了;
-
领域层因此不依赖任何技术细节。MySQL 换 MongoDB,领域代码一行不动;写单测时用一个内存版 InMemoryOrderRepository,领域层照样跑。
到此,第二节说的四个问题全部有了着落:规则有家了(实体/值对象/领域服务)、对象有肉了(充血模型)、能单测了(领域层零外部依赖)、改规则只动一处(规则内聚)。
四、现在回头看概念:实体、值对象、聚合
第三节已经把代码看完了,这一节把概念正式收个尾。你会发现每个概念你都已经在代码里见过了。
4.1 实体(Entity):有身份的对象
Order 是实体:它有 id / orderNo,状态从"待支付"一路变到"已完成",但它始终是那一张订单。判断两个实体是否相等,看身份标识,不看属性。
4.2 值对象(Value Object):只看值的对象
Money 是值对象:没有 id,不可变,相等看值。
给你一个实用的判断方法——"换一个问题":
-
把订单的收货地址从"北京"换成"上海",这还是原来那张订单吗?→ 是 → 订单是实体,地址是订单身上的一个值。
-
把一张 100 元钞票换成另一张 100 元钞票,你的钱变了吗?→ 没变 → 金额是值对象。
有个经典坑要注意:同一个概念,在不同场景下身份不同。 比如地址:
-
在订单上下文里,地址只是订单的一个快照属性 → 值对象;
-
在"用户收货地址簿"里,每个地址有 id,能被单独编辑、设为默认 → 它是实体。
没有天生的实体或值对象,只有在你的业务边界里它是什么。这就引出了限界上下文,第六节讲。
4.3 聚合(Aggregate)与聚合根:一致性边界
回到第三节的 Order.addItem():为什么加商品明细必须通过 Order,而不能直接 new 一个 OrderItem 塞进数据库?
因为"订单总价必须等于明细之和"这条规则,只有 Order 能保证。如果外部能绕过 Order 直接改 OrderItem,总价和明细就对不上了。
聚合就是一组对象的访问边界,聚合根(这里是 Order)是唯一的入口。 类比快递柜:你只能通过柜门取件,不能把手伸进柜子。
设计聚合时记住两条原则,能避开新手 90% 的坑:
一致性边界原则:聚合是强一致性的最小单元。聚合内的规则(总价=明细之和)由聚合根在同一个事务里保证;聚合之间(订单和库存、订单和积分)通过领域事件或应用服务协调,做到最终一致即可。
最小化原则:聚合越小越好。只把"生命周期一致、规则强绑定"的对象放进去。订单需要知道商品叫什么、多少钱——存个商品 ID + 当时的价格快照就够了,别把 Product 整个塞进来,更别把 User 塞进来。为了"查询方便"把聚合撑大,最后边界形同虚设,代码重新变回一锅粥。
4.4 领域服务 vs 应用服务:一句话区分
这两个最容易混,记住这句:
-
领域服务:把数据库、框架、前端全换掉,这段逻辑依然成立 → 它是业务规则(如算价);
-
应用服务:它描述的是"这个用例的步骤" → 先查再算再存再通知,是流程编排(如下单)。
再简单点:领域服务里不该出现 @Transactional、@Autowired Mapper、HTTP、MQ;应用服务里不该出现业务规则的 if-else。
4.5 领域事件(Domain Event):顺便提一句
第三节末尾的 eventPublisher.publish(new OrderCreatedEvent(…)) 就是领域事件:订单聚合说"我被创建了",至于谁关心(发积分、发通知、推大数据),让订阅者自己去处理。聚合之间靠事件解耦,而不是互相直接调用。
五、DDD 四层架构,和 MVC 摆在一起看
现在可以把全貌亮出来了。DDD 在后端常见的落地方式是四层架构:
| 接口层(Interfaces) | 接收请求、参数校验、组装响应 | View + Controller | Controller、DTO、VO 返回模型 |
| 应用层(Application) | 用例流程编排,无业务规则 | 胖 Service 的"流程"部分 | 应用服务、Command/Query 对象 |
| 领域层(Domain) | 核心业务规则,系统的心脏 | 胖 Service 的"规则"部分 + 数据模型 | 实体、值对象、聚合、领域服务、Repository 接口 |
| 基础设施层(Infrastructure) | 技术实现支撑 | DAO + 外部调用 | Repository 实现、Mapper、远程调用、MQ |
两个要点:
MVC 没有被干掉,它被收编了。 Controller 和 View 大体落在接口层;原来 Model/Service/DAO 里混成一团的职责,被拆进了应用层、领域层、基础设施层。MVC 管"请求怎么进来怎么出去",DDD 管"请求进来之后业务逻辑怎么组织"。不同维度,互补关系,可以共存。
调用方向和依赖方向是两回事。 请求从接口层 → 应用层 → 领域层往下调用,但依赖关系通过依赖倒置保护着领域层:领域层只握有 Repository 接口,实现由基础设施层提供。领域层是四层里唯一不知道数据库存在的层。
六、战略设计:代码之外的另一半 DDD
前面五节讲的都是战术设计——边界之内代码怎么写。DDD 还有另一半叫战略设计,回答的是更宏观的问题:系统怎么拆,边界怎么划。
很多教程把 DDD 讲成"充血模型 + 四层架构",其实是丢掉了一半。入门阶段战略设计记住两个概念就够。
6.1 限界上下文(Bounded Context):同一个词,不同的意思
4.2 节留了个伏笔:地址在不同场景下身份不同。推广开——"用户"这个词,在你们系统里真的是同一个东西吗?
-
在会员中心,用户有等级、积分、优惠券;
-
在订单中心,用户就是收货地址 + 联系方式;
-
在财务中心,用户是开票信息 + 账单。
如果你试图建一张"万能用户表"通吃所有场景,字段会越加越多,每个模块只用其中 20 个字段,规则互相打架。限界上下文就是给每个业务领域画一个圈:圈内,每个概念有唯一明确的含义;圈与圈之间,通过明确的接口(或防腐层)协作,互不干涉内部实现。
它是后来拆微服务的重要参考——但注意,只是参考,不是一一对应(见第八节误区一)。
6.2 核心域 / 支撑域 / 通用域:好钢用在刀刃上
战略设计还会把子域分三类,用来分配研发资源:
-
核心域:你的业务护城河,比如电商的交易履约、定价策略 → 精细建模,上完整的 DDD;
-
支撑域:辅助核心域运转,比如商品资料管理 → 有规则但非核心竞争力,适度建模;
-
通用域:行业通用能力,比如短信、权限、日志 → 买现成或用开源,别造轮子。
这个分类本身就是 DDD 最重要的实践价值之一:它明确告诉你哪里值得精细设计,哪里简单 CRUD 就够——避免全系统过度设计,也避免核心业务被草率对待。
七、冷静一下:充血 vs 贫血,不是信仰之争
讲完充血模型必须泼一盆冷水:贫血模型不是"错",它是一种合理的默认选择。
如果你的业务就是"把表单存进表,再从表里查出来",贫血模型 + 三层架构简单直接,没有任何问题。充血模型在这种情况下反而是负担——给只有 CRUD 的对象硬造行为,纯属戏精上身。
真正的判断标准是业务规则的密度:
-
规则少 → 放 Service 里没什么成本,贫血够用;
-
规则多、规则之间有关联、规则会被多个入口复用 → 贫血的代价开始显现(散落、重复、不可测),这时充血模型的收益才盖过成本。
第三节的重构之所以值得做,是因为"下单"这个场景规则密集:状态机、库存、价格、优惠券。不是因为贫血模型政治不正确。
八、什么时候用 DDD,什么时候别用
适合:
-
业务规则复杂,不是 CRUD 能搞定的(状态机、复杂计算、多规则叠加);
-
系统要长期维护迭代,今天省的事明天要还;
-
团队有规模,需要靠代码结构传递业务知识。
不适合:
-
简单后台管理系统(就是增删改查);
-
快速原型 / MVP(先跑起来验证,活下去再说);
-
业务逻辑本来就薄的项目。
一个粗糙但实用的信号:你的某个 Service 超过 500 行,改一条业务规则要动三个以上的文件,新人来了问"XX 规则写在哪"你答不上来——出现这些,就该考虑 DDD 了。数字不是硬标准,是复杂度的体温计。
常见误区避坑
误区一:DDD = 微服务。 DDD 是设计思想,微服务是部署架构。限界上下文是拆服务的重要参考,但简单业务里多个上下文跑在一个单体里完全没问题。
误区二:充血模型 = 把所有逻辑塞进实体。 那只是把胖 Service 换成了胖实体。实体只管自己的状态和自身的规则;跨对象的规则去领域服务;流程编排去应用服务。
误区三:Repository 就是 DAO 改名。 见第 5 步:DAO 面向数据库说 insert/update,Repository 面向领域说"给我订单/存订单"。DAO 是 Repository 的实现细节,两者不在一个层级。
误区四:所有项目都要上全套 DDD。 见第七节。简单项目硬套 DDD,是拿复杂度治复杂度,得不偿失。
误区五:一上来就追求完美分层。 更务实的路径是:先在最痛的那个核心模块里,把规则下沉到实体,把 Repository 接口抽出来,跑通了再推广。大爆炸式重构死多活少。
九、标准包结构参考
以 Java 电商订单域为例,可直接抄:
com.xxx.order
├── interfaces // 接口层:对外暴露能力
│ ├── controller // HTTP 接口
│ ├── dto // 请求/响应传输对象
│ ├── response // 返回模型(别和领域值对象混用)
│ └── facade // 对外 RPC 接口实现
├── application // 应用层:流程编排,无业务规则
│ ├── service // 应用服务
│ ├── command // 写操作命令对象(如 PlaceOrderCommand)
│ └── query // 读操作查询对象
├── domain // 领域层:核心业务规则
│ ├── entity // 实体、聚合根
│ ├── valueobject // 值对象(Money、Address…)
│ ├── service // 领域服务(PricingDomainService…)
│ ├── event // 领域事件
│ └── repository // 仓储接口(只有接口!)
└── infrastructure // 基础设施层:技术实现
├── repository // 仓储实现(MybatisOrderRepository…)
├── dao // Mapper / DAO + PO
├── remote // 第三方接口调用
├── mq // 消息收发实现
└── config // 技术配置
一个自检方法:打开 domain 包,全文搜索 import,不应该出现任何 org.apache.ibatis、javax.servlet、com.aliyun 之类的技术包。 出现了,说明依赖漏进来了。
十、总结
回到开头的三个问题:
1. 胖 Service 胖在哪? 不是行数,是规则没有家:散落各处、对象贫血、和技术实现焊死、改一处慌全身。
2. 拆完去哪了? 业务规则 → 实体/值对象/领域服务;流程编排 → 应用服务;数据库和外部接口 → 基础设施层。MVC 的 Controller 和 View 没变,变的是 Model 内部的组织方式。
3. 要不要用? 看规则密度。CRUD 项目别硬套,复杂业务别硬扛。
一句话版本:
MVC 解决的是"展示和逻辑分离",DDD 解决的是"业务逻辑内部怎么组织"。它们不在同一个维度,不是替代关系,是互补关系。DDD 做的事情,说穿了就一件:让每条业务规则待在它真正属于的地方。
十一、下一步
概念和重构思路都有了,但还差最后一步:从零开始写。 下一篇我们用一个完整可运行的电商订单模块,从建表到四层代码全部落地,包括事务怎么处理、领域事件怎么发、PO 和领域对象怎么转换——这些教程里通常语焉不详的工程细节,才是真正卡住人的地方。




