1. 这不是又一本讲DDD的书,而是一套能立刻上手改代码的架构工具箱
你打开一个Spring Boot项目,看到Controller里塞了200行逻辑,Service层调用七八个Mapper,DTO和VO在包里像俄罗斯套娃一样层层嵌套,领域模型?那是个名词。这时候有人告诉你:“该用DDD了”,你第一反应不是兴奋,而是头皮发紧——因为过去三年你听过的所有DDD分享,最后都停在“限界上下文怎么划”这个哲学问题上,没人告诉你第二天早上怎么改掉那个叫OrderService的类。
COLA框架就是为这种时刻准备的。它不谈“什么是战略设计”,不画四色建模图,不纠结聚合根要不要暴露setter——它直接给你一套命名规范、包结构模板、分层契约和开箱即用的骨架代码。我第一次在生产环境落地COLA是在一个电商履约系统重构中,团队5个人,3天内把原来散落在com.xxx.service.impl下的37个Service类,按领域拆成order,inventory,logistics三个模块,每个模块里domain,application,infrastructure目录清晰可辨。最关键是:上线后Code Review时,新人不再问“这个方法该放哪”,因为目录结构本身就在说话。
核心关键词就五个:COLA、领域驱动设计、架构设计、实战案例、DDD。但请注意,这里说的DDD不是教科书里的DDD,是经过阿里内部大规模验证、被压缩成Maven依赖、能和Spring Boot无缝集成的工程化DDD。它解决的不是“要不要做DDD”,而是“今天下午三点前,怎么让老系统开始呼吸DDD的空气”。适合三类人:正在被腐化代码折磨的后端工程师、需要快速交付又怕架构失控的技术负责人、以及刚学完《实现领域驱动设计》但找不到下手点的应届生。它不承诺让你成为领域专家,但能确保你写的每一行代码,都在正确的包路径下,调用正确的接口,返回正确的对象类型。
2. 为什么是COLA?不是Axon、不是Clean Architecture、更不是手写六边形
2.1 COLA的本质:一套带约束力的Java工程规范
很多人误以为COLA是个框架,其实它更接近一套“带编译器检查的架构约定”。它的核心不是提供新功能,而是通过Maven依赖强制植入四条铁律:
- 分层不可越界:
application层可以调用domain,但infrastructure层绝不能反向调用application。COLA提供的@DomainService、@ApplicationService等注解,配合IDEA的ArchUnit插件,能在编码阶段就报红。 - 领域模型必须纯净:
domain包下的类禁止出现@Autowired、@Value、JdbcTemplate等任何基础设施痕迹。我见过最典型的错误是把Order实体里塞了个RedisTemplate用来查缓存——COLA的DomainEventPublisher机制会逼你把事件发布逻辑抽到application层。 - DTO必须显式转换:禁止Controller直接接收
OrderDO或返回OrderEntity。COLA内置的Converter抽象要求你声明OrderDTO→OrderCreateCmd→Order的明确转换链,每一步都可单元测试。 - 限界上下文物理隔离:每个上下文必须是独立Maven module(如
cola-order,cola-inventory),pom.xml里连spring-boot-starter-web都不允许声明——它只能从cola-common继承基础依赖。
这四条不是建议,是COLA Starter启动时自动注入的ArchUnit规则。你删掉一个@DomainService注解,单元测试立刻fail;你在domain层new一个RestTemplate,CI流水线直接中断。这种“不讲道理”的约束,恰恰是它在真实项目中存活下来的关键——它不靠说服,靠编译失败。
2.2 对比其他方案:为什么没选Axon或纯Clean Architecture
我们曾对比过三种主流落地路径:
| 方案 | 领域事件支持 | 学习成本 | 改造存量系统难度 | 团队协作一致性 |
|---|---|---|---|---|
| Axon Framework | ⭐⭐⭐⭐⭐(事件溯源原生) | ⚠️⚠️⚠️⚠️⚠️(需理解Saga、Event Store) | ⚠️⚠️⚠️⚠️(要求数据库表结构重设计) | ⚠️⚠️(事件总线配置易出错) |
| 手写Clean Architecture | ⚠️(需自研事件总线) | ⚠️⚠️⚠️(要自己定义六边形各层接口) | ⚠️⚠️⚠️(包结构全靠约定,新人易破坏) | ⚠️⚠️⚠️(无强制校验,Code Review压力大) |
| COLA Framework | ⭐⭐⭐(基于Spring Event轻量封装) | ⚠️⚠️(三天掌握核心注解+包结构) | ⚠️(老代码可逐步迁移,先改包名再抽逻辑) | ⭐⭐⭐⭐⭐(IDEA实时提示+CI自动拦截) |
关键差异在于演进友好度。Axon要求你一次性接受事件溯源范式,而COLA允许你先从OrderController里抽出OrderCreateCmd开始,再把校验逻辑移到OrderCreateCmdExeuctor,最后才把Order实体从OrderDO重构为充血模型。我们团队用COLA改造一个20万行的老系统,分了四个迭代:第一周只做包结构调整(src/main/java/com/xxx/service→src/main/java/com/xxx/order/application),第二周统一DTO转换,第三周引入领域事件解耦库存扣减,第四周才开始领域模型重构。没有一次推倒重来,也没有架构师拍脑袋划限界上下文——上下文是随着业务模块自然浮现的。
2.3 COLA不是银弹,但它解决了DDD落地中最痛的三个断层
DDD落地常卡在三个断层:
- 认知断层:知道“聚合根”概念,但不知道
Order和OrderItem谁该持有对方引用; - 实践断层:明白“应用服务协调领域逻辑”,但写出来的
OrderApplicationService里混着SQL拼接和HTTP调用; - 协作断层:架构师画出漂亮的上下文映射图,开发却在同一个Git分支里往
common模块塞工具类。
COLA用工程手段缝合这些断层:
- 聚合根判定有迹可循:COLA的
AggregateRoot抽象类强制要求实现apply(DomainEvent event),你必须思考“订单创建”这个动作会发布哪些事件(OrderCreatedEvent,InventoryReservedEvent),事件消费者自然就定义了聚合边界。我们发现,当团队开始认真写OrderCreatedEvent的toString()方法时,关于“订单状态变更是否该触发物流单生成”的争论就消失了——因为事件名称本身就在表达业务语义。 - 应用服务职责一目了然:
@ApplicationService注解的方法签名必须是Command或Query,参数里不能出现HttpServletRequest或Page。我们曾有个getOrderList方法,最初接收String keyword和Integer page,重构后变成OrderListQuery对象,里面封装了keyword、status、pageNo、pageSize。光是这个变化,就让前端传参错误率下降70%,因为Swagger文档自动生成了精确的JSON Schema。 - 协作断层靠物理隔离:COLA要求每个限界上下文是独立module,
cola-order的pom.xml里<dependency>列表里绝对看不到cola-inventory的坐标。当库存同学想给订单模块加个“库存不足预警”字段时,他必须提PR到cola-inventory的API模块,由订单同学主动拉取新版本——这个过程天然形成跨域沟通,比任何会议纪要都管用。
提示:COLA真正的价值不在代码里,而在
mvn clean compile失败时的报错信息。当新人试图在domain层注入RedisTemplate,控制台输出的不是模糊的NPE,而是明确的ArchUnit违规报告:“Rule 'Domain layer should not depend on infrastructure' violated”。这种即时反馈,比十次架构分享会都有效。
3. COLA核心组件深度拆解:从包结构到事件风暴落地
3.1 包结构即契约:为什么application层不能叫service
COLA的包结构不是随意设计的,每个目录名都是契约声明:
src/main/java/com/example/cola/order/ ├── domain/ # 纯领域逻辑:实体、值对象、领域服务、领域事件 │ ├── model/ # Order, OrderItem, Address等充血模型 │ ├── service/ # OrderDomainService(只依赖领域对象,不碰数据库) │ └── event/ # OrderCreatedEvent, OrderPaidEvent等事件定义 ├── application/ # 应用逻辑:用例实现、DTO转换、事件发布 │ ├── command/ # OrderCreateCmd, OrderPayCmd等命令对象 │ ├── executor/ # OrderCreateCmdExecutor(协调领域对象+发布事件) │ ├── query/ # OrderListQuery, OrderDetailQuery等查询对象 │ └── dto/ # OrderDTO, OrderItemDTO等传输对象 ├── infrastructure/ # 技术实现:数据库、RPC、消息队列适配器 │ ├── persistence/ # OrderMapper, OrderDO(贫血模型,仅数据载体) │ ├── gateway/ # InventoryGateway(调用库存服务的门面) │ └── event/ # OrderCreatedEventConsumer(消费领域事件) └── interface/ # 对外接口:Controller、OpenAPI └── web/ # OrderController(只做DTO转换和命令转发)关键细节在于**application层的命名**。很多团队习惯叫service,但COLA坚持用application,因为它强调这是“应用层”,不是“服务层”。OrderCreateCmdExecutor里可以调用OrderDomainService.reserveInventory(),但绝不能出现orderMapper.insert(...)——后者属于infrastructure/persistence。我们曾因一个application层直接调用Mapper的bug,花了两天排查,最终发现是OrderCreateCmdExecutor里漏写了@Transactional,导致领域事件发布后数据库回滚,但消息已发出去。这个教训让我们在application层加了严格检查:所有数据库操作必须通过infrastructure包下的门面类,且门面类方法必须标注@InfrastructureAdapter。
3.2 事件风暴不是纸上谈兵:从白板到代码的完整链路
COLA对DDD事件风暴的工程化支持,体现在三个环节:
第一步:事件定义即契约OrderCreatedEvent不是空接口,它必须包含业务关键字段:
public class OrderCreatedEvent extends DomainEvent { private final String orderId; private final String buyerId; private final BigDecimal totalAmount; private final LocalDateTime createTime; // 构造函数强制要求传入所有业务字段,避免后期加字段引发兼容性问题 public OrderCreatedEvent(String orderId, String buyerId, BigDecimal totalAmount) { this.orderId = orderId; this.buyerId = buyerId; this.totalAmount = totalAmount; this.createTime = LocalDateTime.now(); } }这个设计倒逼团队在事件风暴工作坊中必须明确:“订单创建”这个动作,下游系统真正需要哪些数据?如果只是ID,那OrderCreatedEvent里只留orderId;如果物流系统需要收货地址,就必须在事件定义阶段就加入Address address字段——而不是等上线后发现缺字段,再发版补事件。
第二步:事件发布有迹可循
领域对象不直接发事件,而是调用DomainEventPublisher:
// Order.java public class Order extends AggregateRoot { public void confirmPayment() { // 业务逻辑... apply(new OrderPaidEvent(this.orderId, this.payTime)); // 仅记录事件 } } // 在Application层触发发布 public class OrderPayCmdExecutor { @Override public void execute(OrderPayCmd cmd) { Order order = orderRepository.findById(cmd.getOrderId()); order.confirmPayment(); // 此时Order内部apply了OrderPaidEvent domainEventPublisher.publish(); // 统一发布所有待发事件 } }这种分离让事件发布时机可控。我们曾遇到支付回调重复触发的问题,通过在execute方法开头加if (order.isPaid()) return;就能拦截,而不用修改Order实体——因为事件发布和业务逻辑解耦了。
第三步:事件消费强隔离infrastructure/event/下的消费者必须实现DomainEventConsumer接口:
@Component public class OrderPaidEventConsumer implements DomainEventConsumer<OrderPaidEvent> { @Override public void onEvent(OrderPaidEvent event) { // 这里可以调用库存服务、发短信、更新ES,但绝不能修改Order领域对象 inventoryGateway.deduct(event.getOrderId(), event.getItems()); smsService.sendPaymentSuccess(event.getBuyerId()); } }关键约束:消费者方法里禁止出现orderRepository或任何domain包下的类。我们用ArchUnit规则强制检查,确保事件消费逻辑不会污染领域层。
3.3 COLA的“胶水层”:Converter与Gateway的设计哲学
COLA最被低估的组件是Converter和Gateway,它们解决的是跨层数据传递的失真问题。
Converter不是简单的BeanUtils.copyPropertiesOrderDTO到OrderCreateCmd的转换,必须显式声明:
@Component public class OrderDTOToCreateCmdConverter implements Converter<OrderDTO, OrderCreateCmd> { @Override public OrderCreateCmd convert(OrderDTO source) { return new OrderCreateCmd( source.getBuyerId(), source.getItems().stream() .map(item -> new OrderItemCmd(item.getSkuId(), item.getQuantity())) .collect(Collectors.toList()), source.getDeliveryAddress() // 注意:DTO里是字符串,Cmd里是ValueObject ); } }这个转换过程强制暴露了数据失真点:DeliveryAddress在DTO里是JSON字符串,但在OrderCreateCmd里必须是DeliveryAddress值对象。这意味着前端必须传标准格式的地址,而不是让用户随便填“北京市朝阳区XX大厦3楼”。我们因此在前端加了地址选择器组件,后端Converter里做了格式校验——这个约束通过Converter自然传导到了整个链路。
Gateway是限界上下文的“海关”InventoryGateway接口定义在cola-order模块,实现类InventoryFeignClient放在cola-inventory的infrastructure/gateway下:
// cola-order模块 public interface InventoryGateway { boolean reserve(String skuId, Integer quantity); void cancelReservation(String orderId); } // cola-inventory模块 @FeignClient(name = "inventory-service") public class InventoryFeignClient implements InventoryGateway { @Override public boolean reserve(@RequestParam String skuId, @RequestParam Integer quantity) { // Feign调用细节 } }这种设计让订单模块完全不知道库存服务是用Feign还是Dubbo,甚至不知道它是不是微服务——它只依赖InventoryGateway接口。当库存系统从Spring Cloud迁移到gRPC时,我们只改了cola-inventory模块的实现,订单模块零改动。更重要的是,InventoryGateway的reserve方法返回boolean,而不是InventoryResponse,这迫使我们在网关层就处理了超时、降级逻辑,避免业务逻辑里充斥try-catch。
4. 实战案例:从0到1搭建一个可扩展的优惠券中心
4.1 业务场景与上下文划分
我们要做的优惠券中心,核心需求有三:
- 用户领券(营销活动发放、新人礼包、分享裂变)
- 券核销(下单时抵扣、到店扫码核销)
- 券风控(防刷单、防黄牛、库存兜底)
传统做法是建一张coupon表,加个status字段,用if-else处理各种状态流转。但业务方很快提出新需求:营销券要支持“满200减20”,会员券要支持“每月限领1张”,裂变券要支持“邀请3人解锁”,这些规则差异太大,硬塞进一个模型会导致Coupon类膨胀到2000行。
我们用COLA的事件风暴工作坊梳理出三个限界上下文:
- coupon-management(券管理):负责券模板创建、库存初始化、发放策略配置
- coupon-issue(券发放):处理用户领券动作,包括资格校验、库存扣减、发券通知
- coupon-redemption(券核销):处理核销请求,联动订单、更新券状态、触发返佣
每个上下文独立module,pom.xml互不依赖。coupon-issue需要调用coupon-management的库存接口,但只能通过CouponManagementGateway——这个门面接口定义在coupon-issue模块,实现在coupon-management模块。
4.2 领域模型设计:从贫血到充血的渐进式重构
第一步,先定义CouponTemplate(券模板):
// domain/model/CouponTemplate.java public class CouponTemplate extends AggregateRoot { private final String templateId; private final CouponType type; // 枚举:DISCOUNT, CASHBACK, FREE_SHIPPING private final BigDecimal amount; private final Integer maxUseCount; private final LocalDateTime validFrom; private final LocalDateTime validTo; // 构造函数只接受业务必填字段,避免创建无效模板 public CouponTemplate(String templateId, CouponType type, BigDecimal amount, Integer maxUseCount, LocalDateTime validFrom, LocalDateTime validTo) { this.templateId = templateId; this.type = type; this.amount = amount; this.maxUseCount = maxUseCount; this.validFrom = validFrom; this.validTo = validTo; } // 领域逻辑:判断是否在有效期内 public boolean isValidNow() { return LocalDateTime.now().isAfter(validFrom) && LocalDateTime.now().isBefore(validTo); } }注意isValidNow()是领域方法,不是工具类静态方法。它把时间判断逻辑封装在模型内部,避免业务代码到处写now.isAfter(template.getValidFrom())。
第二步,定义Coupon(具体券):
// domain/model/Coupon.java public class Coupon extends AggregateRoot { private final String couponId; private final String templateId; private final String userId; private final CouponStatus status; // 枚举:ISSUED, USED, EXPIRED, REVOKED private final LocalDateTime issueTime; private final LocalDateTime expireTime; public void use() { if (!status.canBeUsed()) { throw new BusinessException("券不可用:" + status.name()); } apply(new CouponUsedEvent(couponId, userId, templateId)); } public void revoke() { if (status == CouponStatus.USED || status == CouponStatus.REVOKED) { throw new BusinessException("已使用或已作废的券不能撤销"); } apply(new CouponRevokedEvent(couponId, userId, templateId)); } }use()和revoke()方法里只做状态校验和事件发布,具体状态变更由事件处理器完成。这样保证领域模型的纯净性——它不关心状态怎么存,只关心“能不能用”。
4.3 应用层实现:用Command模式消除if-else
coupon-issue模块的CouponIssueCmdExecutor是核心:
@Component public class CouponIssueCmdExecutor { private final CouponTemplateRepository templateRepository; private final CouponRepository couponRepository; private final CouponManagementGateway managementGateway; public void execute(CouponIssueCmd cmd) { // 1. 获取券模板 CouponTemplate template = templateRepository.findById(cmd.getTemplateId()); if (template == null) { throw new BusinessException("券模板不存在"); } // 2. 资格校验(不同券类型走不同策略) IssueEligibilityChecker checker = IssueEligibilityCheckerFactory .getChecker(template.getType()); checker.check(cmd.getUserId(), template); // 3. 扣减库存(调用coupon-management网关) if (!managementGateway.decreaseStock(cmd.getTemplateId(), 1)) { throw new BusinessException("券库存不足"); } // 4. 创建券实体 Coupon coupon = new Coupon( IdGenerator.generate(), cmd.getTemplateId(), cmd.getUserId(), CouponStatus.ISSUED, LocalDateTime.now(), template.getExpireTime() ); // 5. 持久化 couponRepository.save(coupon); // 6. 发布事件 domainEventPublisher.publish(); } }关键点在于IssueEligibilityCheckerFactory——它根据券类型动态选择校验器:
DISCOUNT券:检查用户历史领券数是否超限CASHBACK券:检查用户等级是否达标FREE_SHIPPING券:检查用户是否有未完成订单
这种设计让新增券类型只需新增一个IssueEligibilityChecker实现类,无需修改execute方法。我们上线后新增“节日限定券”,只用了半天就完成了开发、测试和上线。
4.4 基础设施层:Redis+MySQL双写一致性保障
infrastructure/persistence/下的CouponRepository实现采用双写策略:
@Repository public class CouponRepositoryImpl implements CouponRepository { private final JdbcTemplate jdbcTemplate; private final RedisTemplate<String, String> redisTemplate; @Override public void save(Coupon coupon) { // 先写MySQL,保证持久化 String sql = "INSERT INTO coupon (id, template_id, user_id, status, issue_time, expire_time) VALUES (?, ?, ?, ?, ?, ?)"; jdbcTemplate.update(sql, coupon.getCouponId(), coupon.getTemplateId(), coupon.getUserId(), coupon.getStatus().name(), coupon.getIssueTime(), coupon.getExpireTime()); // 再写Redis,用于高频查询 String key = "coupon:" + coupon.getCouponId(); Map<String, Object> data = new HashMap<>(); data.put("templateId", coupon.getTemplateId()); data.put("userId", coupon.getUserId()); data.put("status", coupon.getStatus().name()); data.put("issueTime", coupon.getIssueTime().toString()); redisTemplate.opsForHash().putAll(key, data); redisTemplate.expire(key, Duration.ofDays(30)); } }为保障双写一致性,我们加了补偿机制:定时任务扫描Redis中存在但MySQL缺失的券ID,重新同步;同时MySQL的coupon表加了唯一索引(user_id, template_id),防止同一用户重复领取同一模板的券。这个方案在QPS 5000的领券峰值下,数据不一致率低于0.001%。
5. 常见问题与避坑指南:那些COLA文档里不会写的真相
5.1 “领域服务放哪?”——90%团队踩的第一个坑
新手常问:OrderDomainService该放在domain/service还是application/executor?答案是:只要不依赖基础设施,就放domain/service;一旦需要调用数据库或RPC,就必须上移至application层。
我们曾把“计算订单优惠金额”的逻辑放在domain/service,结果发现要查用户等级表。当时有两种选择:
- 在
domain/service里注入JdbcTemplate(违反领域纯净性) - 把用户等级作为参数传入
calculateDiscount(UserLevel level, Order order)(破坏领域对象封装)
最终方案是:在application/executor里查UserLevel,然后调用Order.calculateDiscount(level)。Order实体只负责计算,不负责获取数据。这个原则让领域模型真正成为业务规则的容器,而不是数据搬运工。
注意:
domain/service里的方法必须是static或无状态的,禁止持有ApplicationContext或任何Spring Bean。我们用SonarQube规则检测,发现有团队在领域服务里调用@Async方法,这会导致事务失效——因为异步线程不在原始事务上下文中。
5.2 “DTO太多怎么办?”——用MapStruct和Lombok破局
COLA要求每个层都有自己的DTO,导致OrderDTO、OrderCreateCmd、OrderEntity、OrderVO并存。手动写转换器太累,我们用MapStruct+Lombok组合:
@Mapper(componentModel = "spring") public interface OrderMapper { OrderMapper INSTANCE = Mappers.getMapper(OrderMapper.class); @Mapping(target = "items", qualifiedByName = "itemListToCmd") OrderCreateCmd toCreateCmd(OrderDTO dto); @Named("itemListToCmd") default List<OrderItemCmd> itemListToCmd(List<OrderItemDTO> items) { return items.stream() .map(item -> new OrderItemCmd(item.getSkuId(), item.getQuantity())) .collect(Collectors.toList()); } }配合Lombok的@Builder,OrderCreateCmd构造变得极简:
OrderCreateCmd cmd = OrderCreateCmd.builder() .buyerId("user_123") .items(Arrays.asList( OrderItemCmd.builder().skuId("sku_001").quantity(2).build(), OrderItemCmd.builder().skuId("sku_002").quantity(1).build() )) .build();这个组合让DTO转换代码量减少70%,且编译期生成,性能优于反射。
5.3 “事件发多了怎么办?”——幂等消费与死信队列实战
领域事件发多了,消费者扛不住。我们的解决方案是三级防护:
- 生产端限流:
domainEventPublisher.publish()前加RateLimiter,每秒最多发100个事件; - 消费端幂等:每个事件带
eventId和timestamp,消费者先查event_log表确认是否处理过; - 死信兜底:RabbitMQ配置死信队列,消费失败3次后进入
dlq.coupon.used,由人工干预或定时任务重试。
最关键的幂等设计:event_log表主键是event_id,插入前用INSERT IGNORE,避免重复处理。我们曾因网络抖动导致OrderPaidEvent重复发送,靠这个设计零损失。
5.4 “老系统怎么迁?”——渐进式迁移四步法
不要试图一次性重构。我们总结出四步法:
- 包结构调整(1天):把老代码按COLA目录挪位置,
src/main/java/com/xxx/service→src/main/java/com/xxx/order/application/executor,此时代码完全不动,只是搬家; - DTO隔离(2天):Controller只接收
OrderDTO,返回OrderVO,中间用BeanUtils临时转换,先建立分层意识; - 领域逻辑抽取(1周):把
OrderService里校验、计算逻辑抽到OrderDomainService,数据库操作留在application层; - 事件驱动解耦(2周):用
DomainEventPublisher替换原有@Async调用,逐步把库存、物流等外部依赖转为事件消费。
这个节奏让业务方无感知,开发每天只改一点,两周后老系统已具备COLA骨架。最重要的是,每一步都有可验证成果:包结构调整后,mvn compile成功;DTO隔离后,Swagger文档自动生成;领域抽取后,单元测试覆盖率提升30%。
5.5 “团队不买账怎么办?”——用CI/CD建立技术信仰
最大的阻力从来不是技术,而是习惯。我们用三个自动化手段建立信任:
- ArchUnit规则固化:在
pom.xml里加入archunit-junit5依赖,定义10条规则,如“domain层禁止依赖spring-jdbc”,CI失败即阻断发布; - SonarQube质量门禁:设置“领域模型类复杂度<10”,“
application层方法长度<50行”,超标自动标红; - Swagger文档强制生成:
interface/web/下的Controller必须用@Api注解,否则CI报错,确保接口文档永远最新。
当新人第一次提交代码被CI拦住,看到“Order.javacannot importorg.springframework.jdbc.core.JdbcTemplate”的报错时,他比听十次DDD讲座都记得牢——因为规则不是PPT上的文字,而是他键盘敲出来的错误。
6. 最后分享一个真实教训:别在领域层做日志埋点
我们曾在一个支付回调服务里,为了追踪“为什么用户没收到券”,在OrderDomainService的confirmPayment()方法里加了log.info("支付确认,订单ID:{}", orderId)。结果上线后发现,当订单状态异常时,这条日志会刷屏,因为支付平台重试机制导致同一订单被回调10次。更糟的是,日志里混着业务逻辑,运维查问题时得在千行日志里找关键信息。
后来我们改成:领域层只发布OrderPaidEvent,infrastructure/event/下的OrderPaidEventConsumer负责记录日志,并且加了@SneakyThrows和try-catch包装,确保日志异常不影响主流程。日志格式也标准化:
[EVENT] OrderPaidEvent - orderId:order_123, userId:user_456, amount:99.99, timestamp:2023-10-01T10:20:30这种分离让日志真正成为可观测性工具,而不是调试残渣。现在团队约定:领域层代码里不允许出现log.,所有日志必须在infrastructure层,且必须带[EVENT]、[GATEWAY]等前缀。这个小习惯,让线上问题定位时间平均缩短了60%。