State from Process Events
The process writes down what it does, and sources itself from those facts. It is the only approach that leaves a record of the process itself rather than only of the entities it drove.
|
Works across any number of event store contexts
The process sources only the events it wrote itself, and it writes them to its own context, so the single-context rule is satisfied trivially. It works where state from context events cannot: the other side may sit in a different Axon Server context, or in no event store at all, because this process needs nothing from it beyond being told that something happened. |
|
Where Workflows are available to you they are the recommended answer, and they cover the integration case here without any events of your own to design. This page is what to write when they are not. |
When to use it
Two situations make this the right choice.
The first is integration with something you cannot source. Deriving state from context events needs the other side’s events in the same event store context, carrying a tag you can select on. A payment provider that only calls you back cannot be sourced that way. Neither can a team whose events live in a different Axon Server context, because an entity cannot be sourced from two contexts. This process reads only its own events, so it needs nothing from the other side beyond being told that something happened.
The second is auditing the process itself. A repository row is deleted when the process ends. Derived state is never written at all. Neither approach retains a record of how a rental turned out or how long it took. A process that writes its own events keeps that record, which matters for long-running or regulated processes.
The process’s own events and decision model
The events describe the process, not the domain it coordinates. They are tagged with the correlation identifier and carry whatever the process will need later:
public record RentalPaymentRequested(
@EventTag(key = RENTAL_ID) String rentalId,
String bikeId,
String renter,
int amount
) {
}
public record RentalPaymentProcessCompleted(@EventTag(key = RENTAL_ID) String rentalId) {
}
Ending is a fact the process records, not a callback anything performs on its behalf. Once the completion event is written, every handler short-circuits on it.
The decision model sources those events back:
@EventSourced(idType = String.class)
static class State {
String bikeId;
String renter;
boolean completed;
@EntityCreator
State(RentalPaymentRequested event) {
this.bikeId = event.bikeId();
this.renter = event.renter();
}
@EventSourcingHandler
void evolve(RentalPaymentProcessCompleted event) {
this.completed = true;
}
@EventCriteriaBuilder
private static EventCriteria criteria(String rentalId) {
return EventCriteria.havingTags(Tag.of(RENTAL_ID, rentalId))
.andBeingOneOfTypes(RentalPaymentRequested.class.getName(), (1)
RentalPaymentProcessCompleted.class.getName());
}
}
| 1 | The criteria selects only what the process itself wrote, so the approach works even when the other side’s events are out of reach. |
Recording what the process did
There are two ways to append those events, and they differ only in mechanism. The choice between them is real: the sample module ships both, proven interchangeable by the same tests.
Through a command
Every event is produced by a command, which keeps the process’s own write visible on an event model as a write slice rather than hidden inside an event handler:
@Component
public static class TranslatingProcess {
@EventHandler
public CompletableFuture<?> on(
BikeRequested event,
@InjectEntity(idResolver = RentalPaymentIdResolver.class) @Nullable State state,
CommandDispatcher dispatcher
) {
if (state != null) {
return CompletableFuture.completedFuture(null);
}
return dispatcher.send(new PreparePayment(paymentReferenceFor(event.rentalId()), PRICE))
.getResultMessage() (1)
.thenCompose(ignored -> dispatcher.send( (2)
new RecordPaymentRequested(event.rentalId(),
event.bikeId(),
event.renter(),
PRICE)
).getResultMessage());
}
@CommandHandler
public void handle(
RecordPaymentRequested command,
@InjectEntity @Nullable State state,
EventAppender appender
) {
if (state == null) { (3)
appender.append(new RentalPaymentRequested(command.rentalId(),
command.bikeId(),
command.renter(),
command.amount()));
}
}
}
| 1 | The real work first. |
| 2 | Recording only once it succeeded, as a second command. |
| 3 | The command handler is the only writer of the process’s own events, and is itself idempotent. |
The cost is a second dispatch per step, and a command that exists only to record a fact: it adds modelling discipline without adding behaviour.
By appending directly
An event handler may append events itself. Wire in the EventAppender to do this:
@Component
public static class AppendingProcess {
@EventHandler
public CompletableFuture<?> on(
BikeRequested event,
@InjectEntity(idResolver = RentalPaymentIdResolver.class) @Nullable State state,
CommandDispatcher dispatcher,
EventAppender appender (1)
) {
if (state != null) {
return CompletableFuture.completedFuture(null);
}
return dispatcher.send(new PreparePayment(paymentReferenceFor(event.rentalId()), PRICE))
.getResultMessage()
.thenRun(() -> appender.append(new RentalPaymentRequested( (2)
event.rentalId(), event.bikeId(), event.renter(), PRICE
)));
}
}
| 1 | The appender is injected like any other handler parameter. |
| 2 | Half the code of the command-translating version, and one dispatch fewer per step. |
What keeps a second append from happening is not a transaction, since the event store and the processor’s progress record are separate systems. It is the entity this handler sourced, whose append condition covers exactly the events it read, making that entity the consistency boundary.
|
Appending is safe here only because the handler sourced an entity first. A handler that appends without having sourced anything gets an unconditional append condition, and therefore no optimistic concurrency at all, silently. Note also that the append condition is batch-wide: everything sourced across the batch is combined into one, so the conflict surface is wider than the per-command condition of the command-translating version, and a single conflict fails the whole batch. |
Which to choose
Choose the command if you follow Event Modelling and want every event to have a command behind it, or if you want the recording step to be independently re-sendable. Choose the direct append if you value the smaller amount of code and are comfortable that an event appears without a command.
Either way the recording follows the work, because the event asserts that the work happened. Appending it before the command succeeded would record something that is not yet true. Only the medium changes.
Trade-offs
| Good | Not so good |
|---|---|
|
|
Where next
If the audit trail is not worth the machinery and both sides write to one event store context, state from context events does the same job with less. If the process is really several independent reactions, see vertical slices.