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
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 generationsrc/main/resources/
├── application.yaml
├── application-dev.yaml
├── application-prod.yaml
├── mapper/ # MyBatis XML
│ └── ScodaProducerMapper.xml
└── sql/
├── schema-h2.sql
└── sample.sqlWhat goes in each layer
| Layer | Package | Responsibility | What must not appear |
|---|---|---|---|
| adapter | adapter.inbound.web | HTTP protocol conversion, parameter binding, response wrapping | business rules, calling Mappers directly |
| application | application.cqrs | use-case orchestration, transaction boundaries, calling repositories | HTTP-related HttpServletRequest |
| domain | domain | business rules, aggregate roots, domain events, repository interfaces | any Spring / database import |
| infrastructure | infrastructure | repository implementations, DOs, Mappers, external systems | business 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.
@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.
@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.
@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.
@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
adapter ──→ application ──→ domain ←── infrastructure| Rule | Meaning |
|---|---|
adapter → application | A Controller may only call application services, never repositories directly |
application → domain | Handlers depend on aggregate roots and repository interfaces |
infrastructure → domain | Repository implementations depend on the domain layer, implementing its declared interfaces |
domain ← infrastructure | The domain layer does not depend on infrastructure; the arrow points inward only |
application ⊥ infrastructure | The 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:
| Assembler | Location | Direction | Who uses it |
|---|---|---|---|
ProducerAssembler | infrastructure.assembler | DO ↔ domain object, Command → domain object | repository implementations, Handlers |
ProducerWebAssembler | adapter.inbound.web.assembler | domain object → DTO | Handlers, Controllers |
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.
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
| Type | Package | Your usage |
|---|---|---|
BaseCommand<R> | com.hibuka.soda.cqrs.command | extend |
BaseQuery<R> / BasePageQuery<R> | com.hibuka.soda.cqrs.query | extend |
AbstractDomainEvent | com.hibuka.soda.domain.event | extend |
AbstractAggregateRoot | com.hibuka.soda.domain.aggregate | extend |
WriteRepository<T> | com.hibuka.soda.domain.repository | implement (write side) |
CommandHandler / QueryHandler / EventHandler | com.hibuka.soda.cqrs.* | implement |
2. Utilities you call directly
| Type | Package | Purpose |
|---|---|---|
BusFacade | com.hibuka.soda.bus.facade | send commands, send queries |
Result<T> / PageResult<T> | com.hibuka.soda.foundation.io | uniform responses |
PageVO<T> / PageDTO<T> | com.hibuka.soda.foundation.io | paged data containers |
BaseException / BaseErrorCode | com.hibuka.soda.foundation.error | exceptions and error codes |
ParamChecker | com.hibuka.soda.foundation.io | parameter validation |
BaseConstant | com.hibuka.soda.foundation.constant | request header constants |
CodeEnum<T> / EnumUtils | com.hibuka.soda.foundation.enums | enum conventions |
3. Brought in by auto-configuration
You don't touch these; the framework registers them:
| Bean | Source | Role |
|---|---|---|
CommandBus / QueryBus | SpringBusAutoConfiguration | command and query buses |
EventBus | SimpleEventBusAutoConfiguration / RedisEventBusAutoConfiguration | event bus |
BusFacade | SpringBusAutoConfiguration | facade |
CqrsAroundHandler | SpringBusAutoConfiguration | CQRS aspect |
RepositoryEventAspect | RepositoryEventAutoConfiguration | repository aspect, publishes domain events |
EventProperties | SpringBusAutoConfiguration | soda.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.
@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);
}
}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:
@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>:
@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:
@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
- Write a write chain following this structure: Write a Command
- Write a read chain following this structure: Write a Query
- Understand how events get published: Event Bus & Aspect