soda docs v17.0.1

Project Structure

soda does not force a directory layout on you, but it recommends a four-layer structure. Below is the complete directory tree of a real production project (sanitized) — you can copy it straight.

Complete directory tree

text
src/main/java/com/example/demo/
├── DemoApplication.java                    # startup class
│
├── adapter/                                 # adapter layer: protocol entry points
│   └── inbound/
│       └── web/                             # inbound (HTTP) adapters
│           ├── ProducerController.java      # Controller
│           ├── dto/                         # outbound DTOs
│           │   └── ProducerDTO.java
│           ├── assembler/                   # Web-side assemblers (domain → DTO)
│           │   └── ProducerWebAssembler.java
│           └── advice/                      # aspects and exception fallback
│               ├── ControllerAroundHandler.java
│               └── GlobalExceptionHandler.java
│
├── application/                             # application layer: use-case orchestration
│   └── cqrs/
│       ├── command/                         # Command definitions
│       │   └── CreateProducerCommand.java
│       ├── query/                           # Query definitions
│       │   ├── GetProducerQuery.java
│       │   └── ListProducerQuery.java
│       └── handler/
│           ├── command/                     # CommandHandler implementations
│           │   └── CreateProducerCommandHandler.java
│           ├── query/                       # QueryHandler implementations
│           │   ├── GetProducerQueryHandler.java
│           │   └── ListProducerQueryHandler.java
│           └── event/                       # EventHandler implementations
│               └── ProducerEventHandler.java
│
├── domain/                                  # domain layer: depends on no technology
│   ├── producer/
│   │   ├── Producer.java                    # aggregate root
│   │   ├── event/
│   │   │   └── ProducerEvent.java           # domain event
│   │   └── repository/
│   │       ├── ProducerWriteRepository.java # write repository interface
│   │       └── ProducerReadRepository.java  # read repository interface
│   ├── consumer/
│   │   ├── Consumer.java
│   │   ├── event/
│   │   │   └── ConsumerEvent.java
│   │   └── repository/
│   │       ├── ConsumerWriteRepository.java
│   │       └── ConsumerReadRepository.java
│   └── enums/
│       ├── ProducerEventTypeEnum.java       # event type enum
│       └── OperationTypeEnum.java           # operation type enum
│
├── infrastructure/                          # infrastructure layer: technical implementation
│   ├── repositoryImpl/                      # repository implementations
│   │   ├── producer/
│   │   │   ├── ProducerWriteRepositoryImpl.java
│   │   │   └── ProducerReadRepositoryImpl.java
│   │   └── consumer/
│   │       ├── ConsumerWriteRepositoryImpl.java
│   │       └── ConsumerReadRepositoryImpl.java
│   ├── assembler/                           # data-layer assemblers (DO ↔ domain)
│   │   ├── ProducerAssembler.java
│   │   └── ConsumerAssembler.java
│   ├── acl/                                 # anti-corruption layer for external systems
│   │   └── mysql/
│   │       ├── entity/
│   │       │   └── ScodaProducer.java      # database entity (DO)
│   │       ├── mapper/
│   │       │   └── ScodaProducerMapper.java
│   │       └── config/
│   │           └── MybatisPlusConfig.java
│   └── config/                              # technical configuration
│       └── SwaggerConfig.java
│
└── utils/                                   # stateless utilities
    └── base/
        └── tools/
            └── BuKaUid.java                 # ID generation
text
src/main/resources/
├── application.yaml
├── application-dev.yaml
├── application-prod.yaml
├── mapper/                                  # MyBatis XML
│   └── ScodaProducerMapper.xml
└── sql/
    ├── schema-h2.sql
    └── sample.sql

What goes in each layer

LayerPackageResponsibilityWhat must not appear
adapteradapter.inbound.webHTTP protocol conversion, parameter binding, response wrappingbusiness rules, calling Mappers directly
applicationapplication.cqrsuse-case orchestration, transaction boundaries, calling repositoriesHTTP-related HttpServletRequest
domaindomainbusiness rules, aggregate roots, domain events, repository interfacesany Spring / database import
infrastructureinfrastructurerepository implementations, DOs, Mappers, external systemsbusiness rules

One test to apply: if an org.springframework or com.baomidou import shows up in the domain package, a boundary has been crossed.

adapter: protocol conversion only

A Controller's job is three things: turn the HTTP request body into a Command / Query, turn the result into Result / PageResult, and turn exceptions into a uniform response.

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);
    }
}

Note the Controller returns a DTO, not a domain object. The DTO is converted from the domain object by the Web assembler in adapter.inbound.web.assembler.

application: orchestrating use cases

The application layer holds the Command / Query definitions and their Handlers. A Handler's job: validate parameters → call the aggregate root → persist via repository → return the result. It writes no business rules — those live in the aggregate root. The side-by-side comparison of the three Handler kinds is in 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: the one home of business rules

The aggregate root holds state and behavior; behavior changes state and suspends events. Repository interfaces are declared here — implementations are not.

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: where technology lands

Repository implementations, MyBatis-Plus DOs and Mappers, and clients for external systems (Redis, logging services) all live here.

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

Dependency direction rules

text
adapter  ──→  application  ──→  domain  ←──  infrastructure
RuleMeaning
adapter → applicationA Controller may only call application services, never repositories directly
application → domainHandlers depend on aggregate roots and repository interfaces
infrastructure → domainRepository implementations depend on the domain layer, implementing its declared interfaces
domain ← infrastructureThe domain layer does not depend on infrastructure; the arrow points inward only
application ⊥ infrastructureThe application layer never depends on repository implementations, only interfaces

The last one is often missed: what a Handler injects must be ProducerWriteRepository (the interface), never ProducerWriteRepositoryImpl. Otherwise the interface loses its point as an isolation boundary, and Spring injection loses the ability to swap implementations.

Two assemblers — don't mix them up

A project usually has two static utility classes with completely different jobs:

AssemblerLocationDirectionWho uses it
ProducerAssemblerinfrastructure.assemblerDO ↔ domain object, Command → domain objectrepository implementations, Handlers
ProducerWebAssembleradapter.inbound.web.assemblerdomain object → DTOHandlers, Controllers
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) { ... }
}

Naming them XxxAssembler (data layer) and XxxWebAssembler (Web layer) to tell them apart is just convention.

Tip

Assemblers are fine as plain static methods — no need to register them as beans; they have no state. Spring only creates proxies for @Component classes; a pure static utility class can just be new-ed (called statically).

Where soda-core types go

The types soda-core provides fall into three categories, used in completely different ways.

1. Abstractions you extend / implement

TypePackageYour usage
BaseCommand<R>com.hibuka.soda.cqrs.commandextend
BaseQuery<R> / BasePageQuery<R>com.hibuka.soda.cqrs.queryextend
AbstractDomainEventcom.hibuka.soda.domain.eventextend
AbstractAggregateRootcom.hibuka.soda.domain.aggregateextend
WriteRepository<T>com.hibuka.soda.domain.repositoryimplement (write side)
CommandHandler / QueryHandler / EventHandlercom.hibuka.soda.cqrs.*implement

2. Utilities you call directly

TypePackagePurpose
BusFacadecom.hibuka.soda.bus.facadesend commands, send queries
Result<T> / PageResult<T>com.hibuka.soda.foundation.iouniform responses
PageVO<T> / PageDTO<T>com.hibuka.soda.foundation.iopaged data containers
BaseException / BaseErrorCodecom.hibuka.soda.foundation.errorexceptions and error codes
ParamCheckercom.hibuka.soda.foundation.ioparameter validation
BaseConstantcom.hibuka.soda.foundation.constantrequest header constants
CodeEnum<T> / EnumUtilscom.hibuka.soda.foundation.enumsenum conventions

3. Brought in by auto-configuration

You don't touch these; the framework registers them:

BeanSourceRole
CommandBus / QueryBusSpringBusAutoConfigurationcommand and query buses
EventBusSimpleEventBusAutoConfiguration / RedisEventBusAutoConfigurationevent bus
BusFacadeSpringBusAutoConfigurationfacade
CqrsAroundHandlerSpringBusAutoConfigurationCQRS aspect
RepositoryEventAspectRepositoryEventAutoConfigurationrepository aspect, publishes domain events
EventPropertiesSpringBusAutoConfigurationsoda.event.* configuration

Application services: the layer between Controller and Handler

In real projects, Controllers usually don't inject Handlers directly — they go through an application service. The application service calls BusFacade, which puts the command on the bus.

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);
    }
}
Note

This layer is not mandatory. In a simple project a Controller injecting BusFacade directly is perfectly fine. The value of an application service: it moves orchestration decisions — "sync or async", "which bus", "with or without a transaction" — out of the Controller.

Where MyBatis-Plus plugs in

All database-related configuration lives under 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 entities map table names with @TableName, and Mappers extend 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;
}

Paged queries use MyBatis-Plus's Page<T> directly; the repository implementation converts it to domain objects, and the upper layer converts those to 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());
    }
}

Next steps