soda docs v17.0.1

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

text
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 code

Details 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:

java
@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:

ConstantHeader
SODA_REQUEST_IDSoda-Request-Id
SODA_USER_NAMESoda-User-Name
X_REQUEST_IDX-Request-Id
X_UIDX-UID
X_USER_NAMEX-User-Name
X_AUTHORITIESX-Authorities
X_JTIX-Jti
X_CALLER_UIDX-Caller-Uid
X_TENANT_IDX-Tenant-Id
Note

BaseConstant also defines SCADA = "scada" — a reserved identifier constant for framework internals.


[3] Command bus aspect — context backfill and hopCount

CqrsAroundHandler intercepts CommandBus.send(..):

java
@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:

java
@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:

  1. Injection — the context from the command/event is moved into ThreadLocal so the aggregate root and repository can grab it at any time.
  2. Cleanup — DomainEventContext.clear() in finally. 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.

java
Producer domain = new Producer();
domain.create(command.getName(), command.getContact());   // change state + suspend event

AbstractAggregateRoot.addPendingEvent injects the context the moment it suspends the event:

java
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

java
@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.
Warning

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:

ImplementationDelivery scopeTraits
SimpleEventBusin-processthe default, independent of Spring's event mechanism
RedisStreamEventBuscross-serviceRedis Streams, with consumer groups, idempotency, and a dead-letter stream
Note

The soda-event-spring module was removed in 17.0.1 — there is no SpringEventBus. Write bus-type as only simple or redis.

Warning

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.

Note

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:

java
} 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

StepWritten byContext lives inOn error
Controlleryour aspectcommand object fieldsnormal return
Command busframework aspectbackfilled into the commandIllegalStateException (recursion limit)
CommandHandlerframework aspectThreadLocalthrown to the aspect
Aggregate rootframeworkinjected into the event—
Repositoryframework aspectevent fieldssave failure → no events published
Event busframeworkevent fieldslogged, main flow unaffected
Event consumerframework aspectrestored into ThreadLocallogged

Next steps