我最早接触COLA,是在接手一个"看起来还行"的微服务项目时。代码结构不能说乱,但改起来就是费劲:加一个字段要改五六个地方,一个订单状态机散落在三个服务里,每次版本发布都像拆定时炸弹。后来把COLA的分层规范引入进去,重构了核心订单域,才知道原来不是我们代码写得差,而是压根没有给业务逻辑一个"该待的地方"。
COLA是阿里开源的一套应用架构规范,全称是Clean Object-oriented and Layered Architecture,直译过来就是整洁面向对象分层架构。它本身不是一个框架,更像是一套代码组织方法论,核心是帮你在Java项目里落地DDD(领域驱动设计)。很多团队喊着要做DDD,结果画了一堆EventStorming的便利贴,最后代码还是三层架构的老套路,Controller直接调Mapper,领域对象沦为数据库表结构的映射。COLA要解决的,正是这个"理论上说得很漂亮,代码里落不了地"的痛。
这篇内容我打算以COLA 4.0为主体,结合我自己的落地经验,把COLA的分层模型、聚合设计、和数据库交互的边界、以及那些文档里不会写的踩坑点都过一遍。适合正在做微服务拆分、或者想引入DDD但不知道怎么动手的团队参考。
1. COLA的分层模型,和传统的三层架构到底差在哪
先看一张COLA 4.0的分层结构示意,这是理解COLA的起点:
- 适配层(Adapter层):负责接收外部请求,包括HTTP接口、RPC接口、定时任务、MQ消息消费者。
- 应用层(Application层):负责业务流程的编排、事务管理、事件发布。它不写业务规则。
- 领域层(Domain层):承载核心业务逻辑,包括实体、值对象、聚合根、领域服务、领域事件。这是整个架构的心脏。
- 基础设施层(Infrastructure层):提供技术支撑,包括数据库访问、缓存、消息发送、外部接口调用等。
很多人第一次看这个分层,会觉得"这不就是把三层架构换了个名字嘛,Controller变成Adapter,Service变成Application"。如果你只是机械地改包名,那确实是换汤不换药。真正关键的区别在于依赖方向和业务逻辑的位置。
传统三层架构里,业务逻辑往往写在Service层,而Service层直接依赖数据访问层。导致的结果是Service里的代码大量在做"把数据库字段搬进DTO"这种低价值工作,真实业务规则反而被淹没在十几行的赋值语句里。COLA的依赖方向是由外向内的,领域层在最中心,不依赖任何外部框架和基础设施。应用层依赖领域层,但不依赖基础设施层。基础设施层反过来依赖领域层,去实现领域层定义的接口。
1.1 适配层解决的是"协议"问题,而不是"业务"问题
我见过最典型的反模式,是在Controller里直接new一个service,然后根据前端参数判断调用哪个方法。这样做短期内确实最快,可一旦接口协议调整——比如从同步HTTP改成MQ异步、或者参数结构变化——你就得去改Controller里的业务判断代码。
在COLA里,适配层只做三件事:
- 协议转换:把HTTP请求参数、RPC入参、MQ消息体转换成应用层需要的DTO。
- 权限校验:登录状态、白名单、基础参数合法性等通用校验。
- 结果返回:把应用层的返回值包装成响应协议。
举个例子,我们有一个订单查询接口,原来的Controller代码长这样:
java复制@RestController
public class OrderController {
@Autowired
private OrderService orderService;
@PostMapping("/order/query")
public Result queryOrder(@RequestBody QueryOrderRequest request) {
if (request.getUserId() == null) {
throw new BizException("user id is required");
}
OrderDO orderDO = orderMapper.selectByOrderNo(request.getOrderNo());
OrderVO vo = new OrderVO();
vo.setOrderNo(orderDO.getOrderNo());
vo.setStatus(orderDO.getStatus());
vo.setAmount(orderDO.getAmount());
return Result.success(vo);
}
}
这里的Controller干了太多不该它干的活——查询数据库、手工拼装VO,业务逻辑和接入协议完全耦合。在COLA规范下,这条链路会变成:
java复制@RestController
public class OrderController {
@Autowired
private OrderQueryAppService orderQueryAppService;
@PostMapping("/order/query")
public Result queryOrder(@RequestBody QueryOrderRequest request) {
QueryOrderDTO dto = QueryOrderDTO.builder()
.userId(request.getUserId())
.orderNo(request.getOrderNo())
.build();
OrderDTO order = orderQueryAppService.queryOrder(dto);
return Result.success(order);
}
}
改动之后,Controller里不再出现任何"查询数据库"的逻辑,只剩下协议转换和应用层调用。以后想把HTTP改成RPC,Controller整个替换掉,应用层代码一行都不用动。
1.2 应用层只做编排,不做决策
应用层在COLA里的定位很微妙。它不像领域层那样承载核心逻辑,但又不能做成"只透传数据的空壳"。我理解应用层的职责是:
- 编排领域服务:决定调用哪些聚合、以什么顺序调用。
- 事务控制:跨聚合操作时的事务边界,落在应用层。
- 领域事件发布:业务完成后,把事件发出去。
- DTO组装:把多个领域对象的数据拼装成调用方需要的DTO。
有一段时间我们团队把应用层的Service写得巨厚,里面全是if-else判断订单状态、计算折扣、校验库存。后来做Code Review,发现这些逻辑其实都应该下沉到领域层。怎么判断该放在哪一层?有一个很实用的标准:如果一个逻辑你能在业务讨论会上直接跟产品经理讲清楚,那它大概率是领域逻辑,应该放进领域层。如果你讲完,产品一脸茫然说"这不是你们系统内部的事情吗",那它才是应用层逻辑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 聚合设计:COLA落地DDD最难的关卡
聚合是DDD里最核心也最容易走偏的概念。代码分层搞得再规范,如果聚合设计得稀烂,整体架构依然是空中楼阁。我在COLA项目里见过太多把聚合做成"大杂烩"的案例:一个Order聚合里塞进了User、PayRecord、LogisticsInfo、Coupon……整个聚合高达十几个类,每次加载都像在扫全表。
2.1 聚合的边界,由业务一致性决定,而不是由数据关系决定
很多团队做聚合,喜欢从数据库外键关系出发:订单和订单项有外键,那就放一个聚合;用户和地址有一对多,也放一个聚合。这完全是关系型数据库思维,跟DDD的聚合概念是两回事。
聚合的边界应该由业务不变量(invariants)来决定,通俗讲就是"哪些数据必须同时满足某个业务规则,否则系统就错了"。我们拿电商来举例:
- 订单被取消时,订单状态、库存占用、优惠券释放必须同时变更——它们属于同一个聚合。
- 订单被创建时,用户账户余额、积分变化、消息通知不需要在同一事务里强一致——它们不属于同一个聚合。
如果你能从"哪些数据必须同生共死"的角度去画聚合,你的聚合边界会自然收敛到很小的范围。这也直接符合COLA对领域层的期望:聚合尽量小,小到一次业务操作只改变一个聚合。
2.2 聚合根是唯一的入口,别让外部直接操作聚合内部
COLA的领域层里,聚合内实体之间的操作必须通过聚合根。也就是说,外部代码(包括应用层、其他聚合)要修改OrderItem的状态,只能通过Order聚合根的方法,比如:
java复制public class Order extends AggregateRoot {
private OrderId orderId;
private OrderStatus status;
private List<OrderItem> items;
private Money totalAmount;
public void cancel() {
if (this.status != OrderStatus.CREATED && this.status != OrderStatus.PAID) {
throw new BizException("当前状态不允许取消");
}
this.status = OrderStatus.CANCELED;
}
public void addItem(OrderItem item) {
if (this.status != OrderStatus.CREATED) {
throw new BizException("已确认的订单不能新增商品");
}
this.items.add(item);
this.totalAmount = totalAmount.add(item.getAmount());
}
}
在刚开始实践的时候,我们常常忍不住在应用层里写下order.getItems().get(0).setPrice(newPrice)这种代码,觉得反正都是同一个JVM里调用,没什么大不了。但实际上这完全绕过了聚合根的校验逻辑,相当于把一个受保护的内部状态直接暴露给了外部。后来我们干脆给聚合内部的集合类做了不可变封装,只提供查询方法,不提供修改方法。想修改,必须走聚合根的公开方法。
2.3 聚合内部用的"数据模型"和数据库表结构,不要强求一模一样
这是COLA落地过程中非常容易翻车的一个点。DO(Data Object)是给数据库ORM用的,Entity是给领域模型用的,两者形态经常不同。比如数据库里用户表存的是delete_flag、create_time、update_time这些字段,而领域模型的User实体压根不需要关心这些。ORM映射这一层的转换职责,应该落在基础设施层里的转换器(Converter)身上,而不是让领域层去理解数据库表结构。
我在项目里是这么处理的:
- DO:只有字段和getter/setter,完全对应数据库表。
- Entity:包含业务行为,对外暴露行为方法,不暴露字段修改入口。
- Converter:负责DO <-> Entity的互转,放在基础设施层。
有些团队会嫌Converter代码啰嗦,直接用BeanUtils.copyProperties一把梭。短期看确实省事,但一旦DO和Entity的字段结构出现差异(几乎一定会出现),这种隐式拷贝会让你找bug找到怀疑人生。我的建议是Converter还是老老实实手写映射,尤其是涉及金额、状态枚举的字段,手写更清晰也更可控。
3. 仓储(Repository)接口的本质:让领域层对基础设施"无感"
DDD里的Repository概念,和传统DAO最大的区别在于:DAO是面向数据库表的,Repository是面向聚合的。你用COLA架构时,领域层只负责定义仓储的接口,接口的入参出参必须是领域对象,具体实现放在基础设施层。
3.1 仓储接口放在领域层,实现放在基础设施层
举个例子,订单聚合根的Repository接口是这个样子:
java复制public interface OrderRepository {
Order find(OrderId orderId);
void save(Order order);
void remove(Order order);
}
注意这里入参和出参都是Order聚合,而不是OrderDO或者OrderPO。领域层根本不关心底层是MySQL还是Redis,也不关心订单和订单项是存在一张表还是拆成三张表。在基础设施层里,OrderRepositoryImpl负责加载DO、转成Entity、拼装聚合根:
java复制@Repository
public class OrderRepositoryImpl implements OrderRepository {
@Resource
private OrderMapper orderMapper;
@Resource
private OrderItemMapper orderItemMapper;
@Resource
private OrderConverter orderConverter;
@Override
public Order find(OrderId orderId) {
OrderDO orderDO = orderMapper.selectById(orderId.getValue());
List<OrderItemDO> itemDOS = orderItemMapper.selectByOrderId(orderId.getValue());
return orderConverter.toEntity(orderDO, itemDOS);
}
@Override
public void save(Order order) {
OrderDO orderDO = orderConverter.toDO(order);
orderMapper.updateById(orderDO);
// 订单项做全量删除重建,还是增量更新,是基础设施层自己的决策
}
}
对于简单的查询——比如列表页、报表、数据看板,不需要走聚合根的那种复杂组装——我的做法是允许直接走OrderQueryMapper查询DO,然后组装成DTO返回。这类读操作不涉及业务一致性,强行套聚合反而增加无谓开销。DDD并不排斥CQRS思想,读模型和写模型分离在COLA架构里是天然合理的。
3.2 事务边界怎么划,COLA里没有银弹
COLA规范本身没有规定事务一定得放在哪一层,但落地时我强烈建议把事务边界放在应用层。为什么?因为事务天然是跨聚合的协调行为,属于应用编排的一部分。
举个例子,创建订单时可能需要同时减少库存、生成支付单,这是两个聚合的操作,必须放到一个事务里。如果你在InventoryRepository.save()里开启了事务,PaymentRepository.save()又各自开事务,一旦第二步失败,库存就被扣了,订单也没生成,数据就脏了。把@Transactional放到应用层方法上,让这两个repo操作共享一个事务,是最稳妥的做法。
有一种情况需要注意:大批量、高并发场景下,跨聚合事务会导致锁竞争激烈、性能下降。这时可以考虑引入本地消息表或者事务性消息中间件,把强一致变成最终一致。这部分就超出了COLA本身要管的事,属于架构上的取舍,不展开讲。
4. 用COLA落地一个订单模块:从领域模型到代码的完整链路
理论说了一堆,还是用一个具体案例把代码链路串一遍。假设我们要做一个电商订单创建功能,核心业务规则是:
- 订单只能由已登录用户创建。
- 创建订单时校验商品库存,扣减库存后生成订单。
- 订单初始状态为CREATED。
- 使用优惠券则校验优惠券状态并标记已使用。
4.1 先建模,再写代码的"领域层先行"思路
很多程序员拿到需求第一反应是建数据库表。在COLA/DDD的实践里,应该先建立领域模型,再倒推数据库设计。针对上述需求,我们的领域模型会是这样:
- Order聚合根:包含orderId、userId、订单项列表、总金额、状态、couponId。
- OrderItem实体:skuId、数量、单价。
- 库存(Inventory):这里可以是一个独立的聚合,负责扣减库存。
- 优惠券(Coupon):独立聚合,校验和标记使用。
订单创建这个动作,落在应用层的OrderAppService#createOrder方法:
java复制@Service
public class OrderAppService {
@Resource
private OrderRepository orderRepository;
@Resource
private InventoryRepository inventoryRepository;
@Resource
private CouponRepository couponRepository;
@Resource
private OrderFactory orderFactory;
@Transactional(rollbackFor = Exception.class)
public CreateOrderDTO createOrder(CreateOrderCommand cmd) {
// 1. 通过工厂创建聚合根,工厂负责把DTO转换成领域对象
Order order = orderFactory.create(cmd);
// 2. 扣减库存
inventoryRepository.deduct(order.getSkuItems());
// 3. 标记优惠券已使用
if (order.getCouponId() != null) {
couponRepository.markUsed(order.getCouponId());
}
// 4. 保存订单聚合
orderRepository.save(order);
// 5. 发布订单已创建事件
eventBus.publish(new OrderCreatedEvent(order.getOrderId(), order.getUserId()));
return new CreateOrderDTO(order.getOrderId().getValue());
}
}
应用层的代码读起来非常"像业务",每个步骤都对应业务需求的一个动作。领域规则的判断逻辑(校验库存是否充足、校验优惠券是否可用)则分别封装在Inventory和Coupon聚合的内部方法里,应用层不关心这些细节。
4.2 工厂模式与领域事件:COLA隐藏的进阶用法
OrderFactory在COLA里不是必须的,但值得推荐。它的作用是把外部DTO和内部领域对象彻底隔离——应用层拿到CreateOrderCommand后,不需要自己去set每个字段。工厂内部处理字段映射、默认值、状态初始化。
领域事件这一层,COLA提供了DomainEventPublisher的抽象,基础设施层负责真正把事件发到MQ。我这里有一个经验之谈:发布领域事件的时机,一定是在聚合状态成功持久化之后,而不是之前。否则事件消费者来查数据,可能查到旧状态,产生竞态。所以我会把eventBus.publish放在事务的最后一个环节,事件消费者异步处理,保证数据先落库,事件后触发。
5. 踩坑实录:COLA DDD落地过程中最常遇到的五个问题
写到这里,单纯看代码范例好像一切挺顺利,但真正推进COLA重构时,每个团队都会踩到类似的坑。我把亲历和辅导过的项目里最典型的五个坑列出来。
5.1 包名改了,行为没变:伪COLA架构
第一种坑是最普遍的。团队按照COLA的包结构新建了adapter、application、domain、infrastructure四个包,但是代码写起来还是老一套。应用层直接调用Mapper查询数据库,领域层实体没有行为只有getter/setter,仓库接口的定义和DAO长得一模一样。这种"伪COLA"比不分层更可怕——因为它给了你一种整洁的幻觉,实际上复杂度一点没降,反而多了几个中间层。
怎么破局?我的经验是Code Review不能只查格式,要查依赖方向。用ArchUnit写一个自动化测试,强制检查:
java复制@ArchTest
static final ArchRule domain_should_not_depend_on_infrastructure =
noClasses()
.that()
.resideInAPackage("..domain..")
.should()
.dependOnClassesThat()
.resideInAnyPackage("..infrastructure..", "..adapter..");
把这条规则加进CI,谁再违反直接构建失败。习惯是规则约束出来的,靠自觉远远不够。
5.2 ORM模型的字段在领域层里阴魂不散
第二个坑是DO和Entity混用。常见场景是领域层代码里直接出现OrderDO orderDO = new OrderDO(),然后把数据库字段塞进实体里。这意味着领域层在依赖基础设施层的ORM模型,依赖方向彻底反了。
解决方式我之前提到过:引入Converter作为防腐层,并且严格规定领域层代码不允许import基础设施层的包。如果找不到某个数据该存在哪,那大概率是模型设计没理清,而不是缺一个DO引用。
5.3 跨聚合的大事务把架构拖垮
有些业务场景确实需要跨多个聚合保持一致,比如"订单创建同时扣库存同时减优惠券"。如果这三个聚合都各自维护事务,一旦并发上来,DB行锁竞争会很严重,系统吞吐量直接下跌。
解决方案有两种思路:一是把业务不变量重新审视一遍,看看哪些操作其实可以容忍最终一致(比如发消息通知、记录日志)。二是引入本地消息表或事务消息,把跨聚合操作拆成"主流程同步完成+副作用异步执行"。这里有一句我反复跟团队说的话:DDD追求的不是把所有操作塞进一个事务,而是明确哪些操作必须同步一致,哪些可以异步收敛。
5.4 仓储接口在设计时无意中泄露了数据存储细节
第三个案例是仓储接口的定义带了分页参数、排序字段、甚至SQL查询条件的影子。比如:
java复制public interface OrderRepository {
Page<OrderEntity> queryPage(int pageNum, int pageSize, String orderBy, String keyword);
}
这个接口完全就是为MyBatis量身定做的。领域层根本不该知道"分页""排序"这种存储概念。正确的做法是面向业务意图去定义Repository的查询方法,例如:
java复制public interface OrderRepository {
List<Order> findPendingRefundOrders(int limit);
Order findById(OrderId orderId);
}
至于底层是用MyBatis的分页插件还是用Elasticsearch聚合,那是基础设施层自己的实现细节,和领域层无关。我见过一个比较好的实践是,把"按业务意图查询"和"为列表页提供灵活查询"分开——前者走仓储接口,后者走专门的查询服务直接查DO,互不干扰。
5.5 只重技术落地,忽略业务语言统一
最后一个坑不属于代码层面,而是团队协作层面。COLA和DDD如果只被当成"技术分层规范"来推进,通常效果打折扣。DDD的核心之一是统一语言(Ubiquitous Language)。同一个概念在PRD里叫"下单",在技术团队代码里叫createOrder,在数据库里叫t_order_insert,在数据仓库那边叫"订单新增事件"。这些叫法不一致,会导致沟通成本极高,代码里的领域概念也被稀释。
我们在推进COLA时,强制要求代码里的类名、方法名、变量名必须跟业务PRD里的名词对齐。比如PRD里叫"取消订单",领域层的方法就叫cancel(),不允许叫updateStatus(2)。这件事听起来简单,但在一个既有项目中落地需要很大决心,因为它意味着要重构大量代码。不过一旦做成了,后边的沟通效率和对需求的响应速度都会有明显提升。
6. 到底什么项目适合引入COLA和DDD
最后说点务实的,不是所有项目都适合上COLA。技术选型一旦错了,后面付出的维护成本比收益还高。我在实际决策时通常会考虑几个条件:
- 业务复杂度够不够:如果项目核心逻辑就是简单的增删改查,没有复杂状态流转,没有多个业务规则互相制约,那直接MyBatis+Controller一把梭反而最高效。引入COLA等于给自行车装了火箭发动机,除了增加复杂度没有任何收益。
- 团队规模与人员稳定性:COLA对团队成员的抽象能力有要求。如果团队里大部分人习惯了写"Controller直接操作数据库",强行推COLA会带来很高的学习和反叛成本。最稳妥的方式是先在一个相对独立的业务模块试点,而不是全部门一起改。
- 未来是否有持续演化的需求:如果你的系统大概率会长期迭代、多团队维护,那么COLA的清晰边界会帮你少踩很多相互踩脚的坑;如果是短平快的活动项目,追求上线速度就好,别追求架构美感。
我自己的原则是:核心交易链路、复杂状态机、多人协作的长期项目,值得上COLA。内部管理后台、简单查询报表、一次性活动页面,直接简单分层即可。
另外建议如果你准备在团队里推广COLA,一定不要一上来就拿一个复杂的真实业务练手。先用一个类似"用户注册""积分发放"的简单链路跑通整个COLA代码骨架,让团队理解适配层、应用层、领域层、基础设施层分别长什么样,再逐步把复杂业务往这个骨架里迁移。我们当时就是因为太激进,拿着最核心的订单系统直接重构,结果上线一个多月才稳定下来,期间还回滚了三次。后来复盘,如果当初选择用一个不太重要的模块先练手,后面会顺很多。
