Domain Events
A domain event is a notification that “some state has already changed”. It is produced by the aggregate root when state changes — whoever changes the state sends the notification, instead of a Service manually new-ing one up. It is not a technical log either: subscribers decide what to do based on eventType.
Defining a domain event
Extend AbstractDomainEvent:
package com.example.demo.domain.producer.event;
import com.example.demo.domain.producer.Producer;
import com.hibuka.soda.domain.event.AbstractDomainEvent;
import lombok.Data;
@Data
public class ProducerEvent extends AbstractDomainEvent {
private Producer producer;
// Default constructor for Jackson deserialization
public ProducerEvent() {
super();
}
public ProducerEvent(String eventType, Producer producer) {
setEventType(eventType);
setProducer(producer);
setEventId(BuKaUid.genEventUid());
}
}The fields AbstractDomainEvent already handles:
| Field | Type | Meaning |
|---|---|---|
eventId | String | Generated in the constructor — a snowflake ID with an eid- prefix |
eventType | String | Event type identifier, set by you |
occurredOn | LocalDateTime | Auto-filled with LocalDateTime.now() in the constructor |
requestId | String | Request tracing ID |
callerUid | String | Caller UID |
userName | String | User name |
authorities | String | Authorities string |
jti | String | JWT token id |
hopCount | Integer | Hop counter for async recursion protection |
The default constructor of AbstractDomainEvent generates an eventId (new ScodaSnowflake(1L, 1L).genEventUid()). If your business constructor then calls setEventId(...) to override it (like the example above), the one you set wins. Either works, as long as the ID is globally unique.
The DomainEvent interface also carries a Jackson annotation:
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY, property = "@class")
public interface DomainEvent {
String getEventId();
LocalDateTime getOccurredOn();
String getEventType();
}The @class property puts the concrete type into the JSON, so deserialization across services can restore the correct subclass.
Manage event types with an eventType enum
Don't discriminate event types by class name. Use one enum implementing CodeEnum to manage them centrally:
package com.example.demo.domain.enums;
import com.hibuka.soda.foundation.enums.CodeEnum;
import lombok.Getter;
import java.util.Arrays;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@Getter
public enum ProducerEventTypeEnum implements CodeEnum<Integer> {
/** Create a PRODUCER */
CREATE_PRODUCER(1, "create_producer"),
/** Create a CONSUMER */
CREATE_CONSUMER(0, "create_consumer"),
/** Unknown */
UNKNONW(-1, "unknown");
private final Integer code;
private final String desc;
ProducerEventTypeEnum(Integer code, String desc) {
this.code = code;
this.desc = desc;
}
private static final Map<Integer, ProducerEventTypeEnum> CODE_MAP =
Arrays.stream(values()).collect(Collectors.toUnmodifiableMap(
ProducerEventTypeEnum::getCode,
e -> e,
(a, b) -> { throw new IllegalStateException("duplicate code: " + a.getCode()); }
));
private static final Map<String, ProducerEventTypeEnum> DESC_MAP =
Arrays.stream(values()).collect(Collectors.toUnmodifiableMap(
ProducerEventTypeEnum::getDesc,
e -> e,
(a, b) -> { throw new IllegalStateException("duplicate description: " + a.getDesc()); }
));
public static Optional<ProducerEventTypeEnum> fromCode(Integer code) {
return Optional.ofNullable(CODE_MAP.get(code));
}
public static Optional<ProducerEventTypeEnum> fromDescription(String description) {
return Optional.ofNullable(DESC_MAP.get(description));
}
public static ProducerEventTypeEnum fromCodeOrUnknown(Integer code) {
ProducerEventTypeEnum v = CODE_MAP.get(code);
return v != null ? v : UNKNONW;
}
public static ProducerEventTypeEnum fromDescriptionOrUnknown(String description) {
ProducerEventTypeEnum v = DESC_MAP.get(description);
return v != null ? v : UNKNONW;
}
}CodeEnum<T> is defined in com.hibuka.soda.foundation.enums:
public interface CodeEnum<T> {
T getCode();
String getDesc();
static <T, E extends Enum<?> & CodeEnum<T>> List<E> getAllEnumValues(Class<E> enumClass) { ... }
}It requires only two methods: getCode() returns the business code, getDesc() the description. getAllEnumValues(Class) is a static method that fetches all values of the enum at once.
The companion EnumUtils provides reverse lookup by code:
public class EnumUtils {
public static <T, E extends Enum<?> & CodeEnum<T>> E getByCode(Class<E> enumClass, T code) { ... }
public static <T, E extends Enum<?> & CodeEnum<T>> Optional<E> getOptionalByCode(Class<E> enumClass, T code) { ... }
}getByCode returns null when not found; getOptionalByCode returns an Optional.
Write desc in lower snake case (create_producer), not camelCase or uppercase: eventType goes into event payloads and logs as a string, and lower snake case travels better across services.
The aggregate root produces events
Events are suspended by the aggregate root inside its business methods:
@Data
public class Producer extends AbstractAggregateRoot {
private String uid;
private String name;
public boolean create(String name) {
this.uid = BuKaUid.genDomainUid();
this.name = name;
addPendingEvent(new ProducerEvent(ProducerEventTypeEnum.CREATE_PRODUCER.getDesc(), this));
return true;
}
}addPendingEvent has three important properties:
- It is
protected— only code inside the aggregate can call it; outside code cannot stuff events in. - It suspends rather than publishing immediately — the event goes into a list inside
AbstractAggregateRootfirst. - Context is injected automatically at suspend time —
requestId,userName,jti,authorities,callerUidare read fromDomainEventContext(a ThreadLocal) to fill in any fields still null on the event.
AbstractAggregateRoot also implements DomainEvents:
public abstract class AbstractAggregateRoot extends AbstractDomainEvent implements DomainEvents {
private final List<AbstractDomainEvent> domainEvents = new ArrayList<>();
protected void addPendingEvent(AbstractDomainEvent event) { ... }
@Override
public Collection<AbstractDomainEvent> getDomainEvents() {
return Collections.unmodifiableList(domainEvents);
}
@Override
public void clearDomainEvents() {
domainEvents.clear();
}
}getDomainEvents() returns an unmodifiable view — outside code can look and iterate, but not modify the list.
When events are published
This is the single most critical question in soda: events are published only after the transaction commits, not at the moment of save.
The flow looks like this:
CommandHandler.handle(command)
├─ aggregateRoot.create(...) → addPendingEvent(...) ← event suspended, not yet published
├─ writeRepository.save(domain) ← the aspect intercepts here
│ ├─ runs the actual save
│ ├─ takes domain.getDomainEvents() and clears it
│ ├─ fills in context (CommandContext first, then DomainEventContext)
│ └─ with a transaction → registers afterCommit callback; without → publishes immediately
└─ returns
↓
transaction commits
↓
afterCommit callback fires
↓
eventBus.publish(event)All of this is intercepted by RepositoryEventAspect, whose pointcut reads:
execution(* *..*Repository+.save(..)) ||
execution(* *..*Repository+.update(..)) ||
execution(* *..*Repository+.delete(..)) ||
execution(* *..*Repository+.operate(..))This pointcut matches types whose names end with Repository, plus their subtypes (+ means subtypes included) — not some particular interface. So:
- An interface named
ProducerWriteRepositoryis enough — the implementation can be named anything,ProducerRepoImplstill gets intercepted. - Conversely, any class ending in
Repositorywithsave/update/delete/operatemethods gets intercepted. Events are actually processed only when the first parameter is a subclass ofAbstractAggregateRoot; otherwise it skips. - With Spring Data JPA,
SimpleJpaRepositoryitself ends inRepositoryand is visible to the aspect — but its parameters are entities, notAbstractAggregateRoot, so it merely matches and gets skipped at processing time; no side effects. - What really loses events is this case: neither the interface nor the implementation has
Repositoryin its name (sayProducerStore). The pointcut misses, and events silently vanish.
The full mechanism: Event Bus & Aspect.
Writing an EventHandler
The EventHandler interface is EventHandler<T extends DomainEvent>:
package com.example.demo.application.cqrs.handler.event;
import com.example.demo.domain.consumer.event.ConsumerEvent;
import com.hibuka.soda.cqrs.event.EventHandler;
import com.hibuka.soda.foundation.error.BaseException;
import com.hibuka.soda.foundation.io.ParamChecker;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class ConsumerEventHandler implements EventHandler<ConsumerEvent> {
@Override
public void handle(ConsumerEvent event) throws BaseException {
eventValidate(event);
// Follow-up work: send messages, send notifications, write the read store
}
private void eventValidate(ConsumerEvent event) throws BaseException {
ParamChecker.notNull(event, "event");
ParamChecker.notNull(event.getConsumer(), "consumer");
}
}The handle of EventHandler returns void — an EventHandler returns no result; results show up as side effects.
EventHandlers are registered automatically. At startup the bus scans List<EventHandler<? extends DomainEvent>> and resolves each EventHandler's event type via GenericTypeResolver.resolveTypeArguments, building an index. You write no registration code at all.
Multiple Handlers for one event
Yes. Inside SimpleEventBus sits a Map<Class<? extends DomainEvent>, List<EventHandler>> — one event type maps to a list of EventHandlers.
Branching by event type
The EventHandler receives the same event class and discriminates by eventType:
@Component
@RequiredArgsConstructor
public class ProducerEventHandler implements EventHandler<ProducerEvent> {
private final BusFacade busFacade;
@Override
public void handle(ProducerEvent event) throws BaseException {
eventValidate(event);
if (ProducerEventTypeEnum.CREATE_CONSUMER.getDesc().equals(event.getEventType())) {
logger.info("aggregate 1: event of {} handled successfully 1", event.getProducer().getUid());
} else if (ProducerEventTypeEnum.CREATE_PRODUCER.getDesc().equals(event.getEventType())) {
logger.info("aggregate 1: event of {} handled successfully", event.getProducer().getUid());
}
}
}If the branches for one event diverge a lot, the better move is splitting into multiple event classes — one EventHandler per event — instead of a long if chain in a single EventHandler. An event-type enum fits “several facets of one state change”; independent changes deserve independent event classes.
Event → Command: the chain continues
An EventHandler can send new commands, and soda carries the context over automatically:
@Override
public void handle(ProducerEvent event) throws BaseException {
CreateConsumerCommand command = new CreateConsumerCommand();
command.setName(event.getProducer().getName());
busFacade.sendCommand(command);
}When CqrsAroundHandler intercepts CommandBus.send, it notices the new command's requestId is null and fills it in from DomainEventContext. requestId, userName, jti, authorities all continue on the same chain.
Events trigger commands, commands produce events — accidentally close the loop and it cycles forever. soda guards this with hopCount: past 20 hops it throws IllegalStateException("Async recursion too deep! hopCount=N"). Details: Recursion Protection.
In-process delivery vs cross-service delivery
soda ships two event bus implementations behind one interface (EventBus):
| Implementation | Class | When it applies | Scope |
|---|---|---|---|
| Simple | SimpleEventBus | soda.event.bus-type unset or simple | Single process |
| Redis Streams | RedisStreamEventBus | soda.event.bus-type=redis | Cross-service |
With the Redis bus, events are serialized into a Redis Stream; a consumer group pulls them, deserializes, and delivers them to local EventHandlers. The serialization abstraction is com.hibuka.soda.event.EventSerializer, with JacksonEventSerializer as the default implementation.
Docs often list the values of soda.event.bus-type as spring / simple / redis. But in the current version the soda-event-spring module has been removed and there is no Spring event bus implementation class in the code — while the default of busType in EventProperties is still spring, SimpleEventBusAutoConfiguration is conditioned on havingValue = "simple", matchIfMissing = true, meaning unset falls back to Simple, but configuring spring finds no implementation at all. With no EventBus bean around, CqrsAroundHandler, which depends on one, fails the application at startup. In production, use simple or redis.
A complete example
The domain event:
@Data
public class ProducerEvent extends AbstractDomainEvent {
private Producer producer;
}The aggregate root:
public boolean create(String name) {
this.uid = BuKaUid.genDomainUid();
this.name = name;
addPendingEvent(new ProducerEvent(ProducerEventTypeEnum.CREATE_PRODUCER.getDesc(), this));
return true;
}The EventHandler:
@Component
public class ProducerEventHandler implements EventHandler<ProducerEvent> {
@Override
public void handle(ProducerEvent event) throws BaseException {
ParamChecker.notNull(event.getProducer(), "producer");
logger.info("producer created: {}", event.getProducer().getUid());
}
}The chain works end to end on its own — you wrote no event-publishing code at all; RepositoryEventAspect does it for you after save.
Next steps
- The exact publish mechanism (pointcut, transaction callback, context injection): Event Bus & Aspect
- Custom event buses and serializers: Extension Points
- The full context propagation rules: Context Propagation