Request Lifecycle
The value of a framework is not a handful of base classes — it is collecting scattered cross-cutting logic into one deterministic chain. This page breaks that chain down segment by segment.
We'll follow a single busFacade.sendCommand(command) all the way through.
The panorama
HTTP request
│
▼
[1] Controller your code
│ assembles the command, stuffs identity from headers into it
▼
[2] BusFacade.sendCommand(cmd) framework entry point
│
▼
[3] CqrsAroundHandler @Around ── CommandBus.send(..)
│ · context backfill (if the command carries no requestId, fill it from ThreadLocal)
│ · hopCount check and +1
│ · calls the real bus
▼
[4] CommandBusImpl.send(cmd) the bus finds the CommandHandler by type
│
▼
[5] CqrsAroundHandler @Around ── CommandHandler.handle(..)
│ · writes the command's context into DomainEventContext (ThreadLocal)
│ · runs your CommandHandler
│ · cleans up the ThreadLocal in finally
▼
[6] CommandHandler.handle(cmd) your code
│ · validate → aggregate root changes state → addPendingEvent
▼
[7] RepositoryEventAspect @Around ── *Repository+.save(..)
│ · saves the aggregate root
│ · takes the suspended domain events, injects context
│ · transaction present → publish in afterCommit; none → publish immediately
▼
[8] EventBus.publish(event) ── Simple / Redis
│
▼
[9] CqrsAroundHandler @Around ── EventHandler.handle(..)
│ · restores context from the event (the event carried requestId along)
▼
[10] EventHandler.handle(event) your codeDetails follow, segment by segment.
[1] Controller — the first stop for context
The framework does not force any Web-layer base class on you. The context fields on BaseCommand need someone to fill them, and the most common way is an aspect:
@Aspect
@Component
public class ControllerAroundHandler {
@Around("execution(* ..*Controller..*(..))")
public Object around(ProceedingJoinPoint jp) throws Throwable {
HttpServletRequest request =
((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes())
.getRequest();
String requestId = request.getHeader(BaseConstant.SODA_REQUEST_ID);
if (StringUtils.isBlank(requestId)) {
requestId = request.getHeader(BaseConstant.X_REQUEST_ID);
}
if (StringUtils.isBlank(requestId)) {
requestId = UUID.randomUUID().toString();
}
String userName = request.getHeader(BaseConstant.SODA_USER_NAME);
// write into MDC, logs pick it up automatically
MDC.put("requestId", requestId);
MDC.put("username", userName);
Object[] args = jp.getArgs();
if (args.length > 0 && args[0] instanceof BaseRequest) {
((BaseRequest) args[0]).setRequestInfo(requestId, userName);
}
return jp.proceed();
}
}BaseConstant defines the headers the framework recognizes:
| Constant | Header |
|---|---|
SODA_REQUEST_ID | Soda-Request-Id |
SODA_USER_NAME | Soda-User-Name |
X_REQUEST_ID | X-Request-Id |
X_UID | X-UID |
X_USER_NAME | X-User-Name |
X_AUTHORITIES | X-Authorities |
X_JTI | X-Jti |
X_CALLER_UID | X-Caller-Uid |
X_TENANT_ID | X-Tenant-Id |
BaseConstant also defines SCADA = "scada" — a reserved identifier constant for framework internals.
[3] Command bus aspect — context backfill and hopCount
CqrsAroundHandler intercepts CommandBus.send(..):
@Around("execution(* com.hibuka.soda.cqrs.command.CommandBus+.send(..))")
public Object handleEventBus(ProceedingJoinPoint joinPoint) throws Throwable {
Object[] args = joinPoint.getArgs();
if (args.length > 0 && args[0] instanceof BaseCommand) {
BaseCommand cmd = (BaseCommand) args[0];
// context backfill: if the command carries no requestId, take it from ThreadLocal
if (cmd.getRequestId() == null) {
String requestId = DomainEventContext.getRequestId();
if (requestId != null) {
cmd.setRequestId(requestId);
cmd.setUserName(DomainEventContext.getUserName());
// ... jti / authorities / callerUid
}
}
// recursion protection
Integer hopCount = DomainEventContext.getHopCount();
int currentHop = (hopCount != null) ? hopCount : 0;
if (currentHop > 20) {
throw new IllegalStateException("Async recursion too deep! hopCount=" + currentHop);
}
cmd.setHopCount(currentHop + 1);
}
return joinPoint.proceed();
}Context backfill solves this scenario: you send a new command from inside an event consumer. The new command has no requestId, but the current thread's ThreadLocal still holds the requestId the last event carried in. Backfill reconnects it to the chain automatically.
hopCount is async recursion protection — see Recursion Protection.
[5] Handler aspect — context injection and cleanup
CqrsAroundHandler also intercepts every Handler:
@Around("execution(* com.hibuka.soda.cqrs.command.CommandHandler+.handle(..)) || " +
"execution(* com.hibuka.soda.cqrs.query.QueryHandler+.handle(..)) || " +
"execution(* com.hibuka.soda.cqrs.event.EventHandler+.handle(..))")
public Object handleExecutor(ProceedingJoinPoint point) throws Throwable {
Object firstArg = point.getArgs()[0];
if (firstArg instanceof BaseCommand) {
BaseCommand cmd = (BaseCommand) firstArg;
DomainEventContext.setRequestId(cmd.getRequestId());
DomainEventContext.setUserName(cmd.getUserName());
DomainEventContext.setJti(cmd.getJti());
DomainEventContext.setAuthorities(cmd.getAuthorities());
// ...
} else if (firstArg instanceof AbstractDomainEvent) {
AbstractDomainEvent event = (AbstractDomainEvent) firstArg;
DomainEventContext.setRequestId(event.getRequestId());
// restore context from the event
}
try {
return point.proceed();
} finally {
if (firstArg instanceof BaseCommand || firstArg instanceof AbstractDomainEvent) {
DomainEventContext.clear();
}
}
}Two jobs:
- Injection — the context from the command/event is moved into ThreadLocal so the aggregate root and repository can grab it at any time.
- Cleanup —
DomainEventContext.clear()infinally. Thread pools reuse threads; skip the cleanup and the previous request's identity leaks into the next request. This is mandatory, and the framework already does it for you.
[6] CommandHandler — domain logic
Your code. The only requirement: state changes must happen inside the aggregate root.
Producer domain = new Producer();
domain.create(command.getName(), command.getContact()); // change state + suspend eventAbstractAggregateRoot.addPendingEvent injects the context the moment it suspends the event:
protected void addPendingEvent(AbstractDomainEvent event) {
if (event != null) {
injectContext(event); // ← requestId / userName are written into the event right here
domainEvents.add(event);
}
}So even if the event is never published, it already carries the correct context.
[7] Repository aspect — where events are triggered
@Around("execution(* *..*Repository+.save(..)) || " +
"execution(* *..*Repository+.update(..)) || " +
"execution(* *..*Repository+.delete(..)) || " +
"execution(* *..*Repository+.operate(..))")
public Object aroundSave(ProceedingJoinPoint joinPoint) throws Throwable {
Object result = joinPoint.proceed(); // ← really save first
Object[] args = joinPoint.getArgs();
if (args.length > 0 && args[0] instanceof AbstractAggregateRoot) {
AbstractAggregateRoot agg = (AbstractAggregateRoot) args[0];
List<AbstractDomainEvent> events = List.copyOf(agg.getDomainEvents());
if (!events.isEmpty()) {
agg.clearDomainEvents();
enrichEventsWithContext(events);
if (TransactionSynchronizationManager.isSynchronizationActive()) {
// transaction present → publish after the transaction commits
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
publishEvents(events);
}
});
} else {
publishEvents(events); // no transaction → publish immediately
}
}
}
return result;
}Three key points:
- Events go out only after a successful save. If the save throws, the events are never published — avoiding the classic “state not stored but event sent” inconsistency.
- With a transaction, events are published in
afterCommit. If the transaction rolls back, no events go out. clearDomainEvents()is called before publishing. Events are sent exactly once; saving the same aggregate root again does not re-send them.
The pointcut's type pattern is *Repository+: it matches “types whose name ends with Repository” and their subtypes. So as long as you implement an interface like ProducerWriteRepository, the implementation class can be named anything and still gets intercepted. Conversely, if neither the interface nor the implementation class name contains Repository, the aspect doesn't see them — the save succeeds, but events are silently lost, no error at all.
[8] Event bus
EventBus has two implementations, selected by soda.event.bus-type:
| Implementation | Delivery scope | Traits |
|---|---|---|
SimpleEventBus | in-process | the default, independent of Spring's event mechanism |
RedisStreamEventBus | cross-service | Redis Streams, with consumer groups, idempotency, and a dead-letter stream |
The soda-event-spring module was removed in 17.0.1 — there is no SpringEventBus. Write bus-type as only simple or redis.
Besides the repository aspect, CqrsAroundHandler has a second publish path: if a CommandHandler's return value implements DomainEvents (returning the aggregate root directly, say), or the return value is itself a DomainEvent, the aspect publishes any events not yet taken after send returns. The normal style is to return a DTO and let the repository aspect publish events uniformly — otherwise the same event may go out twice.
When bus-type=redis, CqrsAroundHandler skips the local immediate publish and hands the publishing to the repository aspect — so the same event doesn't end up both in Redis and dispatched locally.
[9] Event consumer — restoring context
When an EventHandler is invoked, the aspect restores context from the event itself:
} else if (firstArg instanceof AbstractDomainEvent) {
AbstractDomainEvent event = (AbstractDomainEvent) firstArg;
DomainEventContext.setRequestId(event.getRequestId());
DomainEventContext.setUserName(event.getUserName());
DomainEventContext.setJti(event.getJti());
DomainEventContext.setAuthorities(event.getAuthorities());
DomainEventContext.setCallerUid(event.getCallerUid());
DomainEventContext.setHopCount(event.getHopCount());
}This closes the loop of full-chain context propagation: taken from HTTP headers on the way in, written into command → event along the way, carried through serialization across services, and restored back into ThreadLocal at consumption.
The whole journey in one table
| Step | Written by | Context lives in | On error |
|---|---|---|---|
| Controller | your aspect | command object fields | normal return |
| Command bus | framework aspect | backfilled into the command | IllegalStateException (recursion limit) |
| CommandHandler | framework aspect | ThreadLocal | thrown to the aspect |
| Aggregate root | framework | injected into the event | — |
| Repository | framework aspect | event fields | save failure → no events published |
| Event bus | framework | event fields | logged, main flow unaffected |
| Event consumer | framework aspect | restored into ThreadLocal | logged |
Next steps
- Why context survives threads and services → Context Propagation
- How recursion gets cut off → Recursion Protection
- Aspect implementation details → Event Bus & Aspect