摘要:本章先不急着讲 DDD 名词,而是从一段看似正常的下单代码出发,看清业务规则散落、技术细节污染、边界缺失和语言不一致如何把系统拖成大泥球。读完后,你会知道 DDD 到底是在解决什么问题,以及什么时候不该为了 DDD 而过度设计。
本系列怎么学:从问题到落地
这套内容不按"DDD 名词表"来排,而是按新手真正理解一件事的顺序来排:先看问题,再划边界,再建模型,最后落到代码、数据库和重构实践。
| 阶段 | 章节 | 新手要抓住的问题 |
|---|---|---|
| 战略设计 | 01-04 | 系统为什么会乱?业务边界怎么划?上下文之间怎么协作? |
| 战术建模 | 05-09 | 对象怎么设计?规则放在哪?聚合怎么守一致性?模型怎么创建和保存? |
| 工程落地 | 10-13 | 代码怎么分层?目录怎么摆?数据库怎么映射?复杂查询怎么处理? |
| 实践收口 | 14 | 老项目怎么渐进式改造?什么时候该用/不该用 DDD? |
一、先看一段"很正常"的代码
我们的"优选门店"要做一个下单功能。按传统三层架构(Controller → Service → DAO),多数人会这么写:
@ServicepublicclassOrderService{@AutowiredprivateProductMapperproductMapper;@AutowiredprivateStockMapperstockMapper;@AutowiredprivateMemberMappermemberMapper;@AutowiredprivateCouponMappercouponMapper;@AutowiredprivateOrderMapperorderMapper;@TransactionalpublicLongplaceOrder(PlaceOrderRequestreq){// 1. 查商品、算价格BigDecimaltotal=BigDecimal.ZERO;for(OrderItemReqitem:req.getItems()){Productp=productMapper.selectById(item.getProductId());if(p==null||p.getStatus()!=1){thrownewRuntimeException("商品不可售");}total=total.add(p.getPrice().multiply(newBigDecimal(item.getQty())));}// 2. 用优惠券if(req.getCouponId()!=null){Couponc=couponMapper.selectById(req.getCouponId());if(c.getThreshold().compareTo(total)<=0){total=total.subtract(c.getAmount());}}// 3. 扣库存for(OrderItemReqitem:req.getItems()){introws=stockMapper.deduct(item.getProductId(),item.getQty());if(rows==0)thrownewRuntimeException("库存不足");}// 4. 加会员积分Memberm=memberMapper.selectById(req.getMemberId());m.setPoints(m.getPoints()+total.intValue());memberMapper.updateById(m);// 5. 落订单Orderorder=newOrder();order.setMemberId(req.getMemberId());order.setTotalAmount(total);order.setStatus(0);orderMapper.insert(order);returnorder.getId();}}这段代码能跑、能上线。问题是:它会随着业务长大而一天天烂掉。
二、它到底烂在哪(传统架构的弊端)
弊端 1:业务逻辑全堆在 Service,没人守得住规则
下单的核心业务规则(哪些商品能买、满减怎么算、库存怎么扣、积分怎么加)全部散落在一个方法里。Product、Order、Member这些类只有 getter/setter,是纯粹的数据袋子(这就是后面要讲的"贫血模型")。
后果是:
- 同样的"算订单总价"逻辑,购物车页面、确认页、下单接口各写了一遍,规则一改三处都要改,必漏一处。
- "积分 = 实付金额取整"这条规则写在
OrderService里,哪天做退款的同事根本不知道有这条规则,积分就退错了。
弊端 2:技术细节和业务逻辑搅在一起
productMapper.selectById(数据库技术) 和 “商品不可售就报错”(业务规则)写在同一个方法里。换 ORM、加缓存、做分库分表,都要动这段业务代码。业务和技术互相污染,改谁都怕碰坏对方。
弊端 3:没有边界,牵一发动全身
订单、库存、会员、促销全在一个事务、一个 Service 里。促销逻辑一复杂,这个方法就膨胀到几百行。它有 5 个Mapper依赖,意味着它和 5 个表强耦合。这就是导读里说的"大泥球":
不同关注点的代码混在一起 → 难以识别领域逻辑 → 该内聚的分散、该解耦的纠缠 → 改业务伤技术、改技术伤业务 → 越维护越乱,重复和不一致越来越多。
弊端 4:代码和业务对不上号
产品经理说的是"会员下单后冻结优惠券",代码里却是couponMapper.updateStatus(id, 2)。2是什么意思?没人知道。业务语言和代码语言对不上,每次沟通都要"翻译",需求一复杂就翻译错。
三、DDD 凭什么能治
DDD(Domain-Driven Design,领域驱动设计)的核心思想其实就一句话:
让代码围着"业务领域"来组织,而不是围着"数据库表"或"技术框架"来组织。
它给出两个层面的方法:
战略设计——先把大系统按业务边界切块。下单、库存、促销、会员各自是独立的"限界上下文",各有各的模型和边界。改促销,物理上就碰不到积分的代码。
战术设计——每块内部,让业务规则有"家"可归。下单的规则属于"订单"这个聚合,由它自己守护;积分规则属于"会员"。Order不再是数据袋子,而是有行为的对象:order.place()、order.cancel()。
用 DDD 重构后,上面那段代码大致会变成:
@ServicepublicclassPlaceOrderAppService{privatefinalProductRepositoryproductRepo;privatefinalOrderRepositoryorderRepo;privatefinalDomainEventPublishereventPublisher;@TransactionalpublicOrderIdplaceOrder(PlaceOrderCommandcmd){// 应用层只做"编排":把活儿派给领域对象,自己不写业务规则List<OrderLine>lines=cmd.toOrderLines(productRepo);// 取价、校验可售Orderorder=Order.place(cmd.memberId(),lines,cmd.coupon());// 业务规则在聚合内部orderRepo.save(order);eventPublisher.publish(order.domainEvents());// 发"订单已创建"事件,库存/积分异步响应returnorder.getId();}}对比一下你能直观感受到的变化:
| 维度 | 传统三层 | DDD |
|---|---|---|
| 业务规则在哪 | 散落在 Service | 内聚在领域对象(聚合)里 |
| 谁守护规则 | 没人,靠程序员自觉 | 聚合根强制守护 |
| 边界 | 没有,全耦合 | 限界上下文物理隔离 |
| 改促销会影响积分吗 | 很可能 | 不会,物理隔离 |
| 代码读起来像业务吗 | 不像,是 SQL 流水账 | 像,order.place()直接对应"下单" |
四、什么时候不该用 DDD(避免过度设计)
DDD 不是银弹,它有成本(学习成本、代码量)。别被"高大上"冲昏头:
- 简单 CRUD、后台管理、数据报表:业务逻辑就是增删改查,没什么规则要守护。直接三层架构甚至低代码,DDD 反而是负担。
- 业务逻辑极其简单的项目:杀鸡用牛刀。
- 真正适合 DDD 的:业务规则复杂、会长期演进、多人协作的核心业务系统——比如我们的订单交易、促销引擎。
判断口诀:逻辑越复杂、越要长期演进、越值钱的业务,越值得上 DDD。
五、怎么验证一个模块是否真的需要 DDD
别靠感觉判断,拿一个真实模块按下面清单打分。命中越多,越值得用 DDD;命中越少,越该保持简单。
| 观察点 | 适合 DDD 的信号 | 不适合 DDD 的信号 |
|---|---|---|
| 业务规则 | 有状态流转、组合规则、例外规则 | 只是字段增删改查 |
| 变化频率 | 需求经常变,且每次都怕改坏 | 长期稳定,很少改 |
| 业务价值 | 核心收入、核心体验、核心风控 | 后台配置、报表导出 |
| 协作人数 | 产品、业务、开发多人反复沟通 | 一个人维护的小工具 |
| 当前痛点 | Service 很长、规则重复、bug 频发 | 代码简单直观 |
可以在老项目里用命令做一个粗略体检:
# 找出超长 Service,优先看是否有业务规则堆积findsrc/main/java-name"*Service.java"-print0|xargs-0wc-l|sort-nr|head# 找魔法状态值、散落的状态判断grep-R"status.*=="src/main/javagrep-R"setStatus"src/main/java# 找一个服务依赖了多少 Mapper/Repository,依赖越多越可能是大泥球grep-R"@Autowired.*Mapper\|private final .*Mapper"src/main/java注意:这些命令只能帮你发现"可疑点",不能替你下结论。最终还是要回到业务:这块是不是复杂、值钱、长期演进。