核心概念
soda 是一套运行在 Spring Boot 上的 DDD / CQRS 框架。它的核心观点很简单:写和读是两种不同性质的操作,不该用同一个形状去承载。
围绕这个观点,soda 把消息分成三类 —— Command(写)、Query(读)、DomainEvent(通知)—— 再由 Handler 执行、Repository 存取、聚合根守住规则。
先看全貌
四个角色各管一段:
| 角色 | 管什么 | 一句话 |
|---|---|---|
| Command / Query / DomainEvent | 消息 | 要做什么 |
| Handler | 执行 | 谁来做 |
| 聚合根 | 规则 | 业务逻辑写在哪 |
| Repository | 存取 | 数据存在哪 |
Command 和 Query 由调用方发出来,DomainEvent 由聚合根在状态变化时产生。Handler 收到消息后,往下调聚合根和 Repository —— 这就是「应用层编排、领域层守规则、基础设施层管存储」的分工。
三种消息:Command、Query、DomainEvent
| 类型 | 基类 | 干什么 |
|---|---|---|
| Command | BaseCommand<R> | 写数据,改状态 |
| Query | BaseQuery<R> | 读数据,查状态 |
| DomainEvent | AbstractDomainEvent | 通知:状态已经变了 |
三个类型都能携带上下文字段(requestId、userName、authorities、jti、tenantId 等)。但只有 Command 和 Query 实现了 Serializable —— 它们是入参,要能跨进程传;DomainEvent 没有实现,跨服务时靠 Jackson 的 @JsonTypeInfo 在 JSON 里写 @class 字段还原类型,见 领域事件。
它们不像表面看上去那么对称,差别在这四处:
| Handler 个数 | 返回值 | 没有 Handler | Handler 抛异常 | |
|---|---|---|---|---|
| Command | 恰好 1 个 | 有 | 抛 BaseException | 异常上抛;外层有事务就回滚,事件不会被取走 |
| Query | 恰好 1 个 | 有 | 抛 BaseException | 异常上抛,本次请求失败 |
| DomainEvent | 0 到 n 个 | 无(void) | 静默跳过 | 事务已提交,只记日志 |
soda 自己不开启事务。 sendTransactCommand 只是名字里带 Transact,它不会替你加 @Transactional。命令要能回滚,得由你在 AppService 或 Controller 的方法上开事务。没有事务时,save 一返回事件就发布了 —— 后面再失败也收不回来。详见 事件总线与切面。
Command:写
命令就是写操作的入参。它带着参数进入 CommandBus,交给唯一一个 CommandHandler;Handler 改完状态,把结果返回。
命令有返回值 —— BaseCommand<R> 的泛型 R 就是 CommandHandler 的返回类型。命令也会失败:校验不过、聚合根拒绝、数据库报错,都抛 BaseException,交给上层的事务边界处理。
命令里只放参数,不放业务规则。业务规则属于聚合根。
public class CreateProducerCommand extends BaseCommand<ProducerDTO> {
private String name;
}Query:读
查询就是读操作的入参。它进入 QueryBus,交给唯一一个 QueryHandler,拿回数据。它不改状态。
public class GetProducerQuery extends BaseQuery<Producer> {
private String uid;
}分页查询继承 BasePageQuery<R>,额外带 pageNumber(默认 1)和 pageSize(默认 20,最大 200)。BasePageQuery 只是消息基类,预置了分页参数,和 Handler 没有关系 —— 写它对应的仍然是一个 QueryHandler。
Query 侧应该只读。soda 不强制这一点,但如果你在 QueryHandler 里调用了写仓储,CQRS 就退化成了「分层架构」,命令和查询还是会在同一次请求里互相污染。
DomainEvent:通知
事件是「某个状态已经变了」的通知。它不是入参,没有返回值,也不指定谁处理 —— 由聚合根在状态变更时产生,由事件总线投递给所有关心它的 EventHandler。
public class ProducerEvent extends AbstractDomainEvent {
private Producer producer;
}事件的发布时机和命令、查询不一样:聚合根只负责挂起,真正 publish 的是仓储切面。
三件事和命令、查询不一样,必须记住:
- 没有 EventHandler 不报错。 发出去没人接,静默跳过。命令少了 Handler 会抛异常,事件不会。
- EventHandler 抛异常不回滚。 事件在事务提交之后才发,那时事务已经结束了,
RepositoryEventAspect只能把异常记进日志。所以事件处理的失败要靠你自己兜。 - 事件里装的是聚合根的引用,不是快照。
ProducerEvent持有的是Producer对象本身。要留档、要跨服务,自己存一份。
AbstractDomainEvent 在构造时自动生成 eventId(eid- 前缀的雪花 ID)和 occurredOn(LocalDateTime.now()),业务代码只填 eventType 和业务字段。
事件名用 eventType(字符串)区分,而不是靠类名。推荐用一个枚举来管理,枚举实现 CodeEnum:
@Getter
public enum ProducerEventTypeEnum implements CodeEnum<Integer> {
CREATE_PRODUCER(1, "create_producer"),
CREATE_CONSUMER(0, "create_consumer"),
UNKNONW(-1, "unknown");
private final Integer code;
private final String desc;
}CodeEnum<T> 定义在 com.hibuka.soda.foundation.enums,只有两个方法:getCode() 和 getDesc()。配套的 EnumUtils.getByCode(...) 帮你按 code 反查枚举。
聚合根:业务规则写在这里
领域层里最核心的东西是聚合根。 业务规则不写在 Handler 里,也不写在 Service 里 —— 它写在聚合根的方法里。
聚合根是一个业务概念的入口。它是唯一被外部持有和修改的对象,内部实体(值对象、子实体)的生命周期由它保护。
在 soda 里,聚合根继承 AbstractAggregateRoot:
public class Producer extends AbstractAggregateRoot {
private String uid;
private String name;
public boolean create(String name) {
this.uid = BuKaUid.genDomainUid();
this.name = name;
addPendingEvent(new ProducerEvent(ProducerEventTypeEnum.CREATE_PRODUCER.getDesc(), this));
return true;
}
}AbstractAggregateRoot 同时实现了 DomainEvents(提供 getDomainEvents() 和 clearDomainEvents()),内部维护一个待发布事件列表。
AbstractAggregateRoot 本身也继承了 AbstractDomainEvent,所以聚合根对象上也有 eventId / eventType / occurredOn 这些字段。这是历史设计,实际业务里用不到,不用管。
关键约束:只有聚合根能 addPendingEvent,而且这个方法是 protected 的 —— 事件只能由聚合内部产生。服务层想发事件,说明聚合设计有问题。
Repository:数据存取的地方
Repository(仓储)是聚合与存储之间的边界:接口声明在领域层,实现在基础设施层。 领域层只说「我需要能存一个 Producer」,不关心底下是 MySQL 还是别的。
因为 CQRS 把读写拆成了两条链路,仓储也是两套接口 —— 写侧跟着命令走,读侧跟着查询走,各自演进,互不干扰。
写侧:继承 WriteRepository
框架在 com.hibuka.soda.domain.repository 里给了基类接口,固定四个方法:
public interface WriteRepository<T extends AbstractAggregateRoot> {
boolean save(T aggregate) throws BaseException;
boolean update(T aggregate) throws BaseException;
boolean delete(T aggregate) throws BaseException;
boolean operate(T aggregate, int operationType) throws BaseException;
}你的仓储接口继承它,把 T 定成聚合根:
public interface ProducerWriteRepository extends WriteRepository<Producer> {
}实现类放在基础设施层,四个方法各自接到 Mapper 上:
@Component
@RequiredArgsConstructor
public class ProducerWriteRepositoryImpl implements ProducerWriteRepository {
private final ScodaProducerMapper scodaProducerMapper;
@Override
public boolean save(Producer producer) throws BaseException {
return scodaProducerMapper.insert(ProducerAssembler.toDO(producer)) > 0;
}
// update / delete 同理,operate 按 operationType 分派给上面三个
}operate 是按操作类型分派(创建 / 更新 / 删除),operationType 通常来自你自己的操作类型枚举。
这四个方法的名字不能随手改 —— RepositoryEventAspect 靠切点捕获它们:
execution(* *..*Repository+.save(..))
它认两样东西:
- 方法名:必须是
save/update/delete/operate。改叫persist/insert,事件静默发不出去。 - 类型名:必须匹配
*Repository或它的子类型(+表示含子类型)。所以接口叫ProducerWriteRepository就够了,实现类叫什么都能拦到;反过来,接口和实现类名字里都不含Repository(比如ProducerStore)就拦不到。
详见 事件总线与切面。
读侧:接口自己定
读侧没有基类接口 —— 分页、联表、搜索各家差异太大,强行抽象只会碍事。自己在 domain 包里声明,返回领域对象:
public interface ProducerReadRepository {
Producer getByUid(String uid) throws BaseException;
Page<Producer> list(ListProducerQuery query) throws BaseException;
}读侧没有 WriteRepository 的四个方法,也就不会被切面拦截 —— 读不产生领域事件。
Handler:消息的执行者
消息本身只是数据,真正干活的是 Handler(处理器)。三类消息对应三类 Handler:
| 消息 | Handler 接口 | 方法签名 | 每个消息几个 |
|---|---|---|---|
Command | CommandHandler<C, R> | R handle(C command) | 恰好 1 个 |
Query | QueryHandler<Q, R> | R handle(Q query) | 恰好 1 个 |
DomainEvent | EventHandler<T> | void handle(T event) | 0 到 n 个 |
Handler 住在应用层,往下调两样东西:
- 聚合根 —— 业务规则。
CommandHandler把命令翻译成聚合根的方法调用,规则本身不写在 Handler 里。 - Repository —— 数据存取。
CommandHandler调写仓储落库,QueryHandler调读仓储取数。EventHandler做善后(更新读模型、发通知、发新命令),一般不碰写仓储。
三条各自的形状:
CommandHandler:校验参数 → 调聚合根 → 存仓储 → 返回结果。一个命令只能有一个 Handler。QueryHandler:调读仓储 → 组装数据 → 返回。不改状态。EventHandler:收到「状态已经变了」的通知,做后续动作。0 到 n 个,没人处理不报错。
Handler 都是普通的 Spring Bean,加 @Component 就自动注册 —— 总线用泛型反射读出它处理哪种消息并建索引,不需要写注册代码。三种 Handler 的完整对照、总线怎么找它们、失败时分别会怎样,见 Handler。
四层:谁依赖谁
soda 借鉴了整洁架构的依赖方向,把一个应用切成四层:
四个层各干一件事:
- adapter(入口层):只做协议转换 —— 把 HTTP 请求翻译成 Command / Query,把结果翻译回响应。不写业务。
- application(应用层):编排一次用例 —— 发命令、发查询、拼装返回值。Handler 住在这一层。
- domain(领域层):业务规则唯一的家。聚合根改状态、领域事件从聚合根产生、仓储接口在这里声明。
- infrastructure(基础设施层):技术细节 —— 实现 domain 声明的仓储接口,管 DO / Mapper、Redis、外部系统。
依赖箭头是单向的:adapter → application → domain ← infrastructure。领域层不依赖任何技术,它只声明「我要一个能存 Producer 的仓储」,具体实现放在基础设施层。这条规则一旦破坏,DDD 就退化成「在 Service 里塞逻辑的贫血模型」。
示例工程 buka-cloud1 有几处为了少写一层转换而做的反向引用:CreateProducerCommandHandler 直接返回 adapter 的 ProducerDTO、用了 infrastructure 的 ProducerAssembler,ProducerReadRepository 的参数是 application 的 ListProducerQuery。上面的图按设计意图画。新工程建议按单向依赖来,这些地方都是可以拆开的。
为什么是 CQRS
CQRS(Command Query Responsibility Segregation)把「改变状态」和「读取状态」拆成两条独立的链路。soda 采用它,理由有三个:
1. 形状不同。 写操作需要校验、幂等、事务、事件;读操作不需要。硬塞进一个方法里,就只能取两者的最小公倍数 —— 结果是读操作被无谓的事务和锁拖累,写操作被无谓的组装逻辑污染。
2. 优化点不同。 读侧和写侧最终往往落在不同的存储上(写库 + 读库 + 搜索 + 缓存)。CQRS 让「读侧走另一套实现」变成一个不需要破坏业务代码的选择。
3. 副作用可审计。 写链路上的每一步都留下了痕迹:命令、事件、上下文、耗时,都在切面日志里。读链路没有这些噪音。
soda 在这条路上做了几件具体的事:
- 命令 / 查询 / 事件各有独立的总线,互不串味。
- 领域事件在事务提交后才发布(
RepositoryEventAspect的afterCommit回调),不会发出「已回滚」的事件。 - 上下文自动贯穿:
requestId、用户身份从 HTTP 头一路带到领域事件,跨线程、跨服务都不丢。 - 递归保护:同步调用深度上限 10,异步事件跳数上限 20,事件互相触发成环时会主动停下。
如果你的系统只有 CRUD、没有跨聚合的不变式、不需要事件驱动下游,那么 CQRS 带来的抽象成本是实打实的。soda 的价值在业务复杂度上来之后才显现 —— 但一旦显现,它省下的样板代码是很多的。