soda docs v17.0.1

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:

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

FieldTypeMeaning
eventIdStringGenerated in the constructor — a snowflake ID with an eid- prefix
eventTypeStringEvent type identifier, set by you
occurredOnLocalDateTimeAuto-filled with LocalDateTime.now() in the constructor
requestIdStringRequest tracing ID
callerUidStringCaller UID
userNameStringUser name
authoritiesStringAuthorities string
jtiStringJWT token id
hopCountIntegerHop counter for async recursion protection
Note

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:

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

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

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

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

Tip

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:

java
@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 AbstractAggregateRoot first.
  • Context is injected automatically at suspend time — requestId, userName, jti, authorities, callerUid are read from DomainEventContext (a ThreadLocal) to fill in any fields still null on the event.

AbstractAggregateRoot also implements DomainEvents:

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

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

text
execution(* *..*Repository+.save(..)) ||
execution(* *..*Repository+.update(..)) ||
execution(* *..*Repository+.delete(..)) ||
execution(* *..*Repository+.operate(..))
Warning

This pointcut matches types whose names end with Repository, plus their subtypes (+ means subtypes included) — not some particular interface. So:

  • An interface named ProducerWriteRepository is enough — the implementation can be named anything, ProducerRepoImpl still gets intercepted.
  • Conversely, any class ending in Repository with save / update / delete / operate methods gets intercepted. Events are actually processed only when the first parameter is a subclass of AbstractAggregateRoot; otherwise it skips.
  • With Spring Data JPA, SimpleJpaRepository itself ends in Repository and is visible to the aspect — but its parameters are entities, not AbstractAggregateRoot, 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 Repository in its name (say ProducerStore). The pointcut misses, and events silently vanish.

The full mechanism: Event Bus & Aspect.

Writing an EventHandler

The EventHandler interface is EventHandler<T extends DomainEvent>:

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

java
@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());
        }
    }
}
Tip

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:

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

Important

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

ImplementationClassWhen it appliesScope
SimpleSimpleEventBussoda.event.bus-type unset or simpleSingle process
Redis StreamsRedisStreamEventBussoda.event.bus-type=redisCross-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.

Note

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:

java
@Data
public class ProducerEvent extends AbstractDomainEvent {
    private Producer producer;
}

The aggregate root:

java
public boolean create(String name) {
    this.uid = BuKaUid.genDomainUid();
    this.name = name;
    addPendingEvent(new ProducerEvent(ProducerEventTypeEnum.CREATE_PRODUCER.getDesc(), this));
    return true;
}

The EventHandler:

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