State in a Repository
The process keeps what it needs to remember in a table of its own. It is the most familiar of the approaches and the right place to start if you are unsure.
The state is an ordinary record with ordinary columns. You can query it, index it, report on it, and join it. A process whose progress somebody will eventually ask about wants exactly that.
|
Works across any number of event store contexts
The process stores what it needs itself. It never sources an entity, so the single-context rule never applies. That makes this the approach with the fewest prerequisites: it works just as well when the events it reacts to come from another Axon Server context, another team’s service, or a third-party provider. |
|
Where Workflows are available to you they are the recommended answer: the position within the flow is the state, so there is no table to design and nothing to keep in step with anything else. This page is what to write when they are not. |
When to use it
Use this when the process genuinely has to remember something, and either you want that memory queryable or the events it reacts to are not all in one event store context you own.
It asks nothing of the events it reacts to. If the payment side is a third-party provider whose events you merely receive, or another team’s service on its own Axon Server context, this still works where deriving state from context events cannot. What it does ask for is a place to write and a transaction manager, for the reason below.
The state
Only one thing forces this process to remember anything: the command that confirms a rental targets the bike and names the renter, and no entity in either context is keyed by a rental. Nothing else can tell the process which bike to approve when payment arrives.
@Entity
class RentalPaymentProcessState {
@Id
private String rentalId;
private String bikeId;
private String renter;
protected RentalPaymentProcessState() {
}
RentalPaymentProcessState(String rentalId, String bikeId, String renter) {
this.rentalId = rentalId;
this.bikeId = bikeId;
this.renter = renter;
}
String rentalId() {
return rentalId;
}
String bikeId() {
return bikeId;
}
String renter() {
return renter;
}
}
In a Spring application this is stored through an ordinary Spring Data repository. Nothing about it is Axon-specific.
The process
@Component
public class RentalPaymentProcess {
private final RentalPaymentProcessRepository repository;
public RentalPaymentProcess(RentalPaymentProcessRepository repository) {
this.repository = repository;
}
@EventHandler
public CompletableFuture<?> on(BikeRequested event, CommandDispatcher dispatcher) {
if (repository.findById(event.rentalId()).isPresent()) {
return CompletableFuture.completedFuture(null); (1)
}
repository.save(new RentalPaymentProcessState( (2)
event.rentalId(), event.bikeId(), event.renter()
));
return dispatcher.send(new PreparePayment(paymentReferenceFor(event.rentalId()), PRICE))
.getResultMessage(); (3)
}
@EventHandler
public CompletableFuture<?> on(PaymentConfirmed event, CommandDispatcher dispatcher) {
Optional<RentalPaymentProcessState> state =
repository.findById(rentalIdFor(event.paymentReference()));
if (state.isEmpty()) {
return CompletableFuture.completedFuture(null);
}
RentalPaymentProcessState process = state.get();
repository.deleteById(process.rentalId()); (4)
return dispatcher.send(new ApproveRequest(process.bikeId(), process.renter()))
.getResultMessage();
}
}
| 1 | The idempotency check. A BikeRequested handled twice must not ask for payment a second time. |
| 2 | An ordinary repository call in an ordinary method. It commits and rolls back with the surrounding unit of work, for the reason below. |
| 3 | Returning the result of the dispatch is what makes the processor await the command. A failed command fails the handler, which rolls the write back with it. |
| 4 | Ending the process by forgetting it. |
There is no ceremony here. Storing process state can otherwise require explicit transaction handling; this approach does not.
One thing the sample leaves out for brevity: this class handles events from both sides, which name their
correlation differently, so no single PropertySequencingPolicy spans them. Where the processor handles events
concurrently it needs a
sequencing policy
routing on the message’s QualifiedName, as
the example module’s
does.
Where the transaction comes from
Nowhere in this class. With a PlatformTransactionManager present the handler already runs in a transaction, so
the write above needs no @Transactional and no callback.
The row and the processor’s record of what it has handled need not commit together. The guard on the first line is what absorbs an event that arrives twice.
|
A process that cannot know what to store until the command has answered has to defer the write. If
Deriving the payment reference from the rental identifier avoids the deferred write, which is another reason to check whether the correlation is derivable before deciding where state lives. |
Ending the process
Deleting the row is the simplest way to end, and it is what the sample does. It is safe here for a specific reason: every command this process sends is idempotent. A redelivery arriving after the row is gone restarts the process and re-dispatches. The receiving context then declines to append anything.
If that were not true, deleting would be unsafe, and you would keep a tombstone row instead:
process.markCompleted();
repository.save(process);
Keeping the row also gives you a queryable history of processes that ran, which deletion throws away. Which you want depends on whether anyone will ask.
Trade-offs
| Good | Not so good |
|---|---|
|
|
Where next
If the state you are storing turns out to be derivable from events you already own, the process can stop storing anything: see state from context events. If you want a record of what the process itself did rather than only its outcomes, see state from process events.