soda docs v17.0.1

工程结构

soda 不强制你的目录布局,但它推荐一套四层结构。下面是一个真实生产工程的完整目录树(已脱敏),可以直接照抄。

完整目录树

text
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 生成
text
src/main/resources/
├── application.yaml
├── application-dev.yaml
├── application-prod.yaml
├── mapper/                                  # MyBatis XML
│   └── ScodaProducerMapper.xml
└── sql/
    ├── schema-h2.sql
    └── sample.sql

每层放什么

层包名职责不该出现的东西
adapteradapter.inbound.webHTTP 协议转换、参数绑定、响应包装业务规则、直接调 Mapper
applicationapplication.cqrs用例编排、事务边界、调用仓储HTTP 相关的 HttpServletRequest
domaindomain业务规则、聚合根、领域事件、仓储接口任何 Spring / 数据库 import
infrastructureinfrastructure仓储实现、DO、Mapper、外部系统业务规则

一个判断标准:在 domain 包里如果出现了 org.springframework 或 com.baomidou 的 import,就是越界了。

adapter:只做协议转换

Controller 的职责就三件事:把 HTTP 请求体变成 Command / Query、把处理结果变成 Result / PageResult、把异常变成统一响应。

java
@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。

java
@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:业务规则的唯一所在地

聚合根持有状态和行为,行为里改状态、挂事件。仓储接口在这里声明,实现不在这里。

java
@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、日志服务)的客户端都在这里。

java
@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());
        }
    }
}

依赖方向规则

text
adapter  ──→  application  ──→  domain  ←──  infrastructure
规则含义
adapter → applicationController 只能调应用服务,不能直接调仓储
application → domainHandler 依赖聚合根和仓储接口
infrastructure → domain仓储实现依赖领域层,实现它声明的接口
domain ← infrastructure领域层不依赖基础设施,箭头只朝内
application ⊥ infrastructure应用层不直接依赖仓储实现,只依赖接口

最后一条经常被忽略:Handler 里注入的必须是 ProducerWriteRepository(接口),不能是 ProducerWriteRepositoryImpl。否则接口就失去了隔离意义,Spring 注入时也失去了切换实现的能力。

两个组装器,别搞混

工程里通常有两个静态工具类,职责完全不同:

组装器位置方向谁用
ProducerAssemblerinfrastructure.assemblerDO ↔ 领域对象、Command → 领域对象仓储实现、Handler
ProducerWebAssembleradapter.inbound.web.assembler领域对象 → DTOHandler、Controller
java
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继承
AbstractDomainEventcom.hibuka.soda.domain.event继承
AbstractAggregateRootcom.hibuka.soda.domain.aggregate继承
WriteRepository<T>com.hibuka.soda.domain.repository实现(写侧)
CommandHandler / QueryHandler / EventHandlercom.hibuka.soda.cqrs.*实现

二、你直接调用的工具

类型包用途
BusFacadecom.hibuka.soda.bus.facade发命令、发查询
Result<T> / PageResult<T>com.hibuka.soda.foundation.io统一响应
PageVO<T> / PageDTO<T>com.hibuka.soda.foundation.io分页数据容器
BaseException / BaseErrorCodecom.hibuka.soda.foundation.error异常与错误码
ParamCheckercom.hibuka.soda.foundation.io参数校验
BaseConstantcom.hibuka.soda.foundation.constant请求头常量
CodeEnum<T> / EnumUtilscom.hibuka.soda.foundation.enums枚举约定

三、自动装配进来的

这些你不用碰,框架会注册好:

Bean来源作用
CommandBus / QueryBusSpringBusAutoConfiguration命令与查询总线
EventBusSimpleEventBusAutoConfiguration / RedisEventBusAutoConfiguration事件总线
BusFacadeSpringBusAutoConfiguration门面
CqrsAroundHandlerSpringBusAutoConfigurationCQRS 切面
RepositoryEventAspectRepositoryEventAutoConfiguration仓储切面,发布领域事件
EventPropertiesSpringBusAutoConfigurationsoda.event.* 配置

应用服务:Controller 和 Handler 之间的一层

真实工程里,Controller 通常不直接注入 Handler,而是经过一层应用服务。应用服务负责调 BusFacade,把命令送进总线。

java
@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 下:

java
@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>:

java
@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:

java
@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());
    }
}

下一步