Handler
Command, Query, and DomainEvent are all just objects stuffed with parameters; the ones who actually work are Handlers. 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 |
Class names follow the message: CreateProducerCommand → CreateProducerCommandHandler, GetProducerQuery → GetProducerQueryHandler, ProducerEvent → ProducerEventHandler.
What a Handler calls down into
Handlers live in the application layer. They implement no business themselves — they only orchestrate. Downstream are two things:
| Handler | Calls the aggregate root | Calls a Repository | Return value |
|---|---|---|---|
CommandHandler | yes — business rules live in the aggregate root | write repository (save / update / delete / operate) | yes |
QueryHandler | usually not | read repository | yes |
EventHandler | never modifies the original aggregate | only when updating the read model; if it sends no new commands it may call nothing | none |
In one line: the Handler executes, the aggregate root holds the rules, the Repository is the boundary of data access. Message → Handler → aggregate root / Repository — this chain is soda's skeleton.
What all three Handler kinds share
- All ordinary Spring beans. Adding
@Componentis the entire registration — nosubscribe, no registry, no XML. - Exactly one
handlemethod. The parameter is the message itself; failures uniformly throwBaseException. - Which message it handles is decided by generics. At startup the bus injects collections like
List<CommandHandler<?, ?>>, reads the message type out of the generics withGenericTypeResolver.resolveTypeArguments(...), and builds an index. - They live in the application layer:
application/cqrs/handler/{command,query,event}. - All intercepted by the
CqrsAroundHandleraspect. The aspect puts context into the ThreadLocal, records latency and in/out params, and cleans up infinally.
@Component
@RequiredArgsConstructor
public class CreateProducerCommandHandler
implements CommandHandler<CreateProducerCommand, ProducerDTO> { // ← generics decide "which command I handle"
private final ProducerWriteRepository producerWriteRepository;
@Override
public ProducerDTO handle(CreateProducerCommand command) throws BaseException {
...
}
}How the bus finds a Handler
Command ──→ CommandBus ──→ Map<Class, CommandHandler> by exact class, 1
Query ──→ QueryBus ──→ Map<Class, QueryHandler> by exact class, 1
DomainEvent ──→ EventBus ──→ Map<Class, List<EventHandler>> self + superclass + interfaces, 0..nCommands and queries are looked up by the exact class of the message — register a Handler for GetProducerQuery, send a subclass of it, and it will not be found. Events differ: starting from the message class they walk up the superclass chain and implemented interfaces, so a Handler registered on DomainEvent receives every event.
CommandHandler: changing state
CommandHandler is the executor of write operations — a command gets exactly one:
@Component
@RequiredArgsConstructor
public class CreateProducerCommandHandler implements CommandHandler<CreateProducerCommand, ProducerDTO> {
private final ProducerWriteRepository producerWriteRepository;
@Override
public ProducerDTO handle(CreateProducerCommand command) throws BaseException {
commandValidate(command); // 1. validate
Producer domain = ProducerAssembler.toDomain(command); // 2. assemble the aggregate root
domain.create(command.getName()); // 3. change state, suspend the event
producerWriteRepository.save(domain); // 4. persist via repository (the aspect publishes events here)
return ProducerWebAssembler.toDTO(domain);
}
private void commandValidate(CreateProducerCommand command) throws BaseException {
ParamChecker.notNull(command, "command");
ParamChecker.notNull(command.getName(), "name");
}
}- Has a return value. The
RinCommandHandler<C, R>is exactlyhandle's return type. - Failures propagate up. A
BaseExceptiongoes all the way up; if there is an outer transaction (your own@Transactional), it rolls back, and the suspended events are not taken by the aspect. - Business rules are not written here. The Handler only translates the message into aggregate root calls; the rules belong to the aggregate root.
- Inject the interface, not the implementation.
ProducerWriteRepository, notProducerWriteRepositoryImpl.
Two Handlers registered for the same command will not raise an error — the later one silently overwrites the earlier. The startup log only shows two lines of Registered handler for command: ...; you cannot tell one got overwritten. One command type should have one Handler.
QueryHandler: reading state
Nearly the same shape as a CommandHandler; the difference is what it does:
@Component
@RequiredArgsConstructor
public class GetProducerQueryHandler implements QueryHandler<GetProducerQuery, Producer> {
private final ProducerReadRepository producerReadRepository;
@Override
public Producer handle(GetProducerQuery query) throws BaseException {
queryValidate(query);
return producerReadRepository.getByUid(query.getUid());
}
}- One query gets one Handler, again last registration wins.
- Read-only. Do not call the write repository from here — the moment the read chain triggers a write, CQRS degrades back into an ordinary layered architecture.
- Returning either a domain object or a DTO is fine, depending on which layer does the assembly. The two examples in Write a Query each use one.
EventHandler: after state has changed
An EventHandler does not "respond" — it does "follow-up". State has already been changed elsewhere; it receives the notification and handles what comes next:
@Component
@RequiredArgsConstructor
public class ProducerEventHandler implements EventHandler<ProducerEvent> {
@Override
public void handle(ProducerEvent event) throws BaseException {
ParamChecker.notNull(event.getProducer(), "producer");
// send messages, send notifications, update the read model...
}
}- 0 to n; nobody handling it is not an error. Publish with no EventHandler receiving and it is silently skipped.
- No return value.
void handle(T event)— results can only show up as side effects. - Do not depend on execution order. Multiple Handlers for the same event are invoked in Bean injection order; the order is not part of the interface contract.
- If one Handler on a class throws, the remaining Handlers for the same event do not run.
SimpleEventBuscalls them sequentially with no per-handler try/catch. - Failures do not roll back. Events are published after the transaction commits;
RepositoryEventAspectcan only log the exception. The Redis bus is the exception — it retries permax-retries, and final failures go to the dead-letter stream. - You can branch on
eventType, or split into multiple event classes. The latter is clearer; see Domain Events. - You may send new commands from here.
busFacade.sendCommand(...)continues the context — mind the recursion protection.
Common Handler pitfalls
| Symptom | Cause |
|---|---|
No handler registered for command: ... | The Handler lacks @Component, or is outside the component scan |
| Command goes out and no Handler is found, though the class name is right | Generics were written as a base class (e.g. CommandHandler<BaseCommand<X>, X>), so the index was built on the base class |
| EventHandler never fires | The event class's generics are wrong; or bus-type is set to spring, which creates no EventBus bean at all |
| Changed a Handler but behavior didn't change | A second Handler for the same command/query is overwriting it |
No Registered handler log at startup | @Component didn't take effect, or the package is not on the scan path |
Names end with Handler, but they are not message Handlers
A few other classes in a generated project end with Handler; they have nothing to do with the three message Handler kinds above:
| Class | What it is | What it does | Where it comes from |
|---|---|---|---|
ControllerAroundHandler | Web layer aspect | Reads Soda-Request-Id / user name from request headers, fills them into the command, writes MDC | the generated project's adapter layer |
CqrsAroundHandler | CQRS aspect | Intercepts the handle of all three Handler kinds and CommandBus.send; moves context, logs, prevents recursion | the soda-core framework |
GlobalExceptionHandler | @RestControllerAdvice | Maps BaseException to Result.fail(code, message) as the fallback | the generated project's adapter layer |
They handle no commands, queries, or events — they only do cross-cutting work. When you see a Handler suffix, don't rush to file it under the three message Handler kinds.
Next steps
- How to write a CommandHandler through a complete chain: Write a Command
- QueryHandler and the read-side repository: Write a Query
- When EventHandlers fire and
eventType: Domain Events