soda docs v17.0.1

Context Propagation

Every multi-service system hits this problem: a request fires in service A, the event lands in service B — how do the logs still show which original request it belonged to and who triggered it?

soda's answer: context travels with the payload, not with the thread.


Context fields

BaseCommand / BaseQuery / AbstractDomainEvent all carry this set of fields:

FieldTypeMeaning
requestIdStringidentifies one request, carried through the whole chain
callerUidLong / Stringthe calling user's ID
userNameStringuser name
authoritiesStringauthorities string
jtiStringthe JWT's jti (token unique ID)
jwtStringthe raw JWT
tenantIdStringtenant ID
userInfoStringextended user info
extensionStringextension field
hopCountIntegerasync hop counter, used for recursion protection

Events additionally carry eventId, eventType, occurredOn.

Note

callerUid is Long in BaseCommand but String in AbstractDomainEvent — events get serialized across services, and a string is safer.


Three carriers

The context of one call exists in three places at once inside the framework:

text
① fields on the command/event object      ← what survives threads and services
② DomainEventContext (ThreadLocal)        ← handy within one thread
③ MDC                                     ← for logs

① Payload-carried — the copy that crosses threads and services

DomainEventContext is a ThreadLocal — it's gone the moment you switch threads. So the truly reliable way to propagate context is to put it into the message payload itself.

An event's full trip from creation to consumption:

text
event created (aggregate root addPendingEvent)
   │  ← injectContext() writes ThreadLocal values into event fields
   ▼
event object { eventId, eventType, requestId, userName, authorities, ... }
   │
   ▼  serialize (Redis bus: JSON)
   ▼
cross-service transport
   │
   ▼  deserialize
   ▼
event object (fields intact)
   │  ← CqrsAroundHandler restores ThreadLocal from the event
   ▼
inside EventHandler, DomainEventContext.getRequestId() works again

This is why events must be serializable — the context travels with the payload. Event classes need a no-arg constructor and getter/setter fields.

② ThreadLocal — the copy for within one thread

java
DomainEventContext.setRequestId("req-123");
DomainEventContext.getRequestId();      // "req-123"
DomainEventContext.setUserName("Zhang San");
DomainEventContext.getUserName();
DomainEventContext.setJti("...");
DomainEventContext.getJti();
DomainEventContext.setAuthorities("admin,user");
DomainEventContext.getAuthorities();
DomainEventContext.setCallerUid("1001");
DomainEventContext.getCallerUid();
DomainEventContext.setHopCount(1);
DomainEventContext.getHopCount();
DomainEventContext.clear();             // ← cleanup

DomainEventContext.clear() must be called in a finally. Thread pools reuse threads — skip the cleanup and the previous request's user identity leaks into the next request. This is not a theoretical problem; it's a real category of production incident.

The framework already does the cleanup for you in CqrsAroundHandler.

③ MDC — for logging

MDC values appear automatically in the log format:

java
MDC.put("requestId", requestId);
MDC.put("username", userName);

Combined with logback's %X{requestId} you get lines like:

text
requestId:8f3a...,method:CreateProducerCommandHandler-handle, ...,username:zhangsan

The four injection points

1. HTTP entry — you write the aspect

The framework does not read HTTP headers for you (that would bind it to a web framework). You write an aspect that reads headers and fills the command:

java
if (args[0] instanceof BaseRequest) {
    ((BaseRequest) args[0]).setRequestInfo(requestId, userName);
}

2. Handler aspect — done automatically

When a command enters a CommandHandler, the framework moves the command's context into ThreadLocal:

java
DomainEventContext.setRequestId(cmd.getRequestId());
DomainEventContext.setUserName(cmd.getUserName());
DomainEventContext.setJti(cmd.getJti());
DomainEventContext.setAuthorities(cmd.getAuthorities());

When an event enters an EventHandler, the framework restores from the event:

java
DomainEventContext.setRequestId(event.getRequestId());
DomainEventContext.setUserName(event.getUserName());
// ...

3. When the aggregate root suspends an event — done automatically

java
protected void addPendingEvent(AbstractDomainEvent event) {
    if (event != null) {
        injectContext(event);   // ← ThreadLocal → event fields
        domainEvents.add(event);
    }
}

injectContext only fills fields that are not yet filled (if (event.getRequestId() == null)), so you can override manually:

java
ProducerEvent event = new ProducerEvent("CREATE_PRODUCER", this);
event.setRequestId("custom-id");   // ← set manually, injectContext won't overwrite
addPendingEvent(event);

4. Repository aspect — framework top-up

On save, the framework top-ups the context once more (mainly hopCount):

java
Integer hopCount = DomainEventContext.getHopCount();
if (hopCount != null) {
    e.setHopCount(hopCount);
}

Context backfill

One spot that's easy to miss: the command bus aspect backfills context.

java
// 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());
        // ...
    }
}

This solves a real problem: when you send a new command from an event consumer, the new command is empty, but the current thread's ThreadLocal still holds the context the upstream event carried in. Backfill reconnects the new command to the chain automatically — the requestId never breaks.


Does it break across threads?

It can, but it's controllable.

DomainEventContext is a ThreadLocal — switch to a new thread and it's gone. So:

  • Async tasks must carry it manually: DomainEventContext.setRequestId(old.getRequestId())
  • Async commands (sendAsyncCommand) go through the framework's aspect — the command object carries its own context fields, so after a thread hop the context is still on the payload and events can pick it up as usual
Note

BusFacade.sendAsyncCommand captures CommandContext (the legacy context carrier) and restores it in the async thread, cleaning up in finally when done. The DomainEventContext mechanism is the newer one, handled by aspects.

Tip

The rule of thumb is simple: context that travels with a “message” survives; context that travels with a “thread” breaks. Every cross-boundary carrier in the framework (commands, queries, events) carries context fields precisely for this reason.


Context and event idempotency

With cross-service delivery, the same event may be delivered more than once (network retries, consumer restarts). soda-event-redis offers idempotency support:

yaml
soda:
  event:
    redis:
      stream:
        idempotency:
          enabled: true
          redis-key-prefix: soda-events-idempotency
          expire-time: 86400

The idempotency key is the event ID — so event IDs must be stable, never regenerated on each attempt. AbstractDomainEvent's no-arg constructor generates eventId with a snowflake algorithm, which guarantees the ID stays the same when an event is redelivered.


Debugging lost context

Check in this order:

SymptomWhat to check
requestId empty in logsdid the Controller aspect stuff Soda-Request-Id into the command?
command has requestId but the event doesn'twas the event hand-new-ed without going through addPendingEvent?
event has it but the consumer can't read itis the event class missing a no-arg constructor / are fields not serialized?
consumer sees another user's identityThreadLocal not cleaned — did you spin up threads bypassing the framework aspects?
new command's requestId brokendid you hand-new the command, bypassing backfill?

Next steps