DDD(领域驱动设计)篇四:退费 + 支付,三层 vs DDD 各撸一遍

DDD(领域驱动设计)篇一:DDD 到底在解决什么问题?

DDD(领域驱动设计)篇二:DDD 和三层架构到底差在哪?

DDD(领域驱动设计)篇三:实战对照------同一个订单业务,两种架构怎么写

先解决"看不懂"的问题: DDD到底把代码拆哪去了

你看不懂 DDD,多半是因为:三层架构里一个 Service 方法干的活,然而DDD被拆进了 x 个不同文件。先背这张对照表,后面读代码就靠它定位:

请求处理的活 三层架构放在哪 DDD放在哪
接 HTTP 参数 Controller SaleOrderCommonRuntimeController(还是 Controller,只是更薄)
流程编排、加锁、查单 Service 方法前半段 OrderBaseSalesRuntimeContext(应用层)
业务规则(能不能退、退多少) Service 方法中间的 if OrderBasedSalesEntity(实体的方法)
状态怎么流转 Service 里的 setStatus OrderDescriptorLoader(状态机声明)
事件之后干具体事 Service 方法后半段 handler 包(SalesOrderRefundHandler 等)
落库、记日志 Service 方法最后几行 SalesOrderDefaultHandler 的 postProcess
SQL Mapper / XML repo 包(IOrderEntityRepo)

接下来两节,每一节都按"三层版怎么写 → DDD 版怎么写"完整走一遍。


场景一:发起退款

用户在小程序点"申请退款",传订单号和退款金额,要求:已全额退过的拒掉、有在途退款单的拒掉、退款总额不能超过订单总额。

1.1 三层版:一个 Service 方法全包

按 patient 模块 OrderServiceImpl 的现有风格还原(示意写法,非逐字拷贝):

java 复制代码
// ===== Controller:接参数就往下扔 =====
@PostMapping("/refund")
public Result<RefundVO> refund(@RequestBody RefundDTO dto) {
    return Result.success(orderService.refundOrder(dto));
}
​
// ===== Service:所有活都在这一个方法里 =====
@Transactional(rollbackFor = Exception.class)
public RefundVO refundOrder(RefundDTO dto) {
    // 1. 参数校验
    Validator.validateNotNull(dto.getOrderId(), "订单ID不能为空");
    if (dto.getAmount() == null || dto.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
        throw new GlobalException("退款金额必须大于0");
    }
    // 2. 查单
    Order order = orderMapper.selectById(dto.getOrderId());
    if (order == null) {
        throw new GlobalException("订单不存在");
    }
    // 3. 业务规则:能不能退(注意:下个月退款申请页要复用这段,只能复制)
    if (RefundStatusEnum.REFUND == order.getRefundStatus()) {
        throw new GlobalException("订单已全额退款,不可再次退款");
    }
    long pending = orderRefundMapper.selectCount(new QueryWrapper<OrderRefund>()
        .eq("order_id", order.getId()).eq("succeed", 0));
    if (pending > 0) {
        throw new GlobalException("存在在途退款单,请等待处理完成后再试");
    }
    BigDecimal total = dto.getAmount().add(nvl(order.getRefundAmount()))
        .add(unSucceedRefundAmount(order.getId()));
    if (total.compareTo(order.getTotalAmount()) > 0) {
        throw new GlobalException("退款金额超过订单总额");
    }
    // 4. 技术细节:分布式锁(和上面的业务规则毫无关系,但挤在一起)
    RLock lock = redissonClient.getLock(order.getId() + ":refund");
    if (!lock.tryLock(1, 10, TimeUnit.SECONDS)) {
        throw new GlobalException("请勿重复提交退款");
    }
    try {
        // 5. 状态变更 + 落库
        order.setStatus(OrderStatus.REFUNDING);      // ← 全局搜 setStatus 的第 N 处
        orderMapper.updateById(order);
        OrderRefund refund = new OrderRefund();
        BeanUtil.copyProperties(dto, refund);
        refund.setSucceed(0);
        orderRefundMapper.insert(refund);
        // 6. 通知渠道、发消息、记日志......
        refundChannelApi.apply(order.getChannelOrderNo(), dto.getAmount());
        return buildRefundVO(refund);
    } finally {
        lock.unlock();
    }
}

问题在哪,前面系列讲过了,这里只强调一点:第 3 步那 40 行业务规则,是"订单"的知识,却长在 Service 里。下个月退款申请页要判断"能不能展示申请按钮",你只能复制或者再包一个方法。

1.2 DDD 版:一次 /refund 请求的六站旅行

下面全是 obs 模块的真实代码。按请求走的顺序看:

第 ① 站:Controller ------ 薄到不用看逻辑

java 复制代码
@Operation(summary = "订单退款")
@PostMapping("/refund")
public Result<RefundDto> refund(@RequestBody RefundParam param) {
    try {
        return runtimeContext.refund(param);   // 只转发
    } catch (Exception exception) {
        log.error("销售订单退款出现异常.", exception);
        return Result.error("系统繁忙.请稍候!");
    }
}

第 ② 站:应用层 refund() ------ 只编排,不写规则

这是 OrderBaseSalesRuntimeContext.java的真实方法(我删了部分日志):

java 复制代码
@Transactional(rollbackFor = {Exception.class, GlobalException.class, RuntimeException.class})
public Result<RefundDto> refund(RefundParam param) {
    Result<RefundDto> result = new Result<>();
​
    // 1. 找到订单实体(不是 Order 表对象,是"领域实体")
    orderEntityRepo.findOrder(orderId, orderNo, sourceChannel, externalOrderNo)
        .ifPresent(orderEntity -> {
            // 2. 锁交给适配器,方法体里只写业务
            Result<RefundDto> r1 = iDomainLockAdapter.logicWithLock(orderEntity.getOrderLockKey(), () -> {
                // 3. 业务规则问实体:"我能退吗?"(规则不在这里!)
                Result<String> isOrderRefundable = orderEntity.isOrderRefundable(param);
                if (!isOrderRefundable.isSucc()) {
                    return Result.error(isOrderRefundable.getMessage());
                }
                // 4. 造一个"退款事件"扔给实体------注意:不是 setStatus
                EventModel event = EventModelFactory.INSTANCE.getEventModel(SalesOrderEventConstants.refund);
                event.setValueObject(JSONUtil.parseObj(param));
                JSONObject object = orderEntity.onEvent(event);   // ← 核心就这一行
​
                RefundDto refundDto = new RefundDto();
                refundDto.setRefundId(object.getLong("refundId"));
                return Result.success(refundDto);
            });
            result.setData(r1.getData());
        });
    return result;
}

对比三层版:应用层没有一条 if 是业务规则,它只干四件事------找实体、加锁、问规则、发事件。

第 ③ 站:规则在实体身上 ------ isOrderRefundable

三层版第 3 步那 40 行,在这里长成了实体的方法(OrderBasedSalesEntity.java,真实源码脱敏后):

java 复制代码
public Result<String> isOrderRefundable(RefundParam refundParam) {
    SalesOrder order = this.getValueObject();
​
    // 条件1: 已全额退款,不可再退
    if (RefundStatusEnum.REFUND == order.getRefundStatus()) {
        return Result.error("订单已全额退款,不可再次退款");
    }
    // 条件2: 有在途退款单,等处理完再说
    long pendingRefundCount = orderRefundRepo.count(Wrappers.<OrderRefund>lambdaQuery()
            .eq(OrderRefund::getOrderId, this.getIdentity())
            .eq(OrderRefund::getSucceed, RefundSucceedEnum.NO));
    if (pendingRefundCount > 0) {
        return Result.error("存在在途退款单,请等待处理完成后再试");
    }
    // 本次退款金额必须大于 0
    BigDecimal refundAmount = refundParam.getAmount();
    if (refundAmount == null || refundAmount.compareTo(BigDecimal.ZERO) <= 0) {
        return Result.error("退款金额必须大于0");
    }
    // 条件3: 本次 + 已退 + 未成功退款 不得超过订单总额
    BigDecimal unSucceedRefundAmount = orderRefundRepo.list(...).stream()
            .map(OrderRefund::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add);
    BigDecimal totalRefund = refundAmount.add(order.getRefundAmount()).add(unSucceedRefundAmount);
    if (totalRefund.compareTo(order.getTotalAmount()) > 0) {
        return Result.error("本次退款金额加上已退款金额和未成功退款金额超过订单总额");
    }
    return Result.success();
}

以后不管哪个入口想知道"这单能不能退",都来问这一个方法------这就是"规则长在对象身上"的落地样子,不是概念。

第 ④ 站:状态机 ------ 你只管发事件,状态它来管

应用层那行 orderEntity.onEvent(event) 会先过状态机这关。规则在 OrderDescriptorLoader.java 里声明过:

java 复制代码
// 服务中 + 退款事件 → 退款中,干这活的是 salesOrderRefundHandler
domainDescriptor.registerStateMachine(running, refund, refunding);
domainDescriptor.registerEventTrigger(running, refund, "salesOrderRefundHandler");

如果订单是 cancelled 状态收到 refund 事件?描述符里没这条转移,verify 直接抛异常------你一行 if 都没写,非法流转就是进不来。

第 ⑤ 站:Handler ------ "退款事件发生了,该干什么"

框架按 salesOrderRefundHandler 这个 Bean 名找到 SalesOrderRefundHandler.java(真实源码全文就这么长):

java 复制代码
@Component
public class SalesOrderRefundHandler extends SalesOrderDefaultHandler {
​
    @Override
    public void postProcess(OrderBasedSalesEntity domain, EventModel event) {
        // 从事件里把参数捞出来
        RefundParam param = JSONUtil.toBean(event.getValueObject(), RefundParam.class);
        // 调实体的业务方法:创建退款单(怎么创建是实体的事)
        RefundDto refund = domain.refund(param);
        // 交给父类统一做:落库 + 记变更日志
        super.postProcess(domain, event);
        return JSONUtil.parseObj(refund);
    }
}

Handler 的宿命接口长这样(ProcessHandler.java):

java 复制代码
public interface ProcessHandler<ValueObject, E extends GenericDomain<ValueObject>> {
    boolean preProcess(E domain, EventModel event);   // 主逻辑前的钩子
    void process(E domain, EventModel event);          // 主逻辑
    JSONObject postProcess(E domain, EventModel event); // 主逻辑后的钩子
}

第 ⑥ 站:父类 postProcess ------ 落库 + 字段级变更日志(这节对你排查问题最有用)

super.postProcess 在 SalesOrderDefaultHandler.java:

java 复制代码
@Override
public JSONObject postProcess(OrderBasedSalesEntity domain, EventModel event) {
    // 1. 持久化领域实体(save 到 sales_order)
    SalesOrder salesOrder = orderEntityRepo.persist(domain);
    // 2. 对比改动前后的订单,记一条字段级变更日志(带 traceId)
    logger(event.getIdentity(), LoggerTypeEnum.UPDATE, oldSalesOrder, salesOrder);
    return new JSONObject();
}

注意它记日志的方式:反射对比新旧订单的每个字段,生成 "更新销售订单记录: 退款状态 从 未退款 变更为 退款中 traceId:xxx" 这样的日志存进 order_logger 表。**以后你再排查"订单状态是谁、在哪个事件里改的",不用全局搜 setStatus 了,查这个表就行,每条还带 traceId 能串链路。**这是三层写法给不了的排查体验。

串起来看一次退款请求的完整旅行:

步骤 文件 干什么
1 SaleOrderCommonRuntimeController 接参数、转发
2 OrderBaseSalesRuntimeContext.refund 找实体 → 加锁 → 问规则 → 发事件
3 OrderBasedSalesEntity.isOrderRefundable 三条退款规则说了算
4 GenericDomain.onEvent → 描述符 verify 状态机放行/拦截
5 SalesOrderRefundHandler.postProcess 调 domain.refund() 创建退款单
6 SalesOrderDefaultHandler.postProcess 落库 + 变更日志
7 框架 getTargetState running → refunding,改状态的代码你没写

场景二:支付回调

支付渠道(比如微信)异步通知你"这笔支付成功了/失败了"。

2.1 三层版骨架

java 复制代码
@PostMapping("/payNotify")
public Result<?> payNotify(@RequestBody PayNotifyDTO dto) {
    Payment payment = paymentMapper.selectById(dto.getPaymentId());
    if (payment.getSucceed() == 1) {           // 幂等:成功过的回调直接返回
        return Result.success();
    }
    Order order = orderMapper.selectById(payment.getOrderId());
    // 拿锁......
    if (dto.isSuccess()) {
        payment.setSucceed(1);
        paymentMapper.updateById(payment);
        if (全部支付成功(order)) {
            order.setStatus(OrderStatus.RUNNING);   // ← 又一处 setStatus
            orderMapper.updateById(order);
            // 通知服务层开始服务......
        }
    } else {
        payment.setSucceed(2);
        paymentMapper.updateById(payment);
        if (全部失败(order)) {
            order.setStatus(OrderStatus.NOTHING);   // ← 又一处
        }
    }
    return Result.success();
}

"全部支付成功""全部失败"这些判断写在方法里,下次要加"部分支付"逻辑就在这堆 if 里再套 if。

2.2 DDD 版真实代码

应用层 paymentNotify 的骨架(和 refund 一个模子:查支付单做幂等 → 找实体 → 加锁 → 选事件 → onEvent):

java 复制代码
public Result<PaymentNotifyDto> paymentNotify(PaymentResultEventValueObject param) {
    OrderPayment payment = orderPaymentRepo.getById(param.getPaymentId());
    // 幂等判断:已成功的回调直接返回
    if (PaymentSucceedEnum.YES == payment.getSucceed()) {
        return Result.success();
    }
    OrderBasedSalesEntity orderEntity = orderEntityRepo.findOrder(...).get();
    return iDomainLockAdapter.logicWithLock(orderEntity.getOrderLockKey(), () -> {
        // 按渠道结果"选事件",不是改状态
        EventModel event = param.getPaymentResult()
            ? EventModelFactory.INSTANCE.getEventModel(SalesOrderEventConstants.payment_success)
            : EventModelFactory.INSTANCE.getEventModel(SalesOrderEventConstants.payment_fail);
        event.setValueObject(JSONUtil.parseObj(param));
        orderEntity.onEvent(event);
        ...
    });
}

"支付成功后干什么"在 SalesOrderPaymentSuccHandler.java(真实源码,注意它把业务放在了 preProcess 钩子里):

java 复制代码
@Override
public boolean preProcess(OrderBasedSalesEntity domain, EventModel event) {
    // 1. 从事件里还原参数
    PaymentResultEventValueObject valueObject = new PaymentResultEventValueObject();
    valueObject.setPaymentId(event.getValueObject().getLong("paymentId"));
    valueObject.setOrderId(event.getValueObject().getLong("orderId"));
    valueObject.setPaymentResult(event.getValueObject().getBool("paymentResult", true));
    // ...其余字段同理
​
    // 2. 调实体的业务方法:更新支付单、算这单是不是全付清了
    PaymentNotifyDto dto = domain.paymentSuccessful(valueObject);
​
    // 3. 返回"这单是否已全部支付成功"
    return YesNo.YES == dto.getAllSucceed();
}
​
@Override
public JSONObject postProcess(OrderBasedSalesEntity domain, EventModel event) {
    super.postProcess(domain, event);   // 照样落库 + 变更日志
    PaymentNotifyDto paymentNotifyDto = new PaymentNotifyDto();
    paymentNotifyDto.setAllSucceed(YesNo.YES);
    return JSONUtil.parseObj(paymentNotifyDto);
}

domain.paymentSuccessful(...) 是实体方法(OrderBasedSalesEntity):更新 OrderPayment 记录、按费用项分摊、判断"是否全部成功/全部失败"、更新订单支付状态------全是支付领域的知识,全在实体里,不在任何 Service 里。

支付这个场景多说一句你熟的:多笔支付单的订单,渠道回调可能来好几次,"是不是全部付清了"这个判断就是 paymentSuccessful 里的领域逻辑,Handler 只负责把结论带回给流程。三层版这个判断散在回调方法的 if 里,DDD 版它有名字、有家、能单测。


两边摆一起看

同一条需求"退款要校验金额",改动位置的差别:

你要做的事 三层版 DDD 版(obs)
加一条退款规则 改 OrderServiceImpl.refundOrder,祈祷别处没复制这段 改 OrderBasedSalesEntity.isOrderRefundable,就一处
改状态流转规则 找到所有 setStatus 逐个确认 改 OrderDescriptorLoader 里一行声明
加"医生驳回"功能 新方法 + 全局排查状态 描述符 2 行 + 一个新 Handler
查"状态被谁改了" 全局搜代码 + 翻日志 查 order_logger 表(字段级 diff + traceId)
单测退款规则 mock mapper、mock redis、mock api 直接 new 实体测纯逻辑

补充:回头补理论和战略设计(长期)

代码会写了,再读书就不抽象了。推荐顺序:

  1. 《实现领域驱动设计》(Vaughn Vernon,"红书")------比原著(蓝书)好读得多,先读前半部分;

  2. 蓝书挑着读:只读"统一语言""限界上下文"两章。

这个阶段的重点是战略设计:新业务接入为什么只需声明描述符?哪些业务根本不该用 DDD?

避坑清单(都是真坑)

  1. **别一刀切全上 DDD。**判断标准就一条:这个业务有没有复杂的状态流转和常变规则?没有就别建描述符;

  2. 别把"充血模型"理解成"实体里什么都能干"。 实体该有的是业务规则,不该有的是 SQL 拼接和远程调用。项目里 OrderBasedSalesEntity 偶尔通过 Repo 查退款记录,这是落地妥协------要清楚这是妥协,别把实体写成第二个 Service;

  3. **别为了事件而事件。**简单同步流程直接方法调用。需要可靠投递的(如支付结果)走消息表 + 补偿,别走轻量事件总线;

  4. **先统一语言,再写代码。**和产品对一遍名词表:状态叫什么、事件叫什么、每个词的边界在哪。这一步不花钱,能省一半返工;

  5. 别背概念面试。 面试官问"聚合根是什么",你用项目里的 GenericDomain + 订单状态机讲清楚"为什么状态只能从 onEvent 改",比背定义有说服力十倍。

结语

回头看整个系列:DDD 不是一门新技术,而是一种纪律------业务规则集中放、用业务的语言放、放在划清的边界内。三层架构没有错,它只是没有回答"规则放哪"这个问题;DDD 回答了,代价是多写几个类、多学几个概念。

对你这种天天排查支付、缓存、数据库问题的后端,DDD 最实在的价值是:出问题时你要找的代码变少了。 状态不对?先看状态机描述符。退款金额不对?先看实体的 isOrderRefundable 和退款计算方法。规则集中了,排查路径就短了。

相关推荐
诺伦3 小时前
AI Agent 营销市场 442 亿:多 Agent 协同如何跑通内容→分发→复盘闭环 | RiseClaw玄策
架构设计·ai agent·多agent协同·agent编排·增长运营
要开心吖ZSH6 小时前
DDD(领域驱动设计)篇三:实战对照——同一个订单业务,两种架构怎么写
java·架构·领域模型·ddd·领域驱动设计·设计思想
真上帝的左手1 天前
10. 软件设计&架构-架构师之路-1.究竟什么是架构设计?
架构·架构设计
loulanyue_1 天前
10000P国产算力破壁上线,算电协同与超智融合打破算力孤岛:高文院士图灵奖得主纵论世界算力网新纪元——读2026世界算力网大会实录
人工智能·大模型·架构设计·高性能计算·算力网络
FungLeo1 天前
成为全栈·Go 后端篇·用 Go 重写分层架构:领域包、装配根与最小抽象
golang·go·领域驱动设计·gorm·分层架构·成为全栈
硅基手札1 天前
操作系统设计思想系列 11 · ThreadX 深度 — 预抢占阈值·认证型设计
操作系统·开源项目·嵌入式开发·嵌入式操作系统·设计思想·threadx
武雄(小星Ai)2 天前
智能家居云依赖排坑:从 Nest 温控器被远程“变砖“到 Home Assistant 自托管救赎
物联网·智能家居·架构设计·homeassistant
硅基手札2 天前
操作系统设计思想系列 10 · VxWorks 深度 — 硬实时旗舰·完全抢占内核
操作系统·实时操作系统·嵌入式开发·vxworks·设计思想