工程结构
soda 不强制你的目录布局,但它推荐一套四层结构。下面是一个真实生产工程的完整目录树(已脱敏),可以直接照抄。
完整目录树
src/main/java/com/example/demo/
├── DemoApplication.java # 启动类
│
├── adapter/ # 适配器层:协议入口
│ └── inbound/
│ └── web/ # 入站(HTTP)适配器
│ ├── ProducerController.java # Controller
│ ├── dto/ # 出参 DTO
│ │ └── ProducerDTO.java
│ ├── assembler/ # Web 侧组装器(领域 → DTO)
│ │ └── ProducerWebAssembler.java
│ └── advice/ # 切面与异常兜底
│ ├── ControllerAroundHandler.java
│ └── GlobalExceptionHandler.java
│
├── application/ # 应用层:用例编排
│ └── cqrs/
│ ├── command/ # Command 定义
│ │ └── CreateProducerCommand.java
│ ├── query/ # Query 定义
│ │ ├── GetProducerQuery.java
│ │ └── ListProducerQuery.java
│ └── handler/
│ ├── command/ # CommandHandler 实现
│ │ └── CreateProducerCommandHandler.java
│ ├── query/ # QueryHandler 实现
│ │ ├── GetProducerQueryHandler.java
│ │ └── ListProducerQueryHandler.java
│ └── event/ # EventHandler 实现
│ └── ProducerEventHandler.java
│
├── domain/ # 领域层:不依赖任何技术
│ ├── producer/
│ │ ├── Producer.java # 聚合根
│ │ ├── event/
│ │ │ └── ProducerEvent.java # 领域事件
│ │ └── repository/
│ │ ├── ProducerWriteRepository.java # 写仓储接口
│ │ └── ProducerReadRepository.java # 读仓储接口
│ ├── consumer/
│ │ ├── Consumer.java
│ │ ├── event/
│ │ │ └── ConsumerEvent.java
│ │ └── repository/
│ │ ├── ConsumerWriteRepository.java
│ │ └── ConsumerReadRepository.java
│ └── enums/
│ ├── ProducerEventTypeEnum.java # 事件类型枚举
│ └── OperationTypeEnum.java # 操作类型枚举
│
├── infrastructure/ # 基础设施层:技术实现
│ ├── repositoryImpl/ # 仓储实现
│ │ ├── producer/
│ │ │ ├── ProducerWriteRepositoryImpl.java
│ │ │ └── ProducerReadRepositoryImpl.java
│ │ └── consumer/
│ │ ├── ConsumerWriteRepositoryImpl.java
│ │ └── ConsumerReadRepositoryImpl.java
│ ├── assembler/ # 数据层组装器(DO ↔ 领域)
│ │ ├── ProducerAssembler.java
│ │ └── ConsumerAssembler.java
│ ├── acl/ # 外部系统防腐层
│ │ └── mysql/
│ │ ├── entity/
│ │ │ └── ScodaProducer.java # 数据库实体(DO)
│ │ ├── mapper/
│ │ │ └── ScodaProducerMapper.java
│ │ └── config/
│ │ └── MybatisPlusConfig.java
│ └── config/ # 技术配置
│ └── SwaggerConfig.java
│
└── utils/ # 无状态工具
└── base/
└── tools/
└── BuKaUid.java # ID 生成src/main/resources/
├── application.yaml
├── application-dev.yaml
├── application-prod.yaml
├── mapper/ # MyBatis XML
│ └── ScodaProducerMapper.xml
└── sql/
├── schema-h2.sql
└── sample.sql每层放什么
| 层 | 包名 | 职责 | 不该出现的东西 |
|---|---|---|---|
| adapter | adapter.inbound.web | HTTP 协议转换、参数绑定、响应包装 | 业务规则、直接调 Mapper |
| application | application.cqrs | 用例编排、事务边界、调用仓储 | HTTP 相关的 HttpServletRequest |
| domain | domain | 业务规则、聚合根、领域事件、仓储接口 | 任何 Spring / 数据库 import |
| infrastructure | infrastructure | 仓储实现、DO、Mapper、外部系统 | 业务规则 |
一个判断标准:在 domain 包里如果出现了 org.springframework 或 com.baomidou 的 import,就是越界了。
adapter:只做协议转换
Controller 的职责就三件事:把 HTTP 请求体变成 Command / Query、把处理结果变成 Result / PageResult、把异常变成统一响应。
@RestController
@RequestMapping("api")
@RequiredArgsConstructor
public class ProducerController {
private final ProducerAppService producerAppService;
@PostMapping("creatProducer")
public Result<ProducerDTO> creatProducer(@RequestBody CreateProducerCommand command) throws BaseException {
ProducerDTO dto = producerAppService.creatProducer(command);
return Result.success(dto);
}
}注意 Controller 返回的是 DTO,不是领域对象。DTO 由 adapter.inbound.web.assembler 里的 Web 组装器从领域对象转换而来。
application:编排用例
应用层放 Command / Query 的定义和对应的 Handler。Handler 的职责是:校验参数 → 调聚合根 → 存仓储 → 返回结果。它不写业务规则,业务规则在聚合根里。三种 Handler 的对照见 Handler。
@Component
@RequiredArgsConstructor
public class CreateProducerCommandHandler implements CommandHandler<CreateProducerCommand, ProducerDTO> {
private final ProducerWriteRepository producerWriteRepository;
@Override
public ProducerDTO handle(CreateProducerCommand command) throws BaseException {
commandValidate(command);
Producer domain = ProducerAssembler.toDomain(command);
domain.create(command.getName());
producerWriteRepository.save(domain);
return ProducerWebAssembler.toDTO(domain);
}
}domain:业务规则的唯一所在地
聚合根持有状态和行为,行为里改状态、挂事件。仓储接口在这里声明,实现不在这里。
@Data
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;
}
}infrastructure:技术的落地
仓储实现、MyBatis-Plus 的 DO 与 Mapper、外部系统(Redis、日志服务)的客户端都在这里。
@Component
@RequiredArgsConstructor
public class ProducerWriteRepositoryImpl implements ProducerWriteRepository {
private final ScodaProducerMapper scodaProducerMapper;
@Override
public boolean save(Producer producer) throws BaseException {
ScodaProducer scodaProducer = ProducerAssembler.toDO(producer);
try {
return scodaProducerMapper.insert(scodaProducer) > 0;
} catch (Exception e) {
throw new BaseException(BaseErrorCode.DATABASE_ERROR.getCode(), e.getMessage());
}
}
}依赖方向规则
adapter ──→ application ──→ domain ←── infrastructure| 规则 | 含义 |
|---|---|
adapter → application | Controller 只能调应用服务,不能直接调仓储 |
application → domain | Handler 依赖聚合根和仓储接口 |
infrastructure → domain | 仓储实现依赖领域层,实现它声明的接口 |
domain ← infrastructure | 领域层不依赖基础设施,箭头只朝内 |
application ⊥ infrastructure | 应用层不直接依赖仓储实现,只依赖接口 |
最后一条经常被忽略:Handler 里注入的必须是 ProducerWriteRepository(接口),不能是 ProducerWriteRepositoryImpl。否则接口就失去了隔离意义,Spring 注入时也失去了切换实现的能力。
两个组装器,别搞混
工程里通常有两个静态工具类,职责完全不同:
| 组装器 | 位置 | 方向 | 谁用 |
|---|---|---|---|
ProducerAssembler | infrastructure.assembler | DO ↔ 领域对象、Command → 领域对象 | 仓储实现、Handler |
ProducerWebAssembler | adapter.inbound.web.assembler | 领域对象 → DTO | Handler、Controller |
public class ProducerAssembler {
public static Producer toDomain(ScodaProducer doRoot) { ... }
public static Producer toDomain(CreateProducerCommand doRoot) { ... }
public static ScodaProducer toDO(Producer producer) { ... }
}
public class ProducerWebAssembler {
public static ProducerDTO toDTO(Producer producer) { ... }
public static PageVO<ProducerDTO> toDTO(PageVO<Producer> page) { ... }
}命名上可以用 XxxAssembler(数据层)和 XxxWebAssembler(Web 层)区分,约定俗成即可。
组装器写成静态方法就够,不需要注册成 Bean —— 它们没有状态。Spring 只会为 @Component 的类创建代理,纯静态工具类交给 new 也行。
soda-core 的类型放在哪
soda-core 提供的类型分成三类,用法完全不同。
一、你要继承 / 实现的抽象
| 类型 | 包 | 你的用法 |
|---|---|---|
BaseCommand<R> | com.hibuka.soda.cqrs.command | 继承 |
BaseQuery<R> / BasePageQuery<R> | com.hibuka.soda.cqrs.query | 继承 |
AbstractDomainEvent | com.hibuka.soda.domain.event | 继承 |
AbstractAggregateRoot | com.hibuka.soda.domain.aggregate | 继承 |
WriteRepository<T> | com.hibuka.soda.domain.repository | 实现(写侧) |
CommandHandler / QueryHandler / EventHandler | com.hibuka.soda.cqrs.* | 实现 |
二、你直接调用的工具
| 类型 | 包 | 用途 |
|---|---|---|
BusFacade | com.hibuka.soda.bus.facade | 发命令、发查询 |
Result<T> / PageResult<T> | com.hibuka.soda.foundation.io | 统一响应 |
PageVO<T> / PageDTO<T> | com.hibuka.soda.foundation.io | 分页数据容器 |
BaseException / BaseErrorCode | com.hibuka.soda.foundation.error | 异常与错误码 |
ParamChecker | com.hibuka.soda.foundation.io | 参数校验 |
BaseConstant | com.hibuka.soda.foundation.constant | 请求头常量 |
CodeEnum<T> / EnumUtils | com.hibuka.soda.foundation.enums | 枚举约定 |
三、自动装配进来的
这些你不用碰,框架会注册好:
| Bean | 来源 | 作用 |
|---|---|---|
CommandBus / QueryBus | SpringBusAutoConfiguration | 命令与查询总线 |
EventBus | SimpleEventBusAutoConfiguration / RedisEventBusAutoConfiguration | 事件总线 |
BusFacade | SpringBusAutoConfiguration | 门面 |
CqrsAroundHandler | SpringBusAutoConfiguration | CQRS 切面 |
RepositoryEventAspect | RepositoryEventAutoConfiguration | 仓储切面,发布领域事件 |
EventProperties | SpringBusAutoConfiguration | soda.event.* 配置 |
应用服务:Controller 和 Handler 之间的一层
真实工程里,Controller 通常不直接注入 Handler,而是经过一层应用服务。应用服务负责调 BusFacade,把命令送进总线。
@Service
@RequiredArgsConstructor
public class ProducerAppServiceImpl implements ProducerAppService {
private final BusFacade busFacade;
@Override
public ProducerDTO creatProducer(CreateProducerCommand command) throws BaseException {
return busFacade.sendTransactCommand(command);
}
@Override
public CompletableFuture<ProducerDTO> creatProducerSyn(CreateProducerCommand command) throws BaseException {
return busFacade.sendAsyncCommand(command);
}
}这一层不是强制的。简单工程里 Controller 直接注入 BusFacade 也完全可行。应用服务的价值在于:把「同步还是异步」「走哪条总线」「要不要事务」这些编排决策从 Controller 里挪出来。
MyBatis-Plus 的接入位置
数据库相关的配置全部在 infrastructure.acl.mysql.config 下:
@Configuration
@MapperScan("com.example.demo.infrastructure.acl.mysql.mapper")
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return interceptor;
}
}DO 实体用 @TableName 映射表名,Mapper 继承 BaseMapper<T>:
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("scoda_producer")
public class ScodaProducer implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private String uid;
private String name;
private LocalDateTime gmtCreate;
private LocalDateTime gmtModified;
}分页查询直接用 MyBatis-Plus 的 Page<T>,在仓储实现里转成领域对象,再交给上层转成 PageVO:
@Override
public Page<Producer> list(ListProducerQuery query) throws BaseException {
try {
Page<ScodaProducer> page = new Page<>(query.getPageNumberWithDefault(), query.getPageSizeWithDefault());
LambdaQueryWrapper<ScodaProducer> wrapper = new LambdaQueryWrapper<>();
Page<ScodaProducer> producerPage = scodaProducerMapper.selectPage(page, wrapper);
if (null == producerPage) {
return null;
}
return ProducerAssembler.toDomain(producerPage);
} catch (Exception e) {
throw new BaseException(BaseErrorCode.DATABASE_ERROR.getCode(), e.getMessage());
}
}