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 Within one context there is no limit on how many bounded contexts the criteria may reach across. 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 |
Trade-offs
| Good | Not so good |
|---|---|
|
|
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.