Deadlines

Most long-running processes need to give up eventually. A payment that never arrives, an approval nobody grants, a confirmation that never comes: something has to notice and act.

Where Workflows are available to you, they are the recommended answer. None of this page applies: waiting is an ordinary statement in the flow, so a timeout is written where it happens rather than built out of a projection and a sweep.

The deadline behavior

A deadline consists of two things: a record of work that is outstanding, and something that periodically notices when a piece of it has waited too long. Separating them removes the need for a scheduler that understands your domain.

  • A projection of outstanding work, built purely from events: a to-do list, in Event Modelling terms.

  • A sweep that reads that projection and sends a command. This is an Event Modelling automation working that to-do list, the same purple building block that drives every other reaction in this guide, only triggered by what is overdue rather than by an event arriving.

The payment timeout modelled as a projection and a sweep on prooph board
Figure 1. The timeout as its own event model, drawn on prooph board (click to open full size)

Both halves are on the left of the model. PaymentPrepared puts a row on the green projection. The purple sweeper reads it. Neither writes anything the other depends on. The rest of this page relies on that property.

The right side of the model shows the reaction. Giving up is not one decision in one place: the payment is called off, and only then does a further reaction release the bike. The two bounded contexts never address each other, and what the rental side is finally told is RejectRequest. Not that a clock ran out, not that a deadline elapsed. No timeout concept crosses the boundary at all. The rental context stays ignorant that payments exist.

This model is drawn separately from the bike rental event model: the sweep is a different actor on a different schedule, and the projection is not part of any one approach. It is built from payment events, so it applies to every approach in this guide equally.

Why the timeout becomes a command

The timeout is expressed as a command rather than an event.

A timer that fires inside a process is invisible from outside it. It can only be triggered by waiting. It has to be unscheduled when the process ends. None of that is necessary. Use an ordinary command that asks the process to give up:

public record CancelRentalPayment(@TargetEntityId String rentalId) {

}

An event says something happened; the fact that a timeout elapsed is not, by itself, a fact about the business. What the sweep wants is to ask the process to give up. The process may well decline, because the payment arrived a moment ago. That is a request, which is a command. It has a receiver entitled to say no.

Publishing a PaymentTimedOut event instead would assert as fact something that has not been decided. It would leave no natural place to decide it. It also loses the ability to reject: an event has no result.

Expressing the timeout as a command has three consequences:

  • Anyone can send it. An operator, a support tool, a REST endpoint or a test, not only the scheduler.

  • It is testable without waiting. The sample tests a fifteen-minute timeout in milliseconds.

  • Cancellation disappears. Nothing is scheduled, so nothing has to be unscheduled when the process ends. A command that arrives after the process is over simply finds nothing to do.

The to-do list

Rows appear when work becomes outstanding and disappear when it settles, driven only by events:

@Component
public class PaymentsAwaitingConfirmation {

    private static final Duration PAYMENT_TIMEOUT = Duration.ofMinutes(15);

    private final PendingPaymentRepository pending;
    private final CommandGateway commandGateway;

    public PaymentsAwaitingConfirmation(PendingPaymentRepository pending, CommandGateway commandGateway) {
        this.pending = pending;
        this.commandGateway = commandGateway;
    }

    @EventHandler
    public void on(PaymentPrepared event, EventMessage message) {
        pending.save(new PendingPayment(event.paymentReference(), message.timestamp())); (1)
    }

    @EventHandler
    public void on(PaymentConfirmed event) {
        pending.deleteById(event.paymentReference()); (2)
    }

    @EventHandler
    public void on(PaymentRejected event) {
        pending.deleteById(event.paymentReference());
    }

    @EventHandler
    public void on(PaymentCancelled event) {
        pending.deleteById(event.paymentReference());
    }

    @Scheduled(fixedDelayString = "PT5S")
    public void tick() { (3)
        cancelOverduePayments(Instant.now());
    }

    public void cancelOverduePayments(Instant now) { (4)
        pending.findByPreparedAtBefore(now.minus(PAYMENT_TIMEOUT))
               .forEach(payment -> commandGateway.sendAndWait(
                       new CancelRentalPayment(rentalIdFor(payment.paymentReference()))
               ));
    }
}
1 The moment the work became outstanding comes from the event message, not from the clock.
2 Every way the work can settle removes the row. Missing one leaves the sweep cancelling something forever.
3 The scheduled trigger holds no logic.
4 The work, taking the moment to judge against as an argument.
@Entity
@Table(
        name = "pending_payment",
        indexes = @Index(name = "idx_pending_payment_prepared_at", columnList = "preparedAt") (1)
)
class PendingPayment {

    @Id
    private String paymentReference;
    private Instant preparedAt;

    protected PendingPayment() {
    }

    PendingPayment(String paymentReference, Instant preparedAt) {
        this.paymentReference = paymentReference;
        this.preparedAt = preparedAt;
    }

    String paymentReference() {
        return paymentReference;
    }
}

interface PendingPaymentRepository {

    List<PendingPayment> findByPreparedAtBefore(Instant cutoff); (2)

    PendingPayment save(PendingPayment payment);

    void deleteById(String paymentReference);
}
1 Index the column the sweep filters on.
2 Filter in the query. Loading everything and filtering in memory works in a demo and falls over in production.
Writes come from events, reads come from the schedule

The projection is written only by event handlers. The sweep only reads and dispatches.

The list cannot drift from the event stream, because nothing else writes to it. The sweep has no state of its own to keep in step with anything, so the hazard a stored-state process has to work around does not arise here.

Testing a timeout

AxonTestFixture cannot manipulate time. For that reason, the work is a method taking Instant rather than logic inside the @Scheduled method. That also splits the testing into two halves that fail for different reasons.

The sweep

This test verifies that the sweep notices overdue work and leaves the rest alone. Because the projection removes a row when the payment settles, the row disappearing is the observable proof that the sweep reached the payment side:

@Test
void givenOverduePayment_whenSweeping_thenItIsCalledOffAndLeavesTheList() {
    // given a payment that has been outstanding for a while
    var reference = someReference();
    fixture.given()
           .events(new PaymentPrepared(UUID.randomUUID().toString(), PRICE, reference));
    awaitListed(reference);

    // when judged from far enough in the future
    sweeper.cancelOverduePayments(Instant.now().plus(Duration.ofHours(1))); (1)

    // then the payment was called off, which is what takes it off the list again
    awaitNotListed(reference);
}
1 No clock is faked and nothing sleeps. A fifteen-minute timeout is tested in microseconds.

A sweep acts on every overdue item it finds, including ones other tests are relying on. Give a test class that sweeps its own event store a context of its own. Under Spring, that means a property set no other test class shares.

The process

This test verifies that the process does the right thing when asked to give up. That is a different question, with no clock in it at all. The test says nothing about sweeping:

@Test
void givenPaymentNotConfirmed_whenAskedToGiveUp_thenRequestRejected() {
    // given a payment was asked for and never arrived
    fixture.given()
           .events(new BikeRequested(bikeId, renter, rentalId))
           .then()
           .await(result -> result.commandsSatisfy(commands -> assertThat(payloadsOf(commands))
                   .contains(new PreparePayment(paymentReferenceFor(rentalId), PRICE))
           ), TIMEOUT) (1)
           .and()
           .when()
           .command(new CancelRentalPayment(rentalId)) (2)
           .then()
           .success()
           .await(result -> result.commandsSatisfy(commands -> assertThat(payloadsOf(commands))
                   .contains(new RejectRequest(bikeId, renter))
           ), TIMEOUT);
}
1 The payment is not published by the test: the process asks for it, and the payment side creates it. Waiting for that command is what tells us the process has noticed this rental. That matters because the approaches that record their own progress do so asynchronously. Asking one to give up before it has noticed would find nothing to give up on.
2 Giving up is the when, because that is the action under test.

A single test driving the sweep and asserting on the released bike passes or fails for at least three different reasons, and tells you nothing about which. Splitting the two tests avoids that ambiguity.

Where this pattern gets harder

The naive version looks simpler than it is. The following caveats apply before relying on it.

Every instance sweeps

@Scheduled fires on every instance, so each cancellation is dispatched once per instance. Sweeping once needs a scheduler that coordinates across instances.

A replay rebuilds the list with original timestamps

Every historical outstanding item briefly looks overdue and is swept. This is survivable only because the receiver ignores a cancellation of something already settled. The pattern leans on idempotency harder than it first appears.

Precision is the poll interval

Fine for "give up after fifteen minutes". Not suitable for "act at 09:00:00 exactly". If you need precision, use a real scheduler and have it send the same command.

The query must stay a query

Filtering at the database on an indexed column, never loading everything and filtering in memory.

This replaces a deadline manager with a deadline projection. It is simple, replayable, approximate in time, and needs no new infrastructure. It is acceptable because every command it sends can be sent twice.

Alternatives

If a scheduled projection does not fit, the command is still the right target and only the trigger changes.

  • An external scheduler, such as Quartz or a cloud scheduler, sending the same command.

  • A message broker’s delayed delivery, if you already run one.

  • Axoniq Framework Workflows, which handles waiting as a first-class concern. It is available as a preview and requires a licence for production use.

Because the timeout is a command, swapping the trigger changes nothing about the process itself.