soda docs v17.0.1

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 recursionAsynchronous recursion
ShapeA's CommandHandler directly sendCommands B, and B calls back into AA publishes event → B consumes → B publishes event → A consumes → …
Traitssame thread, the stack grows deeperacross threads or even services; the stack is shallow but messages loop forever
Detectionstack deptha 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:

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

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

  • recursionTrace records 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.
Tip

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.

Warning

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:

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

text
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

java
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

text
┌─────────────────────────────────────────────────┐
│  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 depthAsync hopCount
Limit10 (check depth > 10)20 (check currentHop > 20)
CarrierThreadLocal (BusFacade)message field (BaseCommand / AbstractDomainEvent)
Scopesame thread, same call stackacross threads, across services
Covered methodssendCommand / sendAsyncCommand / sendTransactCommandevery command through the command bus
Error messageCommand recursion too deep! Trace: ->A->B->AAsync recursion too deep! hopCount=21
Trigger momenton call entrywhen 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.

text
create order → publish order event → deduct stock (event consumer)

A directed acyclic chain — no loop possible.

text
order event → review approved → publish review event → trigger downstream
review event → ignore if the state is already terminal

The 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

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

Warning

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:

java
private static final int MAX_RECURSION_DEPTH = 10;   // BusFacade
if (currentHop > 20) { ... }                        // CqrsAroundHandler

Changing 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

SymptomCause
Command recursion too deep! Trace: ->A->B->A->Bsync recursion — the trace tail is where the loop starts
Async recursion too deep! hopCount=21async loop — events bouncing between services
Fires intermittently, other environments finecheck 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.