Recursion Protection
Event-driven architectures have a classic disease: A's event triggers B, and B's event triggers A. It looks reasonable, but running it is an infinite loop that burns threads and CPU.
soda handles this with two layers of protection. The difference: one guards the synchronous call stack, the other guards the asynchronous message chain.
Why two layers
| Synchronous recursion | Asynchronous recursion | |
|---|---|---|
| Shape | A's CommandHandler directly sendCommands B, and B calls back into A | A publishes event → B consumes → B publishes event → A consumes → … |
| Traits | same thread, the stack grows deeper | across threads or even services; the stack is shallow but messages loop forever |
| Detection | stack depth | a hop counter carried on the message |
Guarding only sync is not enough — the async layer is the truly dangerous one (it can cross services, invisible to stack depth). Guarding only async isn't enough either — the sync layer will actually overflow the stack.
Layer one: synchronous depth limit
BusFacade tracks synchronous call depth with two ThreadLocals:
private static final int MAX_RECURSION_DEPTH = 10;
private static final ThreadLocal<Integer> recursionDepth =
ThreadLocal.withInitial(() -> 0);
private static final ThreadLocal<StringBuilder> recursionTrace =
ThreadLocal.withInitial(StringBuilder::new);All three of sendCommand / sendAsyncCommand / sendTransactCommand check on entry:
int depth = recursionDepth.get();
if (depth > MAX_RECURSION_DEPTH) {
String trace = recursionTrace.get().toString();
logger.error("[BusFacade] Command recursion too deep! Trace: {}", trace);
throw new IllegalStateException("Command recursion too deep! Trace: " + trace);
}
recursionDepth.set(depth + 1);
recursionTrace.get().append("->").append(command.getClass().getSimpleName());
try {
return commandBus.send(command);
} finally {
recursionDepth.set(depth); // unwind
StringBuilder trace = recursionTrace.get();
int idx = trace.lastIndexOf("->");
if (idx >= 0) trace.delete(idx, trace.length()); // pop the call path too
}Three details make the error actually useful:
recursionTracerecords the full call path, e.g.->CreateOrderCommand->DeductStockCommand->CreateOrderCommand. The error shows exactly where it looped back.- Unwinding in
finally— depth correctly rolls back even when an exception is thrown, so later requests on the same thread aren't polluted. The call path pops in sync, so the trace always reflects the current call stack, not accumulated history. - The check is
depth > 10— nesting 11 levels deep still passes; the 12th is blocked.
An error message carrying the call chain is far more useful than a bare "too deep". When debugging, look at the tail of the trace first — that's where the loop starts.
sendQuery has no recursion protection. Queries go through queryBus.send(query), skipping both the depth check and the trace. Queries are supposed to be side-effect-free and not trigger each other; if your query sends commands, you're bypassing the entire protection mechanism — don't design it that way.
Layer two: asynchronous hopCount
The async layer relies on hopCount carried on the message.
How the counter grows
The command bus aspect adds +1 per command sent:
Integer hopCount = DomainEventContext.getHopCount();
int currentHop = (hopCount != null) ? hopCount : 0;
if (currentHop > 20) {
logger.error("[CqrsAroundHandler] Async recursion too deep! hopCount={}, command={}",
currentHop, cmd.getClass().getSimpleName());
throw new IllegalStateException("Async recursion too deep! hopCount=" + currentHop);
}
cmd.setHopCount(currentHop + 1);How the counter travels
The key is that hopCount is a field on BaseCommand and AbstractDomainEvent — it travels with the message payload.
command (hopCount=0)
│ command bus aspect: check → +1 → hopCount=1
▼
CommandHandler runs → aggregate root suspends event
│ injectContext + repository aspect: event inherits hopCount=1
▼
event (hopCount=1) → cross-service delivery
│ on consumption the aspect restores hopCount=1 from the event
▼
consumer sends a new command
│ command bus aspect: reads hopCount=1 → +1 → hopCount=2
▼
command (hopCount=2) → ...So it doesn't break across services either — the hop count is written into the message, and when service B receives it, it's right there on the event.
What happens at the limit
throw new IllegalStateException("Async recursion too deep! hopCount=" + currentHop);Note the check is currentHop > 20, so in practice 21 hops run and the 22nd is blocked.
How the two layers work together
┌─────────────────────────────────────────────────┐
│ A's CommandHandler │
│ ├─ sendCommand(B) ← sync depth +1 (1/10) │
│ │ └─ B's CommandHandler │
│ │ ├─ sendCommand(A) ← sync depth +1 │
│ │ │ └─ A's CommandHandler │
│ │ │ └─ sendCommand(B) ... │
│ │ │ ✗ depth 10 → throw │
│ │ └─ (or) publish event │
│ │ └─ event hopCount=1 cross-svc │
│ │ └─ B consumes → command │
│ │ hopCount=2 ... │
│ │ ✗ >20 → throw │
└─────────────────────────────────────────────────┘| Sync depth | Async hopCount | |
|---|---|---|
| Limit | 10 (check depth > 10) | 20 (check currentHop > 20) |
| Carrier | ThreadLocal (BusFacade) | message field (BaseCommand / AbstractDomainEvent) |
| Scope | same thread, same call stack | across threads, across services |
| Covered methods | sendCommand / sendAsyncCommand / sendTransactCommand | every command through the command bus |
| Error message | Command recursion too deep! Trace: ->A->B->A | Async recursion too deep! hopCount=21 |
| Trigger moment | on call entry | when sending a command |
Designing so you never hit it
Recursion protection is a safety net, not a design. Hitting it means your business logic has a cycle. Here are common legal and illegal patterns.
Legal: one-way chain
create order → publish order event → deduct stock (event consumer)A directed acyclic chain — no loop possible.
Legal: convergent with a condition
order event → review approved → publish review event → trigger downstream
review event → ignore if the state is already terminalThe key is the consumer must have a terminating condition. Check the aggregate root's current state when handling the event, and return early if it doesn't qualify.
Illegal: mutual triggering
order event → triggers order modification → publishes order event again → triggers order modification → …At the framework level this is indistinguishable from “legitimate consecutive changes”. soda can only guarantee it stops — it cannot judge whether stopping is correct.
If your business genuinely needs “one change triggers several”, converge with an event version number or a state machine — don't loosen the hopCount limit. Raising the limit only turns “error after 20 hops” into “OOM after maxing out CPU”.
Tuning
Both limits are compile-time constants — they cannot be changed via configuration:
private static final int MAX_RECURSION_DEPTH = 10; // BusFacade
if (currentHop > 20) { ... } // CqrsAroundHandlerChanging them means editing framework source. In practice you shouldn't need to — if you truly need more than 10 levels of sync nesting, the problem is usually the design, not the limit.
Debugging
| Symptom | Cause |
|---|---|
Command recursion too deep! Trace: ->A->B->A->B | sync recursion — the trace tail is where the loop starts |
Async recursion too deep! hopCount=21 | async loop — events bouncing between services |
| Fires intermittently, other environments fine | check whether the consumer has a terminating condition; does it depend on external state? |
When debugging async recursion, start by joining the logs of both services with requestId — context propagation guarantees events on the same chain share one requestId, which is the most effective way to locate a loop. See Context Propagation.