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:
| Field | Type | Meaning |
|---|---|---|
requestId | String | identifies one request, carried through the whole chain |
callerUid | Long / String | the calling user's ID |
userName | String | user name |
authorities | String | authorities string |
jti | String | the JWT's jti (token unique ID) |
jwt | String | the raw JWT |
tenantId | String | tenant ID |
userInfo | String | extended user info |
extension | String | extension field |
hopCount | Integer | async hop counter, used for recursion protection |
Events additionally carry eventId, eventType, occurredOn.
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:
① 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:
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 againThis 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
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(); // ← cleanupDomainEventContext.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:
MDC.put("requestId", requestId);
MDC.put("username", userName);Combined with logback's %X{requestId} you get lines like:
requestId:8f3a...,method:CreateProducerCommandHandler-handle, ...,username:zhangsanThe 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:
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:
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:
DomainEventContext.setRequestId(event.getRequestId());
DomainEventContext.setUserName(event.getUserName());
// ...3. When the aggregate root suspends an event — done automatically
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:
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):
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.
// 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
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.
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:
soda:
event:
redis:
stream:
idempotency:
enabled: true
redis-key-prefix: soda-events-idempotency
expire-time: 86400The 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:
| Symptom | What to check |
|---|---|
| requestId empty in logs | did the Controller aspect stuff Soda-Request-Id into the command? |
| command has requestId but the event doesn't | was the event hand-new-ed without going through addPendingEvent? |
| event has it but the consumer can't read it | is the event class missing a no-arg constructor / are fields not serialized? |
| consumer sees another user's identity | ThreadLocal not cleaned — did you spin up threads bypassing the framework aspects? |
| new command's requestId broken | did you hand-new the command, bypassing backfill? |
Next steps
- How async hops avoid infinite loops → Recursion Protection
- The full context chain → Request Lifecycle