State from Context Events

The process stores nothing. Everything it needs to know has already been recorded by one side or the other, so instead of keeping a copy it rebuilds the answer from those events each time a handler runs.

This is the approach a Dynamic Consistency Boundary makes possible. The decision model is an ordinary event-sourced entity, except that its criteria span two bounded contexts instead of one.

Works across one event store context only

Both bounded contexts have to write their events to the same event store context. That is not a stylistic preference: an event-sourced entity is sourced from one context, and EventCriteria selects within one context; no criteria span two. An entity assembled from events in two contexts cannot be injected at all.

Within one context there is no limit on how many bounded contexts the criteria may reach across. rental and payment share nothing but a partition of the event store, and the criteria in the example select across both.

If the other side lives in a different Axon Server context, or in a system that is not an Axon event store, state from process events is the approach that replaces this one.

Where Workflows are available to you they are the recommended answer, and they ask nothing of the events at all: no shared event store context, no tag to select on. This page is what to write when they are not.

When to use it

Three conditions have to hold:

  • Both sides write to the same event store context. This one is a deployment fact rather than a design choice, and no amount of tagging works around it.

  • Their events carry a tag the process can select on.

  • The state the process needs is implied by events that already exist.

The decision model

@EventSourced(idType = String.class)
class State {

    String bikeId;
    String renter;
    boolean paymentRequested;
    boolean requestSettled;

    @EntityCreator
    State(BikeRequested event) {
        this.bikeId = event.bikeId();
        this.renter = event.renter();
    }

    @EventSourcingHandler
    void evolve(PaymentPrepared event) {
        this.paymentRequested = true;
    }

    @EventSourcingHandler
    void evolve(BikeInUse event) {
        this.requestSettled = true;
    }

    @EventSourcingHandler
    void evolve(RequestRejected event) {
        this.requestSettled = true;
    }

    @EventCriteriaBuilder
    private static EventCriteria criteria(String rentalId) {
        return EventCriteria.either(
                EventCriteria.havingTags(Tag.of(RENTAL_ID, rentalId)) (1)
                             .andBeingOneOfTypes(BikeRequested.class.getName(),
                                                 BikeInUse.class.getName(),
                                                 RequestRejected.class.getName()),
                EventCriteria.havingTags(Tag.of(PAYMENT_REFERENCE, paymentReferenceFor(rentalId))) (2)
                             .andBeingOneOfTypes(PaymentPrepared.class.getName(),
                                                 PaymentConfirmed.class.getName(),
                                                 PaymentRejected.class.getName(),
                                                 PaymentCancelled.class.getName())
        );
    }
}
1 Rental events are selected by the rental tag.
2 Payment events are selected by the payment side’s own reference tag, whose value happens to be the rental identifier.

That pair of criteria is the only place in the application where the two bounded contexts meet. The rental side does not know payments exist; the payment side treats the reference as an opaque string. This class alone holds both pieces of knowledge. Both criteria select within the same event store context, because a single EventCriteria cannot do anything else.

Keep the criteria as narrow as the decision allows. Every event type listed widens both the read and the conflict surface. Include an event type only if an evolve method actually reacts to it.

The process

@Component
public class RentalPaymentProcess {

    @EventHandler
    public CompletableFuture<?> on(
            BikeRequested event,
            @InjectEntity(idResolver = RentalPaymentIdResolver.class) @Nullable State state, (1)
            CommandDispatcher dispatcher
    ) {
        if (state != null && state.paymentRequested) {
            return CompletableFuture.completedFuture(null);
        }
        return dispatcher.send(new PreparePayment(paymentReferenceFor(event.rentalId()), PRICE))
                         .getResultMessage();
    }

    @EventHandler
    public CompletableFuture<?> on(
            PaymentConfirmed event,
            @InjectEntity(idResolver = RentalPaymentIdResolver.class) @Nullable State state,
            CommandDispatcher dispatcher
    ) {
        if (state == null || state.requestSettled) {
            return CompletableFuture.completedFuture(null);
        }
        return dispatcher.send(new ApproveRequest(state.bikeId, state.renter)) (2)
                         .getResultMessage();
    }
}
1 The entity is nullable because a process that has not started yet has no events, and therefore no entity.
2 The bike and the renter are read back from BikeRequested: what another process would have to store is recovered from an event that already exists.

One effect per handler

Compare this to keeping state in a repository. There, a row is written and a command dispatched. They agree only because a transaction spans them. Here only one thing happens: a command is dispatched.

That difference removes an entire class of bug:

  • If the command fails, no event is appended and the handler fails, so handling the event again rebuilds an identical decision model and tries again.

  • If the command succeeded but the commit did not, handling the event again re-dispatches and the receiver’s idempotency absorbs it.

There is nothing to keep in step. Nothing is written to a second place.

Resolving which process an event belongs to

The one piece of machinery this approach needs is a way to work out which process an incoming event concerns. Rental events carry rentalId; payment events carry paymentReference. Neither the idProperty shortcut nor the default @TargetEntityId lookup spans that difference, so the process resolves the identifier itself:

public class RentalPaymentIdResolver implements EntityIdResolver<String> {

    private static final Map<QualifiedName, BiFunction<Message, EventConverter, String>> RESOLVERS = Map.of(
            new QualifiedName(BikeRequested.class),
            (message, converter) -> message.payloadAs(BikeRequested.class, converter).rentalId(),
            new QualifiedName(BikeInUse.class),
            (message, converter) -> message.payloadAs(BikeInUse.class, converter).rentalId(),
            new QualifiedName(RequestRejected.class),
            (message, converter) -> message.payloadAs(RequestRejected.class, converter).rentalId(),
            new QualifiedName(PaymentPrepared.class),
            (message, converter) -> rentalIdFor(message.payloadAs(PaymentPrepared.class, converter)
                                                       .paymentReference()),
            new QualifiedName(PaymentConfirmed.class),
            (message, converter) -> rentalIdFor(message.payloadAs(PaymentConfirmed.class, converter)
                                                       .paymentReference()),
            new QualifiedName(PaymentRejected.class),
            (message, converter) -> rentalIdFor(message.payloadAs(PaymentRejected.class, converter)
                                                       .paymentReference()),
            new QualifiedName(PaymentCancelled.class),
            (message, converter) -> rentalIdFor(message.payloadAs(PaymentCancelled.class, converter)
                                                       .paymentReference())
    );

    @Override
    public String resolve(Message message, ProcessingContext context) throws EntityIdResolutionException {
        var resolver = RESOLVERS.get(message.type().qualifiedName()); (1)
        if (resolver == null) {
            throw new EntityIdResolutionException(message.payloadType(), List.of());
        }
        return resolver.apply(message, context.component(EventConverter.class)); (2)
    }
}
1 Routing on the qualified name reads nothing from the payload, so an event this process does not handle costs nothing.
2 Each event is then converted exactly once, to its own concrete type.

Do not be tempted to convert to a shared supertype and read the correlation from that. It works in memory, where the payload is already an object and payloadAs short-circuits. It fails against a real event store, where the payload arrives as bytes that no converter can turn into an interface. A test built from plain objects will pass while production fails.

Trade-offs

Good Not so good
  • Nothing is stored, so nothing can drift.

  • One effect per handler, so there is no second write that could fall out of step with the progress record.

  • No life cycle to manage: being finished is a question asked of the events.

  • No schema, no migration, no cleanup.

  • Requires one event store context, a tag on the correlated events, and ownership of both sides.

  • Records only outcomes. A step that produces no event, such as sending an e-mail, leaves no trace and cannot be tracked.

  • One event-store read per handled event.

  • Needs an identifier resolver when the two sides name their correlation differently.

Ending the process

There is nothing to end. Being finished is a predicate over events that already exist, state.requestSettled in the sample, rather than a fact anyone records. Nothing to delete and nothing to clean up.

Where next

If the process must record steps that produce no domain event, or you need an audit trail of the process itself, move to state from process events. If the process turns out to be several unrelated reactions rather than one thing, see vertical slices.