Core Concepts
soda is a DDD / CQRS framework that runs on Spring Boot. Its core claim is simple: writes and reads are operations of a different nature and should not be carried by the same shape.
Around this claim, soda splits messages into three kinds — Command (write), Query (read), DomainEvent (notification) — then has Handler execute them, Repository store and fetch them, and the aggregate root guard the rules.
The big picture
The four roles each own one piece:
| Role | Owns | One-liner |
|---|---|---|
| Command / Query / DomainEvent | Messages | What to do |
| Handler | Execution | Who does it |
| Aggregate root | Rules | Where business logic lives |
| Repository | Storage | Where data lives |
Command and Query are sent by the caller; DomainEvent is produced by the aggregate root when state changes. The Handler receives the message and calls down into the aggregate root and the Repository — that is the division of labor: the application layer orchestrates, the domain layer guards the rules, the infrastructure layer manages storage.
Three kinds of message: Command, Query, DomainEvent
| Kind | Base class | What it does |
|---|---|---|
| Command | BaseCommand<R> | Writes data, changes state |
| Query | BaseQuery<R> | Reads data, fetches state |
| DomainEvent | AbstractDomainEvent | Notification: state has already changed |
All three can carry context fields (requestId, userName, authorities, jti, tenantId, and friends). But only Command and Query implement Serializable — they are inputs and must cross process boundaries; DomainEvent does not, so across services it relies on Jackson's @JsonTypeInfo writing a @class field into the JSON to restore the concrete type. See Domain Events.
They are not as symmetric as they look. The differences are in these four places:
| Handlers per message | Return value | No handler | Handler throws | |
|---|---|---|---|---|
| Command | exactly 1 | yes | throws BaseException | exception propagates; an outer transaction rolls back, events are not taken |
| Query | exactly 1 | yes | throws BaseException | exception propagates, the request fails |
| DomainEvent | 0 to n | none (void) | silently skipped | transaction already committed — log only |
soda opens no transactions. sendTransactCommand only has “Transact” in its name — it will not add @Transactional for you. For a command to roll back, you open the transaction on your AppService or Controller method. Without a transaction, the moment save returns the events are already published — nothing can pull them back if a later step fails. Details: Event Bus & Aspect.
Command: writes
A command is the input of a write operation. It carries parameters into the CommandBus, which hands it to the single CommandHandler; the Handler changes state and returns the result.
Commands have return values — the R in BaseCommand<R> is exactly the CommandHandler's return type. Commands can fail too: validation rejects, the aggregate root refuses, the database errors — all throw BaseException, left to the surrounding transaction boundary.
Commands hold parameters, not business rules. Business rules belong to the aggregate root.
public class CreateProducerCommand extends BaseCommand<ProducerDTO> {
private String name;
}Query: reads
A query is the input of a read operation. It enters the QueryBus, goes to the single QueryHandler, and gets data back. It changes nothing.
public class GetProducerQuery extends BaseQuery<Producer> {
private String uid;
}Paged queries extend BasePageQuery<R>, which adds pageNumber (default 1) and pageSize (default 20, max 200). BasePageQuery is only a message base class — it preloads paging parameters and has nothing to do with Handlers; it still gets exactly one QueryHandler.
The query side should stay read-only. soda does not enforce this, but if you call a write repository from a QueryHandler, CQRS degrades into “layered architecture” and commands and queries contaminate each other within the same request.
DomainEvent: notifications
An event is a notification that some state has already changed. It is not an input, has no return value, and names no handler — the aggregate root produces it when state changes, and the event bus delivers it to every EventHandler that cares.
public class ProducerEvent extends AbstractDomainEvent {
private Producer producer;
}The publishing moment differs from commands and queries: the aggregate root only suspends events; the real publish is done by the repository aspect.
Three things set events apart from commands and queries — remember them:
- No EventHandler is not an error. Publish with no subscribers and it is silently skipped. A command without a Handler throws; an event does not.
- A throwing EventHandler does not roll back. Events are published after the transaction commits; the transaction is already over, so
RepositoryEventAspectcan only log the exception. Failures in event handling are yours to cover. - Events carry a reference to the aggregate root, not a snapshot.
ProducerEventholds theProducerobject itself. If you need an archive or cross-service payload, store a copy yourself.
AbstractDomainEvent generates eventId (a snowflake ID with an eid- prefix) and occurredOn (LocalDateTime.now()) in its constructor; business code only fills eventType and business fields.
Events are discriminated by eventType (a string), not by class name. The recommended pattern is one enum managing them, implementing 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> lives in com.hibuka.soda.foundation.enums and has only two methods: getCode() and getDesc(). The companion EnumUtils.getByCode(...) looks an enum up by code.
Aggregate root: where business rules live
The aggregate root is the most central thing in the domain layer. Business rules do not live in Handlers and do not live in services — they live in the aggregate root's methods.
The aggregate root is the entry point of one business concept. It is the only object held and modified from the outside; the lifecycle of its internals (value objects, child entities) is under its protection.
In soda, an aggregate root extends 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 also implements DomainEvents (providing getDomainEvents() and clearDomainEvents()), keeping an internal list of events awaiting publication.
AbstractAggregateRoot itself extends AbstractDomainEvent, so aggregate root objects also carry eventId / eventType / occurredOn fields. This is a historical design; real business code never uses them. Ignore it.
The key constraint: only the aggregate root can addPendingEvent, and the method is protected — events can only originate inside the aggregate. A service layer that wants to fire an event means the aggregate design is wrong.
Repository: where data is read and written
The Repository is the boundary between the aggregate and storage: interfaces declared in the domain layer, implementations in the infrastructure layer. The domain layer only says “I need to be able to store a Producer” — it does not care whether MySQL is underneath.
Because CQRS splits reads and writes into two chains, there are two sets of repository interfaces — the write side follows commands, the read side follows queries, each evolving on its own.
Write side: extend WriteRepository
The framework ships a base interface in com.hibuka.soda.domain.repository with four fixed methods:
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;
}Your repository interface extends it, fixing T to the aggregate root:
public interface ProducerWriteRepository extends WriteRepository<Producer> {
}The implementation lives in the infrastructure layer, each method wired to a 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 work the same way; operate dispatches to the three above by operationType
}operate dispatches by operation type (create / update / delete); operationType usually comes from your own operation-type enum.
These four method names are not up for renaming — RepositoryEventAspect captures them through a pointcut:
execution(* *..*Repository+.save(..))
It matches two things:
- Method names: they must be
save/update/delete/operate. Rename topersist/insertand events silently stop going out. - Type names: they must match
*Repositoryor a subtype of it (+means subtypes included). So an interface namedProducerWriteRepositoryis enough — the implementation class can be named anything and still gets intercepted; conversely, if neither the interface nor the implementation containsRepository(aProducerStore, say) nothing gets intercepted.
Details: Event Bus & Aspect.
Read side: interfaces are yours
The read side has no base interface — pagination, joins, and search differ too much per project, and forced abstraction only gets in the way. Declare your own in the domain package, returning domain objects:
public interface ProducerReadRepository {
Producer getByUid(String uid) throws BaseException;
Page<Producer> list(ListProducerQuery query) throws BaseException;
}The read side has none of WriteRepository's four methods, so the aspect never intercepts it — reads produce no domain events.
Handler: who executes messages
Messages are just data; the ones who actually work are Handlers. The three message kinds map to three Handler kinds:
| Message | Handler interface | Method signature | How many per message |
|---|---|---|---|
Command | CommandHandler<C, R> | R handle(C command) | exactly 1 |
Query | QueryHandler<Q, R> | R handle(Q query) | exactly 1 |
DomainEvent | EventHandler<T> | void handle(T event) | 0 to n |
Handlers live in the application layer and call down into two things:
- Aggregate root — business rules. A
CommandHandlertranslates the command into aggregate root method calls; the rules themselves are not written in the Handler. - Repository — data access. A
CommandHandlercalls the write repository to persist; aQueryHandlercalls the read repository to fetch. AnEventHandlerdoes follow-up work (update the read model, send notifications, send new commands) and usually does not touch the write repository.
The shape of each of the three:
CommandHandler: validate parameters → call the aggregate root → save via repository → return the result. Exactly one Handler per command.QueryHandler: call the read repository → assemble data → return. Changes nothing.EventHandler: receives the “state has changed” notification and does the follow-up. 0 to n; nobody handling it is not an error.
Handlers are ordinary Spring beans — add @Component and they register automatically. The bus reads their generic types reflectively to index which message each handles; there is no registration code. The full comparison of the three Handler kinds, how the bus finds them, and what happens on failure: Handler.
The four layers: who depends on whom
soda borrows the dependency direction of clean architecture and cuts an application into four layers:
Each layer does one thing:
- adapter (entry layer): protocol translation only — turning HTTP requests into Commands / Queries and results back into responses. No business rules.
- application (application layer): orchestrates one use case — send commands, send queries, assemble return values. Handlers live here.
- domain (domain layer): the one home of business rules. Aggregate roots change state, domain events originate in aggregate roots, repository interfaces are declared here.
- infrastructure (infrastructure layer): technical details — implementing the repository interfaces declared by domain, managing DOs / Mappers, Redis, external systems.
Dependency arrows are one-way: adapter → application → domain ← infrastructure. The domain layer depends on no technology; it only declares “I want a repository that can store Producer”, with the concrete implementation in the infrastructure layer. Once this rule breaks, DDD degrades into “an anemic model with logic stuffed into services”.
The sample project buka-cloud1 has a few reverse references made to skip a conversion layer: CreateProducerCommandHandler returns the adapter's ProducerDTO directly, uses the infrastructure ProducerAssembler, and ProducerReadRepository takes the application's ListProducerQuery as a parameter. The diagram above shows the design intent. New projects should follow one-way dependencies; all of these spots can be untangled.
Why CQRS
CQRS (Command Query Responsibility Segregation) splits “changing state” and “reading state” into two independent chains. soda adopts it for three reasons:
1. The shapes differ. Writes need validation, idempotency, transactions, and events; reads need none. Force them into one method and you get the lowest common denominator — reads dragged down by pointless transactions and locks, writes polluted by pointless assembly logic.
2. The optimization points differ. The read and write sides usually end up on different storage (write database + read database + search + cache). CQRS makes “the read side runs on another implementation” a choice that breaks no business code.
3. Side effects are auditable. Every step on the write chain leaves a trace: commands, events, context, latency — all in the aspect logs. The read chain has none of that noise.
soda does a few concrete things on this road:
- Commands / queries / events each get their own bus, no cross-flavoring.
- Domain events are published after the transaction commits (the
afterCommitcallback ofRepositoryEventAspect) — no “already rolled back” events ever go out. - Context flows automatically:
requestIdand user identity travel from HTTP headers all the way into domain events, across threads and services without getting lost. - Recursion protection: synchronous calls are capped at depth 10, async events at 20 hops; when events trigger each other in a cycle, it stops the chain proactively.
If your system is pure CRUD, with no cross-aggregate invariants and no event-driven downstreams, CQRS's abstraction cost is real. soda's value shows up once business complexity arrives — but when it does, the boilerplate it saves is a lot.
Next steps
- How the directories are laid out and where dependencies point: Project Structure
- Run one write chain end to end: Write a Command
- Run one read chain end to end: Write a Query