Event Bus & Aspect
Nearly all of soda's automation comes from two @Aspect classes:
| Aspect | Class | Intercepts | Does |
|---|---|---|---|
| CQRS aspect | CqrsAroundHandler | the handle methods of Command / Query / Event, plus CommandBus.send | context propagation, recursion protection, chain logging |
| Repository aspect | RepositoryEventAspect | save / update / delete / operate on types named *Repository and their subtypes | takes the domain events, publishes them after the transaction commits |
Understand these two aspects and you understand most of soda's runtime behavior.
CqrsAroundHandler
com.hibuka.soda.bus.interceptor.CqrsAroundHandler. It's an @Aspect class, registered by SpringBusAutoConfiguration:
@Bean
@ConditionalOnMissingBean
public CqrsAroundHandler cqrsAroundHandler(EventBus eventBus, EventProperties eventProperties) {
return new CqrsAroundHandler(eventBus, eventProperties);
}It has two @Around pointcuts.
Pointcut one: intercept every Handler
execution(* com.hibuka.soda.cqrs.command.CommandHandler+.handle(..)) ||
execution(* com.hibuka.soda.cqrs.query.QueryHandler+.handle(..)) ||
execution(* com.hibuka.soda.cqrs.event.EventHandler+.handle(..))+ is an AspectJ type pattern meaning “including subtypes”. So your CreateProducerCommandHandler implements CommandHandler<...> gets intercepted.
This pointcut does three things.
1. Establish context. Based on the type of the first argument, write context into DomainEventContext (ThreadLocal):
if (firstArg instanceof BaseCommand) {
BaseCommand cmd = (BaseCommand) firstArg;
requestId = cmd.getRequestId();
username = cmd.getUserName();
DomainEventContext.setRequestId(requestId);
DomainEventContext.setUserName(username);
DomainEventContext.setJti(cmd.getJti());
DomainEventContext.setAuthorities(cmd.getAuthorities());
if (cmd.getCallerUid() != null) {
DomainEventContext.setCallerUid(String.valueOf(cmd.getCallerUid()));
}
if (cmd.getHopCount() != null) {
DomainEventContext.setHopCount(cmd.getHopCount());
}
}The BaseQuery branch only reads requestId and userName and writes no context — the read chain produces no events and needs no context.
The AbstractDomainEvent branch restores context so that new commands sent from an EventHandler inherit the chain information.
2. Clean up context. In finally:
} finally {
// Clean up context to prevent memory leaks
if (firstArg instanceof BaseCommand || firstArg instanceof AbstractDomainEvent) {
DomainEventContext.clear();
}
}This clear() is mandatory. DomainEventContext is a ThreadLocal — if thread pools reuse threads without cleanup, one request's requestId leaks into the next request's events, and during debugging you'll see ghostly phenomena like “user A's request triggered a data change for user B”.
3. Write chain logs. Fixed format:
requestId:{},method:{}-{}, {}:{}, result:{}, duration:{}-{},username:{}That is: requestId:abc-123,method:com.example.demo.app.handler.CreateProducerCommandHandler-handle, CreateProducerCommand:{"name":"x"}, result:{"uid":"..."}, duration:12-0,username:alice.
Two duration fields: the first is the method execution time, the second is the time after log serialization.
The special branch in Redis mode
When soda.event.bus-type=redis, the aspect skips EventHandlers:
boolean streamEnabled = isStreamEnabled();
boolean isEventHandler = firstArg instanceof AbstractDomainEvent;
boolean isStreamConsumer = false;
boolean isAsyncStreamThread = Thread.currentThread().getName().startsWith("SimpleAsyncTaskExecutor");
if (isEventHandler && streamEnabled && !isStreamConsumer && !isAsyncStreamThread) {
logger.info("[CqrsAroundHandler] Stream enabled, skip EventHandler on thread: {}", Thread.currentThread().getName());
return null;
}The reason: in Redis mode events are already written to the Stream, and Redis consumer threads handle deserialization and delivery. Processing locally as well would double-consume.
isStreamConsumer is always false in the current code — it's a reserved switch. In other words, the real check is “is the EventHandler running on a SimpleAsyncTaskExecutor thread”. When you run async EventHandlers on the default cqrs-async- thread pool (ThreadPoolTaskExecutor), this thread-name check misses and an event may be skipped once.
Pointcut two: intercept CommandBus.send
execution(* com.hibuka.soda.cqrs.command.CommandBus+.send(..))This pointcut does two things.
1. Complete the command's context. If the new command's requestId is null, fill it from DomainEventContext:
if (cmd.getRequestId() == null) {
String requestId = DomainEventContext.getRequestId();
if (requestId != null) {
cmd.setRequestId(requestId);
cmd.setUserName(DomainEventContext.getUserName());
cmd.setJti(DomainEventContext.getJti());
cmd.setAuthorities(DomainEventContext.getAuthorities());
String callerUid = DomainEventContext.getCallerUid();
if (callerUid != null) {
try {
cmd.setCallerUid(callerUid);
} catch (Exception ignored) {}
}
}
}This is what keeps the “event → command” chain going: when an EventHandler calls busFacade.sendCommand(newCmd), the new command automatically inherits the event's context.
2. Async recursion protection. Every send bumps the command's hopCount by one, and over 20 throws:
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);The 20-hop limit is the async protection. Synchronous recursion is guarded by BusFacade's MAX_RECURSION_DEPTH = 10. The two mechanisms work independently. Details: Recursion Protection.
3. Publish events produced by the command. The third job of pointcut two, easy to overlook:
if (result instanceof DomainEvents) {
for (AbstractDomainEvent event : ((DomainEvents) result).getDomainEvents()) {
sycBaseInfo((BaseCommand) args[0], event);
if (streamEnabled) {
// Redis mode: skip local publish, hand it to the repository aspect
} else {
eventBus.publish(event);
}
}
} else if (result instanceof DomainEvent) {
// the command returns a domain event directly
...
}That is: if the aggregate root a CommandHandler returns still carries unpublished domain events, the aspect publishes them right away.
Doesn't this double-publish with the repository aspect? No — the repository aspect takes the events and calls clearDomainEvents() at save time. By the time pointcut two inspects the return value, the event list is already empty.
But if you manually addPendingEvent after save in a CommandHandler (or call update instead of save), events can go out twice. The rule: events are published only by the repository aspect — never call eventBus.publish manually in a Handler.
RepositoryEventAspect
com.hibuka.soda.event.spring.RepositoryEventAspect. Registered by RepositoryEventAutoConfiguration:
@Configuration
@EnableAspectJAutoProxy
@ConditionalOnBean(EventBus.class)
@AutoConfigureAfter(name = {
"com.hibuka.soda.event.spring.SpringEventBusAutoConfiguration",
"com.hibuka.soda.event.redis.RedisEventBusAutoConfiguration"
})
public class RepositoryEventAutoConfiguration {
@Bean
public RepositoryEventAspect repositoryEventAspect(EventBus eventBus, ObjectProvider<CommandContext> commandContextProvider) {
return new RepositoryEventAspect(eventBus, commandContextProvider);
}
}@ConditionalOnBean(EventBus.class) means: with no EventBus bean in the container, this aspect isn't registered at all, and repository calls are just ordinary method calls.
The pointcut
execution(* *..*Repository+.save(..)) ||
execution(* *..*Repository+.update(..)) ||
execution(* *..*Repository+.delete(..)) ||
execution(* *..*Repository+.operate(..))This expression has three critical points, and each is a trap:
*..*Repository+matches types whose name ends withRepository, plus their subtypes. So a repository interface namedProducerWriteRepositoryis enough — an implementation class namedProducerRepoImplstill gets intercepted. But when neither the interface nor the implementation name containsRepository(aProducerStore, say), the pointcut misses and events are silently lost.- Method names must be
save/update/delete/operate. Rename topersistorinsertand it silently fails the same way. *..*means any package. This means any class namedXxxRepositoryin any of your modules matches. The aspect checks whether the first argument is anAbstractAggregateRootsubclass and skips it otherwise — but if you happen to have a utility class calledOrderRepositorywith asave(String)method, this aspect will see it too (it just gets skipped, no impact).
Debugging mantra: if events aren't going out, first check the repository type name contains Repository, then check the method names.
Execution flow
public Object aroundSave(ProceedingJoinPoint joinPoint) throws Throwable {
Object result = joinPoint.proceed(); // ① run the real save first
Object[] args = joinPoint.getArgs();
if (args != null && args.length > 0 && args[0] instanceof AbstractAggregateRoot) {
AbstractAggregateRoot agg = (AbstractAggregateRoot) args[0];
List<AbstractDomainEvent> events = List.copyOf(agg.getDomainEvents());
if (!events.isEmpty()) {
agg.clearDomainEvents(); // ② take the events and clear
enrichEventsWithContext(events); // ③ top up context
if (TransactionSynchronizationManager.isSynchronizationActive()) {
// ④ transaction present: register an afterCommit callback
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
publishEvents(events);
}
});
} else {
// ⑤ no transaction: publish immediately
publishEvents(events);
}
}
}
return result;
}Key points:
- Run save first, then take the events. The order cannot flip — if save throws, the events are never taken and never published.
List.copyOfmakes a snapshot copy, then clears the aggregate's list. So even if something else modifies the events before publication, it doesn't matter.- Transaction present → afterCommit; no transaction → publish immediately.
Transaction callback
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
publishEvents(events);
}
});
} else {
publishEvents(events);
}isSynchronizationActive() asks “is the current thread in transaction synchronization” — i.e. has a transaction started. Inside a Spring-managed @Transactional method it's true.
On rollback afterCommit does not run — the events are never published. This is how “publish events only after the transaction commits” is implemented: the phantom of “the business failed but the event went out” cannot happen.
Conversely, if you don't add @Transactional, events are published the moment save returns. If the save succeeded but later logic failed, the events are already out — data and events are inconsistent. So always open a transaction on the write chain.
Context top-up
enrichEventsWithContext has two priority levels:
// Priority 1: CommandContext (Legacy)
if (ctx != null) {
events.forEach(e -> {
e.setRequestId(ctx.getRequestId());
e.setJti(ctx.getJti());
String[] authoritiesArray = ctx.getAuthorities();
if (authoritiesArray != null) {
e.setAuthorities(String.join(",", authoritiesArray));
}
e.setUserName(ctx.getUserName());
...
});
}
// Priority 2: DomainEventContext (New, supports hopCount and context propagation)
String requestId = DomainEventContext.getRequestId();
if (requestId != null) {
events.forEach(e -> {
if (e.getRequestId() == null) {
e.setRequestId(requestId);
...
}
// Always update hopCount from ThreadLocal if available
Integer hopCount = DomainEventContext.getHopCount();
if (hopCount != null) {
e.setHopCount(hopCount);
}
});
}CommandContextis the legacy mechanism; itsauthoritiesis aString[], joined with commas.DomainEventContextis the new mechanism, withhopCounton top. Its fields are filled only when empty (if (e.getRequestId() == null)), buthopCountis overwritten unconditionally — because it backs async recursion protection.
Note CommandContext has higher priority: once present, it unconditionally overwrites the event's context fields.
Publishing
private void publishEvents(List<AbstractDomainEvent> events) {
for (AbstractDomainEvent event : events) {
try {
eventBus.publish(event);
} catch (Exception e) {
log.error("Failed to publish domain event: {}", event, e);
}
}
}A single failed event publication doesn't affect the other events and doesn't roll back the transaction. The exception is caught and only logged. This means: when event delivery fails, your business data is already committed but downstream never hears about it. In cross-service scenarios you need Redis mode's dead-letter stream or a compensation job as the safety net.
The two event buses
The EventBus interface has three methods:
public interface EventBus {
void publish(DomainEvent event) throws BaseException;
void subscribe(Class<? extends DomainEvent> eventType, EventHandler handler) throws BaseException;
void unsubscribe(Class<? extends DomainEvent> eventType, EventHandler handler) throws BaseException;
}SimpleEventBus
The in-process implementation. Enabled when soda.event.bus-type is unset or simple:
@Configuration
@ConditionalOnProperty(name = "soda.event.bus-type", havingValue = "simple", matchIfMissing = true)
public class SimpleEventBusAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public EventBus simpleEventBus(List<EventHandler<? extends DomainEvent>> eventHandlers) {
return new SimpleEventBus(eventHandlers);
}
}Internally it's a Map<Class<? extends DomainEvent>, List<EventHandler>>, with the List a CopyOnWriteArrayList — so one event type can have multiple Handlers.
On publish, type lookup walks up the type chain:
public void publish(DomainEvent event) throws BaseException {
Class<?> eventType = event.getClass();
while (eventType != null) {
List<EventHandler> eventHandlers = handlers.get(eventType);
if (eventHandlers != null && !eventHandlers.isEmpty()) {
for (EventHandler handler : eventHandlers) {
handler.handle(event);
}
}
// Check all interfaces implemented by this class
for (Class<?> interfaceType : eventType.getInterfaces()) {
if (DomainEvent.class.isAssignableFrom(interfaceType)) {
List<EventHandler> interfaceHandlers = handlers.get(interfaceType);
...
}
}
eventType = eventType.getSuperclass();
}
}In other words, Handler implements EventHandler<AbstractDomainEvent> receives every domain event; Handler implements EventHandler<ProducerEvent> receives only ProducerEvent and its subclasses.
Writing an EventHandler<DomainEvent> as a “global audit handler” is a very convenient pattern — it captures all events.
RedisStreamEventBus
The cross-service implementation. Enabled when soda.event.bus-type=redis; requires the soda-event-redis module and a RedisTemplate on the classpath.
@Configuration
@ConditionalOnClass(RedisTemplate.class)
@ConditionalOnProperty(name = "soda.event.bus-type", havingValue = "redis")
public class RedisEventBusAutoConfiguration {
@Bean
@Primary
@ConditionalOnBean(RedisConnectionFactory.class)
public EventBus redisStreamEventBus(...) { ... }
}It is @Primary — with Redis present, it overrides SimpleEventBus.
It's built on Redis Streams and provides:
| Capability | Description |
|---|---|
| Consumer groups | XGROUP CREATE, multiple consumers in one group share the load |
| Consumption ack | XACK, acknowledged only after successful handling |
| Batch pull | XREADGROUP, batchSize controls entries per pull |
| Concurrent consumption | concurrency consumer threads, consumerName gets -0, -1 suffixes |
| Failure retry | up to maxRetries times, delay initialRetryDelay * 2^n |
| Dead-letter stream | written to deadLetterStream after retries are exhausted |
| Idempotency | via IdempotencyService, dedup by eventId |
| Periodic cleanup | @Scheduled(fixedRate = 3600000) clears expired idempotency state |
The payload on publish:
Map<String, String> entry = new HashMap<>(3);
String eventJson = serializer.toJson(event);
entry.put("event", eventJson);
entry.put("type", event.getClass().getName());
if (context != null) {
entry.put("context", serializer.toJson(context));
}
streamOps.add(streamKey, entry);type stores the event class's fully qualified name — the consumer uses it to deserialize.
The Redis bus's publish is also transaction-aware:
if (TransactionSynchronizationManager.isActualTransactionActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
publishAction.run();
}
});
} else {
publishAction.run();
}Note this uses isActualTransactionActive() while the repository aspect uses isSynchronizationActive(). The semantics are close but not identical — the former asks “is there a real transaction”, the latter “is the transaction synchronization mechanism active”. In practice the difference rarely matters.
The spring option doesn't actually exist
EventProperties.busType defaults to "spring", and docs often list spring / simple / redis as three choices. But in 17.0.1:
- the
soda-event-springmodule has been deleted - no
EventBusimplementation hashavingValue = "spring"
So configuring soda.event.bus-type=spring results in no EventBus bean at all. The consequences:
RepositoryEventAutoConfigurationdoesn't register due to@ConditionalOnBean(EventBus.class)→ the repository aspect is dead → domain events never go outSpringBusAutoConfiguration.cqrsAroundHandlerrequires anEventBusparameter → bean creation fails
In production use simple or redis, never spring.
The safest move is to configure soda.event.bus-type=simple explicitly. Unconfigured, SimpleEventBusAutoConfiguration's matchIfMissing = true takes effect (Simple path), but writing it out is clearer — and dodges the misleading "spring" default in EventProperties.
Auto-configuration inventory
soda-core-base's META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports:
com.hibuka.soda.bus.configuration.SpringBusAutoConfiguration
com.hibuka.soda.bus.configuration.SimpleEventBusAutoConfiguration
com.hibuka.soda.event.spring.RepositoryEventAutoConfigurationThe same file in soda-event-redis:
com.hibuka.soda.event.redis.RedisEventBusAutoConfigurationBeans registered by SpringBusAutoConfiguration:
| Bean | Condition | Description |
|---|---|---|
commandBus | @ConditionalOnMissingBean | SimpleCommandBus |
queryBus | @ConditionalOnMissingBean | SimpleQueryBus |
cqrsAsyncExecutor | @ConditionalOnMissingBean(name = ...) | ThreadPoolTaskExecutor |
busFacade | @ConditionalOnMissingBean | BusFacade |
cqrsAroundHandler | @ConditionalOnMissingBean | the CQRS aspect |
eventProperties | @ConditionalOnMissingBean | soda.event.* config |
Every key bean is @ConditionalOnMissingBean. That is the extension point: define your own bean with the same name and the framework's is skipped. See Extension Points.
The execution order of the two aspects
In one complete write request, the aspects nest like this:
BusFacade.sendTransactCommand(command)
│
├─ CommandBus.send(command)
│ │
│ ├─ CqrsAroundHandler.handleEventBus ← outer
│ │ ├─ backfill command context
│ │ ├─ hopCount check, +1
│ │ ├─ proceed()
│ │ │ └─ SimpleCommandBus.send → finds CommandHandler → handle(command)
│ │ │ │
│ │ │ └─ CqrsAroundHandler.handleExecutor ← inner
│ │ │ ├─ write DomainEventContext
│ │ │ ├─ proceed()
│ │ │ │ ├─ domain.create() → addPendingEvent
│ │ │ │ └─ producerWriteRepository.save(domain)
│ │ │ │ └─ RepositoryEventAspect.aroundSave
│ │ │ │ ├─ proceed() → the real insert
│ │ │ │ ├─ take events, clear, top up context
│ │ │ │ └─ register afterCommit
│ │ │ ├─ clear() DomainEventContext
│ │ │ └─ write chain log
│ │ │
│ │ └─ check return value: DomainEvents / DomainEvent → publish
│ │
│ └─ return
│
└─ transaction commits
└─ afterCommit → eventBus.publish(event)
└─ ProducerEventHandler.handle(event)
└─ CqrsAroundHandler.handleExecutor (again)Note that the repository aspect sits inside the CQRS aspect. So at save time DomainEventContext is still there (not yet cleared) and the events can pick up context.
Debugging checklist
If events aren't going out, check in this order:
- Does the repository type name contain
Repository? (either interface or implementation matching is enough) - Is the method name
save/update/delete/operate? - Is the first argument an
AbstractAggregateRootsubclass? - Did the aggregate root's business method call
addPendingEvent? (protected— outsiders can't) - Was
soda.event.bus-typemisconfigured asspring? - Does the
EventBusbean exist? Search the startup log for[SimpleEventBusAutoConfiguration] Creating SimpleEventBus instance - Did the transaction roll back?
afterCommitruns only on successful commit.
These startup log lines confirm registration directly:
[SimpleCommandBus] Constructor called, handlers size: 1
[SimpleCommandBus] Registered handler for command: com.example.demo.CreateProducerCommand
[SimpleEventBus] Constructor called, handlers size: 1
[SimpleEventBus] Registered 1 event handler types
[CqrsAroundHandler] Bean created!Next steps
- Implement your own event bus, serializer, or repository: Extension Points
- How context travels across threads and services: Context Propagation
- How recursion gets blocked: Recursion Protection