1. 项目缘起:为什么返利系统需要领域驱动设计
做电商返利系统,很多人第一反应是“这不就是一套分润计算逻辑嘛”,觉得几个表、几个接口就能搞定。但真正深入到业务里你会发现,返利系统的复杂程度远超想象:商品佣金比例随时调整、订单状态频繁流转、用户提现与风控规则交织、平台结算周期和商家账期错位……如果一开始没有把领域边界划清楚,后续每一次规则迭代都可能牵一发动全身。
我经历过一个真实的项目:某电商导购平台的返利模块,初期用传统三层架构快速上线,半年后代码里到处都是if (orderStatus == 3)这种硬编码判断,佣金计算散落在Service层各个角落,定时任务、消息消费者、接口调用各自维护一套状态流转逻辑。最痛的一次是结算规则调整,改一个佣金比例参数,结果报表数据对不上、用户提现被卡、商家结算重复打款,三个线上事故连着来。复盘时大家达成一个共识:不是开发能力不行,是领域模型压根没建起来,业务规则没有归属感,自然就到处乱飞。
那之后我们决定用领域驱动设计(DDD)对返利系统做一次重新建模。这篇文章不会讲DDD的理论教条,而是聚焦整个系统中最关键的两个设计决策:核心聚合根的划分和领域事件的定义与落地。如果你正在设计类似的返利、分销、分账系统,或者正在为已有系统的腐化代码发愁,这篇文章里的思路、代码示例和踩坑记录,应该能帮你少走不少弯路。
2. 领域拆解:先用事件风暴把业务链路摊开
2.1 返利系统的三条核心业务链路
在划分聚合根之前,必须先把返利系统的业务链路完全理清楚。我习惯用事件风暴的方式,拉着运营、产品、财务、技术一起过流程。不要只画用例图,而是把“发生了什么”逐个列出来。返利系统大致能梳理出三条主链路:
- 下单返利链路:用户通过推广链接下单 → 电商平台回传订单(待付款/已付款) → 订单状态变更(确认收货) → 返利计算 → 返利入账 → 用户可提现。
- 提现链路:用户发起提现 → 校验可提现余额与风控规则 → 创建提现单 → 人工/自动审核 → 打款 → 结果回传 → 账户余额扣减。
- 商品与佣金管理链路:运营配置商品推广计划 → 商家调整佣金比例 → 商品类目变更 → 佣金策略版本更新 → 影响后续新订单的返利计算。
三条链路不是孤立的,它们通过一个核心概念交汇——返利订单。返利订单承载了用户、商品、电商订单号、佣金比例、状态流转、结算结果等几乎全部关键信息。所以接下来的聚合根设计,一定是围绕返利订单展开的。
2.2 聚合边界划分的核心原则
DDD里聚合根的划分原则其实就一句话:在一个事务边界内部,保证业务一致性。但落地时很多人会犯错——把“感觉上相关”的东西全塞进一个聚合里,结果聚合越来越大,事务锁越来越多,并发量一上来系统先垮。
我个人的实操经验是三条:
- 优先从业务不变式出发找聚合边界,而不是从数据关系的外键出发。比如“返利金额必须为正”“同一订单不能被重复计算返利”,这些才是真正需要强一致性的规则。
- 能容忍最终一致的关系,不要放进同一个聚合。用户下单、订单回传、返利计算、提现,这个链条天然是异步的,不可能也不必要全部包裹在一个大事务里。
- 聚合之间的交互,优先用领域事件解耦,不要直接调用对方的Repository或者Service。这也是这篇文章重点讲的部分。
基于这三条原则,我们把系统划分为四个主要聚合:返利订单聚合(核心)、用户账户聚合、商品推广计划聚合、提现单聚合。下面逐一拆解每个聚合的职责与内部结构。
3. 核心聚合根设计:返利订单聚合
3.1 为什么选择“返利订单”作为核心聚合根
电商平台回传一个原始订单后,系统要先判断这个订单是不是通过推广链接产生的,再计算可返利润,再确定佣金归属,最后还要跟踪订单状态直到可结算。所以“返利订单”天然是所有返利业务的锚点。后续无论你加多少功能——团队管理、渠道分成、活动奖励——都会挂到这个聚合上。
我把返利订单聚合定义为整个系统的核心聚合根,而不是“用户”或者“商品”。原因很简单:用户聚合承载的是账户资产与个人信息,它的生命周期和返利订单并不一致;商品推广计划聚合关注的是佣金配置的时效性,它会频繁修改,但每次修改不应该直接影响历史订单。返利订单这个聚合刚好是稳定且高并发的,它记录的是每一次推广行为的快照,天然适合作为一致性边界。
3.2 聚合内部的实体与值对象
聚合根内部结构如图表所示(文字描述,不用图):
RebateOrder(聚合根) ├── orderId(电商平台原始订单号,全局唯一) ├── rebateOrderId(内部订单号) ├── userInfo(值对象:userId, userLevel, inviteUserId等) ├── productSnapshot(值对象:productId, productName, categoryId, price等) ├── commissionPolicy(值对象:rate, floorAmount, validFrom, validTo等) ├── rebateAmount(值对象:money, currency) ├── status(枚举:PENDING, VALIDATING, CALCULATED, SETTLED, CLOSED) └── events(领域事件列表,记录已发布的事件)这里重点解释一下**值对象(Value Object)**的使用。很多初学者会问:为什么commissionPolicy不单独建表,而要作为值对象塞到订单快照里?
因为返利计算有一个非常重要的特点:佣金比例必须在订单发生时刻被固化。假设用户在周一通过推广链接下单,当时的佣金比例是10%,周三商家调整成了5%,那么这个订单的返利应该按哪次的规则算?答案是按周一的10%。如果commissionPolicy是单独的表关联查询,一旦商家改了配置,历史订单的返利计算依据就变了——这会导致报表对不上、用户投诉。
所以正确的做法是把下单时刻的佣金政策完整复制进订单聚合,形成不可变的快照。这个设计决策,本质上是用空间换确定性,也是这个聚合根存在的核心价值之一。
3.3 关键业务规则与不变式
聚合根内部维护的业务不变式,我整理成了下表:
| 不变式 | 说明 | 违反后果 |
|---|---|---|
| 同一电商订单号只能产生一条有效返利订单 | 防止重复计算返利 | 用户账户金额错误,资损事故 |
| 佣金比例必须是 [0,1] 范围内的正数 | 防止配置脏数据进入计算 | 异常金额入账,审计难以追溯 |
| 订单金额必须大于0才能进入返利计算 | 防止无效订单干扰结算 | 报表失真,出现负数返利 |
| 返利订单状态流转必须符合合法状态机 | 防止跳状态导致结算错误 | 提现提前放行或订单悬挂 |
| 佣金快照时间戳必须早于订单创建时间 | 确保规则时效性 | 规则应用错误,返利金额不准 |
这些不变式,在DDD落地时应该由聚合自身保证,而不是靠Service层去检查。我在代码里是这样实现的:
public class RebateOrder { private RebateOrderId rebateOrderId; private String externalOrderId; private UserInfo userInfo; private ProductSnapshot productSnapshot; private CommissionPolicy commissionPolicy; private Amount rebateAmount; private RebateStatus status; private List<IDomainEvent> domainEvents; private RebateOrder() {} // 工厂方法:从电商回传订单创建聚合根 public static RebateOrder createFromExternalOrder( String externalOrderId, UserInfo userInfo, ProductSnapshot productSnapshot, CommissionPolicy policy) { // 参数校验就是业务规则的一部分 Preconditions.checkArgument(policy.getRate().compareTo(BigDecimal.ZERO) > 0, "佣金比例必须大于0"); Preconditions.checkArgument(productSnapshot.getPrice().compareTo(BigDecimal.ZERO) > 0, "订单金额必须大于0"); RebateOrder order = new RebateOrder(); order.rebateOrderId = RebateOrderId.generate(); order.externalOrderId = externalOrderId; order.userInfo = userInfo; order.productSnapshot = productSnapshot; order.commissionPolicy = policy; order.status = RebateStatus.PENDING; // 领域事件在聚合内部记录,事务提交后统一发布 order.addDomainEvent(new RebateOrderCreatedEvent( order.rebateOrderId, externalOrderId, userInfo.getUserId(), policy.getRate() )); return order; } // 状态流转方法:确认收货 public void confirmReceived() { if (this.status != RebateStatus.CALCULATED) { throw new IllegalStateException("只有已计算的订单才能确认收货,当前状态:" + this.status); } this.status = RebateStatus.SETTLED; // 状态变更也是一个领域事件 this.addDomainEvent(new RebateOrderSettledEvent(this.rebateOrderId, this.rebateAmount)); } // 聚合内部发布领域事件的方法,统一出口 private void addDomainEvent(IDomainEvent event) { if (this.domainEvents == null) { this.domainEvents = new ArrayList<>(); } this.domainEvents.add(event); } public List<IDomainEvent> getDomainEvents() { return domainEvents; } public void clearDomainEvents() { this.domainEvents.clear(); } }需要特别说明:聚合根内部的事件列表是临时内存变量,不是持久化的业务数据。它只在当前事务内使用,事务提交后由Repository统一取出并发布到消息中间件,然后清空。这是很多DDD落地失败的关键细节——把领域事件混到业务表里,容易造成数据冗余和语义混乱。
3.4 聚合根与其他聚合的协作
聚合根之间不直接互相调用,而是通过聚合ID引用实现关联。比如返利订单聚合中保存了userId和inviteUserId,但这只是对用户聚合的引用,不代表返利订单聚合内有用户信息表。
这样设计带来的好处很直接:
- 用户账户聚合和返利订单聚合可以独立伸缩。假设大促期间下单量暴增,我可以只扩展返利订单聚合对应的服务实例,用户账户服务不必跟着扩容。
- 修改用户聚合的内部结构,不会影响返利订单聚合的代码。比如用户等级规则调整,虽然可能影响后续订单的返利计算,但已经生成的返利订单不需要做任何修改——它的快照数据已经固化了。
- 跨聚合的事务一致性通过领域事件最终达成,而不是靠分布式事务。这一点在下面详细展开。
4. 领域事件建模:让订单链路异步解耦
4.1 领域事件在返利系统中的角色
领域事件(Domain Event)用一句话解释就是:聚合根对外告知“某件业务相关的事情已经发生了”。返利系统里,订单从创建到结算的整个生命周期中,每一步都会产生值得其他模块关注的事件:
RebateOrderCreatedEvent:新返利订单产生,用户账户模块可用它做后续分析。RebateCalculatedEvent:返利金额计算完成,通知用户侧展示“预计可赚xx元”。RebateOrderSettledEvent:订单确认收货、返利终态确定,通知账户模块入账。SettlementFailureEvent:结算失败,触发重试或人工介入。RebateOrderClosedEvent:订单取消或关闭,返利作废,清理在途状态。
设计领域事件时,我遵循几个原则:
- 事件的语义必须是“已发生的事实”,用过去时态命名。
- 事件内容必须携带足够的上下文,让消费方不需要回头查数据库就能处理。
- 事件与命令要区分:命令(Command)是我要求你做某件事;事件(Event)是所有模块都必须接受的事实。两者混用是最常见的错误。
4.2 领域事件的定义与代码示例
返利订单创建事件的定义:
public class RebateOrderCreatedEvent implements IDomainEvent { private final String rebateOrderId; private final String externalOrderId; private final String userId; private final String inviteUserId; private final BigDecimal commissionRate; private final BigDecimal orderAmount; private final OffsetDateTime occurredAt; public RebateOrderCreatedEvent(String rebateOrderId, String externalOrderId, String userId, String inviteUserId, BigDecimal commissionRate, BigDecimal orderAmount) { this.rebateOrderId = rebateOrderId; this.externalOrderId = externalOrderId; this.userId = userId; this.inviteUserId = inviteUserId; this.commissionRate = commissionRate; this.orderAmount = orderAmount; this.occurredAt = OffsetDateTime.now(); } @Override public OffsetDateTime getOccurredAt() { return occurredAt; } // getters... }有人会问:事件里带上inviteUserId和commissionRate不算冗余吗?是不是破坏封装?
我的回答是:领域事件就是为了跨聚合传递上下文才存在的。消费方(比如用户账户模块)拿到RebateOrderCreatedEvent,可以直接更新用户的“推广收益概览”而不必反查订单服务。如果事件里只有rebateOrderId,那消费方每次都要做一次RPC或查库,异步化的收益就大打折扣了。当然这会带来事件与订单数据的重复存储,但用空间换时间、换解耦,在返利系统这种高读场景下非常划算。
4.3 事件发布机制与事务边界的配合
领域事件必须在聚合状态变更的同一个数据库事务内持久化,才能保证“事件不会丢”。但直接发消息队列的话,又会面临“数据库提交成功但消息发送失败”的双写问题。
我用的方案是事务性发件箱(Transactional Outbox)模式,配合消息表实现:
RebateOrder 聚合状态变更(事务A) ↓ 同时向 outbox_event 表写入一条事件记录(同一个事务A) ↓ 定时任务扫描 outbox_event 表中未发送的记录 ↓ 发送到 Kafka / RocketMQ ↓ 标记为已发送(发送成功后再更新状态)伪代码逻辑如下:
@Transactional public void createRebateOrder(CreateRebateOrderCommand command) { RebateOrder order = RebateOrder.createFromExternalOrder(...); rebateOrderRepository.save(order); // 关键:和聚合保存放在同一个事务里 for (IDomainEvent event : order.getDomainEvents()) { outboxRepository.save(new OutboxEvent(event)); } order.clearDomainEvents(); }这里的OutboxEvent不是业务表,而是发送箱表,只做消息持久化中转。outboxRepository.save()和rebateOrderRepository.save()必须在一个事务里,这是整个异步化不丢事件的核心保障。
对应的定时发送任务:
@Component public class OutboxPublisher { private static final int BATCH_SIZE = 100; @Scheduled(fixedDelay = 1000) public void publishPendingEvents() { List<OutboxEvent> pendingEvents = outboxRepository.findTopNByStatus(BatchSize); for (OutboxEvent event : pendingEvents) { try { messageProducer.send(event.getTopic(), event.getPayload()); outboxRepository.markSent(event.getId()); } catch (Exception ex) { // 记录日志,等待下轮重试 log.error("Outbox event publish failed", ex); } } } }定时轮询的间隔可以按实际延迟要求调,我常用1秒到5秒。如果对延迟极其敏感,也可以用监听数据库binlog的方式替代定时轮询,但复杂度会高不少,大多数返利场景下1秒的延迟完全够用。
5. 其他核心聚合根:账户聚合与提现单聚合
5.1 用户账户聚合:资产变更必须单点收敛
返利订单聚合负责“产生返利”,但“返利入账”“提现扣减”这一类资产操作,必须收敛到用户账户聚合。理由很简单:资产操作是高一致性要求,不能分散到多个聚合里。
用户账户聚合的构成:
userId:账户归属。balance:可提现余额。frozenBalance:提现中冻结金额(防止超提)。totalEarned:累计收益。version:乐观锁版本号。
这里有一个设计细节:返利订单结算后,账户聚合通过监听RebateOrderSettledEvent来增加余额,而不是由订单聚合直接调用账户服务。这会让很多不熟悉DDD的同事困惑:“这不多此一举吗?直接加个余额不就行了?”
我的解释是:资产变更必须有一条独立且唯一的入账通道。如果业务代码里到处都是“加余额”“减余额”的调用,将来加一个风控逻辑(比如限制单日提现额度、异常账户冻结)的时候,你根本不知道要去哪里加。通过统一监听事件入账,账户聚合就成了所有资产操作的唯一漏斗,规则只需要在入口处加一道,全链路就都覆盖了。
账户聚合核心代码:
public class UserAccount { private String userId; private Money balance; private Money frozenBalance; private Money totalEarned; private int version; public void settleRebate(Money amount) { Preconditions.checkArgument(amount.isPositive(), "入账金额必须为正"); this.balance = this.balance.add(amount); this.totalEarned = this.totalEarned.add(amount); this.version++; } public void freezeForWithdrawal(Money amount) { Preconditions.checkArgument(this.balance.compareTo(amount) >= 0, "余额不足"); this.balance = this.balance.subtract(amount); this.frozenBalance = this.frozenBalance.add(amount); this.version++; } public void confirmWithdrawal(Money amount) { Preconditions.checkArgument(this.frozenBalance.compareTo(amount) >= 0, "冻结金额不足"); this.frozenBalance = this.frozenBalance.subtract(amount); this.version++; } }并发控制这里必须强调:账户聚合的更新用乐观锁(version字段),配合数据库的UPDATE ... WHERE version = ?语句。不要用分布式锁,因为账户操作的并发量在返利场景下并不低(大促时每秒几百上千笔入账),分布式锁会成为性能瓶颈,而且锁超时后容易出各种边界问题。
5.2 提现单聚合:长流程状态机
提现单聚合和返利订单聚合类似,也是一个长生命周期状态机,但业务规则更偏重风控与资金安全。提现单的状态流转:
CREATED(创建) → REVIEWING(审批中) → APPROVED(通过) → PAYING(打款中) → PAID(已打款) → REJECTED(拒绝)提现单聚合的核心不变式是:冻结金额和提现单金额必须严格对应。用户发起提现后,freezeForWithdrawal先把账户余额冻结;审批通过后,confirmWithdrawal减少冻结金额;打款结果异步回传后,提现单终态更新。如果打款失败,需要走UNFREEZE流程,把冻结金额解冻回余额。
这个聚合和返利订单聚合不同的是,提现单会频繁与外部渠道交互(银行、支付平台),所以需要多维护一个“渠道状态”字段,用于和第三方对账。这里建议不要把渠道返回的原始报文解析后塞进领域模型,应该新增一个WithdrawalChannelRecord值对象来承载渠道侧的数据快照。
5.3 商品推广计划聚合:规则变更的版本管理
商品推广计划聚合相对独立。商家调整佣金比例、运营配置活动,都发生在这一侧。它需要维护一个版本号,每次变更生成新版本。返利订单在创建时,读取的是当前生效的佣金版本,并把该版本固化进订单聚合。
需要注意:佣金策略变更在返利系统里是一个高危操作。商家改低佣金后,已产生的返利订单不能受影响;改高佣金后,也不应该追溯到历史订单,否则报表和结算都会乱套。所以这个聚合内部要设计effectiveFrom和effectiveUntil两个时间字段,新订单只取当前时间命中的佣金版本。
实践中一个容易忽略的点:运营可能在同一天内修改多次佣金比例,如果只存一个最新版本,历史就丢了。我建议设计成多版本共存,每次变更新增一条记录,查询时按时间区间过滤。你甚至可以加一个changeReason字段,方便运营回溯“为什么今天佣金突然变高了”。
6. 领域事件在返利系统中的应用场景清单
为了更直观,我把返利系统里常用到的领域事件及其消费方整理成一张表:
| 领域事件 | 发布聚合 | 消费方 | 消费后的动作 |
|---|---|---|---|
| RebateOrderCreatedEvent | 返利订单 | 用户通知服务 | 推送“返利订单已生成”通知 |
| RebateCalculatedEvent | 返利订单 | 用户查询服务 | 展示“预计返利xx元” |
| RebateOrderSettledEvent | 返利订单 | 用户账户聚合 | 增加可提现余额 |
| RebateOrderSettledEvent | 返利订单 | 数据报表服务 | 更新佣金收入统计 |
| RebateOrderClosedEvent | 返利订单 | 用户账户聚合 | 无入账操作,清挂在途订单 |
| WithdrawalCreatedEvent | 提现单 | 风控服务 | 触发异步风控校验 |
| WithdrawalApprovedEvent | 提现单 | 打款渠道适配器 | 发起打款请求 |
| WithdrawalPaidEvent | 提现单 | 通知服务 | 通知用户提现成功 |
| CommissionPolicyChangedEvent | 商品推广计划 | 返利订单工厂 | 用于日志审计,不参与订单创建 |
这张表的要点是:同一个事件可以有多个消费方,一个消费方也可以监听多个事件。领域事件本质上是一种“发布-订阅”模型,发布方不需要知道谁在听,这正是它实现解耦的根本原因。
7. 落地过程中的坑与排查技巧
7.1 事件消费的幂等性 —— 99%的故障都出在这里
领域事件发布后,消费方必须保证幂等。原因很简单:消息队列做不到恰好一次投递。我实际遇到的场景是:Kafka 的 at-least-once 语义下,同一个RebateOrderSettledEvent在消费端被处理了两次,用户余额翻了倍。
幂等处理的方案要放在消费方,不能指望发布方做全局去重。实战中最稳妥的方式是:消费方用一个processed_event_id表记录已经处理过的事件ID,处理前先查重,处理完成后插入记录,这两步要在同一个数据库事务里完成。有些团队喜欢用Redis的SETNX做去重,虽然性能好,但要做持久化和过期时间设计,不然Redis重启后又能重复消费了。我的建议是:涉及资产类的消费,用数据库去重,别省这个成本。
核心代码如下:
@Transactional public void handleSettledEvent(RebateOrderSettledEvent event) { // 同一个事务里先查重再处理 if (processedEventRepository.exists(event.getRebateOrderId())) { log.info("重复事件,直接跳过:{}", event.getRebateOrderId()); return; } UserAccount account = userAccountRepository.findByUserId(event.getUserId()); account.settleRebate(event.getRebateAmount()); userAccountRepository.save(account); processedEventRepository.save(new ProcessedEvent(event.getRebateOrderId())); }如果你用的是RocketMQ,可以利用它的消息Key天然去重;但底层原理是一样的,都是“标记-校验-处理”。
7.2 聚合根的膨胀 —— 别让“大订单聚合”拖垮性能
实际建模中,返利订单聚合会承载越来越多的“附属功能”:售后单、用户评价、物流跟踪、发票……这些虽然和订单相关,但不应该都塞进返利订单聚合根里。
我见过一个反例:某个团队把用户退款处理也映射到返利订单聚合里,结果每次创建返利订单都要带上退款单据的关联判断,导致订单创建接口的响应时间从150ms涨到了600ms。数据库锁竞争也变严重了——因为退款单的变更会和订单主表锁争抢。
解决思路很简单:把售后单单独建模。返利订单聚合只关注“是否需要收回已发返利”,通过RebateOrderRevokedEvent和售后模块联动,而不是让两个聚合合并成一个。聚合越小,并发冲突越少,性能越容易保证。
7.3 事件命名混乱 —— 统一用“过去时+业务含义”
事件命名是设计评审中最容易起分歧的地方。有的团队用OrderPaid,有的用OrderPaySuccess,有的用PayCallback,同一个业务含义三套名字,消费方对接时痛苦不堪。
我建议团队内部定一条铁律:领域事件统一采用“业务对象+过去式动作”的格式,比如RebateOrderCreated、WithdrawalPaid。避免使用Callback、Notify这类偏向技术实现的词,也避免用命令式的CreateRebateOrder。事件是已经发生的事实,不是要求别人做什么。
7.4 领域事件与消息队列的选型匹配
不同的消息中间件,接入成本和特性差别很大,我简单对比一下:
| 消息中间件 | 事务消息支持 | 顺序消息 | 适用场景 |
|---|---|---|---|
| Kafka | 支持配合Outbox实现 | 单分区内有序 | 海量事件、吞吐优先 |
| RocketMQ | 原生事务消息 | 队列内有序 | 金融级强可靠性 |
| RabbitMQ | 需配合Outbox | 不内置顺序 | 中小规模、快速迭代 |
返利系统里,订单结算入账这类事件建议用RocketMQ或者Outbox配合Kafka,不要用RabbitMQ直接裸奔事务。因为资产类操作对可靠性要求极高,一旦事件丢失,补数据的成本远大于用一点性能来换。
但这里有一个折中:Kafka的幂等性配置和事务性 API 在Java场景下会吃掉部分吞吐,如果QPS只有几百,根本感受不到性能差异。所以选型时先用可靠性换吞吐,别为了“将来可能的高并发”牺牲当下的稳定性。
7.5 单元测试的策略 —— 聚合根的测试要覆盖业务规则
DDD强调聚合根是业务规则的家,那单元测试就重点覆盖聚合的规则校验。我用JUnit写测试的示意:
class RebateOrderTest { @Test void testConfirmReceivedWithWrongStatusThrowsException() { RebateOrder order = RebateOrder.createFromExternalOrder(...); // 模拟状态还在 PENDING,直接确认收货 assertThrows(IllegalStateException.class, order::confirmReceived); } @Test void testSettleRebateFreezesWhenBalanceInsufficient() { UserAccount account = UserAccount.create("user1"); assertThrows(IllegalArgumentException.class, () -> account.freezeForWithdrawal(Money.of("1000"))); } }这类测试不用启动Spring容器、不用连数据库,纯内存就能跑,速度极快。它能保证你在重构代码时,业务规则不会被改坏。这是DDD落地中最容易忽视,也最回报最高的一部分投入。
8. 工程落地要点总结:一套可直接复用的骨架
8.1 代码分层结构建议
这里给出我常用的包结构,方便直接抄作业:
com.example.rebate ├── application │ ├── service # 应用服务,负责事务编排 │ ├── command # 命令对象 │ └── eventhandler # 领域事件监听器 ├── domain │ ├── model # 聚合根、实体、值对象 │ ├── repository # 仓储接口 │ ├── service # 领域服务(只有聚合根装不下的逻辑才放这里) │ └── event # 领域事件定义 ├── infrastructure │ ├── persistence # Repository实现、JPA/MyBatis │ ├── mq # 消息队列适配器 │ └── outbox # 发件箱实现 └── interfaces ├── rest # 对外接口/Controller └── job # 定时任务注意:应用服务只做事务编排和参数组装,业务逻辑在聚合根内部。如果发现应用服务里开始出现if...else判断业务规则,那就是领域逻辑漏出去了,需要及时收回来。
8.2 数据库表设计的对应关系
聚合根和数据库表并不是一一对应的。我最初踩过一个坑:以为每个值对象都要建一张表,结果商品快照、佣金策略都拆了表,查询时不得不做多次Join,性能很差。
实际的表设计原则是:聚合本身一张主表,值对象采用序列化或平铺字段的方式存储。
rebate_order表的结构大概是:
| 字段 | 说明 | 类型 |
|---|---|---|
| rebate_order_id | 聚合根ID | varchar(64) |
| external_order_id | 电商原始订单号,唯一索引 | varchar(64) |
| user_id | 用户ID | varchar(64) |
| invite_user_id | 邀请人ID | varchar(64) |
| product_snapshot | 商品快照(JSON) | text |
| commission_policy | 佣金快照(JSON) | text |
| rebate_amount | 返利金额(分) | bigint |
| status | 状态 | varchar(32) |
| version | 乐观锁版本 | int |
| created_at / updated_at | 审计字段 | datetime |
把product_snapshot和commission_policy直接存JSON,既保证了快照的完整性,又避免了一堆冗余的关联表。如果你觉得JSON不直观,也可以在ORM里映射为@Embedded对象,双管齐下。
8.3 代码仓库的模块划分
如果项目体量较大(多人协作),我建议按模块拆分成多仓库或多Module:
rebate-order-service:返利订单聚合的读写服务。user-account-service:用户账户聚合的读写服务。withdrawal-service:提现单聚合的读写服务。promotion-service:商品推广计划聚合的读写服务。outbox-publisher:统一的发件箱扫描与事件发布任务。
每个Service内部都是完整的DDD四层分层,Service之间通过领域事件异步通信,通过接口进行必要的同步查询补偿。这样做之后,团队可以并行开发,订单模块的改动不会导致账户模块的代码冲突。
9. 写给同行的话:DDD不是银弹,但返利系统真的很适合
我在多个项目中实践过DDD,有一个明确的感受:DDD最怕“为了DDD而DDD”。如果系统只有简单的CRUD,没有复杂业务规则,没有多个状态机联动,生搬硬套DDD反而增加开发和维护成本。但对返利系统这种业务规则密度高、状态流转复杂、多个子系统协作紧密的场景,DDD的价值是实实在在的:每个业务规则都找到了它的“家”,跨模块通信都走领域事件,新增需求时不再需要在一堆if里翻找改哪里。
最后,如果只能给一条建议,我会说:先从画事件风暴开始,把业务流程中的每个“已发生事实”列出来,再决定哪些属于一个聚合。聚合并不需要一开始就完美,可以随着业务理解加深逐步调整。但领域语言的统一、聚合边界的划分、事件上下文的明确,这三件事在项目第一天就要做对。
踩过几次坑之后,我的体会是:返利系统的核心从来不是“怎么算钱”,而是“如何保证算过的每一笔钱都对得上账”。DDD的聚合根和领域事件,恰好是为这个核心服务的两把趁手工具。希望这篇文章能给你一些可落地的参考。