Delayed and recurring commands
A delayed command continues the Operation that scheduled it, later; a recurring command runs on a cadence as a named system Actor, continuing nothing.
What it is
A delayed command is a command an Operation schedules to run later in its own Module. It is committed with the Operation and runs once due as its continuation, with its Actor, tenant and correlation.
A recurring command is a command a Module declares to run at every occurrence of a cadence. Each occurrence is an Operation of its own, for the system Actor the declaration names, and continues nothing.
Both are ordinary commands with ordinary handlers. The Module's Outbox stores them until they are due, and each run records a Receipt, so a redelivered run applies its database effect once.
Two kinds of authority
A delayed command runs on the authority of the Operation that scheduled it. A recurring command runs as its declared system Actor, with no tenant, so a tenant-scoped Module cannot declare one. Write each handler for the state it finds when it runs, which may have changed since.
When to use it
- Something must happen later because of what an Operation did, such as a quote that expires: a delayed command, scheduled by that Operation, committed with it or not at all.
- Work recurs on a cadence and no Operation asks for it, such as a nightly reconciliation: a recurring command.
How it works
Billing's OpenQuote opens a quote and schedules its expiry in the same commit:
// Opens a quote and schedules its expiry: the Unit of Work writes the expiry with the quote, in one commit, and the
// message store keeps it until it is due.
internal sealed class OpenQuoteCommandHandler(
IRepository<Quote, QuoteId> quotes,
IDelayedCommands later,
TimeProvider time
) : CommandHandler<OpenQuoteCommand>
{
public override async Task<Result> Handle(
OpenQuoteCommand command,
CancellationToken cancellationToken
)
{
var quote = Quote.Open(new QuoteId(command.QuoteId), time.GetUtcNow() + command.ValidFor);
await quotes.AddAsync(quote, cancellationToken);
later.Schedule(new ExpireQuoteCommand(command.QuoteId), quote.ExpiresOn);
return Result.Success();
}
}When it is due, ExpireQuote runs as a continuation of the Operation that opened the quote. The Kit never removes a scheduled command, so the handler checks the state it finds:
// Runs when the expiry OpenQuote scheduled is due, as a continuation of the Operation that opened the quote.
internal sealed class ExpireQuoteCommandHandler(IRepository<Quote, QuoteId> quotes)
: CommandHandler<ExpireQuoteCommand>
{
public override async Task<Result> Handle(
ExpireQuoteCommand command,
CancellationToken cancellationToken
)
{
var quote = await quotes.FindAsync(new QuoteId(command.QuoteId), cancellationToken);
return quote is null ? QuoteErrors.NotFound : quote.Expire();
}
}Inventory reconciles its stock on a cadence the Host's configuration sets, as the system Actor its Composition Root names:
// Records, at each occurrence, how many stock items Inventory keeps, and on whose authority: the system Actor the
// Composition Root declared, since no person asked for it. Only the effect: the Kit records the occurrence's Receipt,
// stores the next occurrence and commits, so an occurrence delivered twice records one reconciliation.
internal sealed class ReconcileStockCommandHandler(
IUnitOfWorkConnection unitOfWork,
OperationContext context
) : CommandHandler<ReconcileStockCommand>
{
public override async Task<Result> Handle(
ReconcileStockCommand command,
CancellationToken cancellationToken
)
{
var (connection, transaction) = unitOfWork.Join();
await connection.ExecuteAsync(
new CommandDefinition(
"insert into inventory.stock_reconciliations (occurrence, stock_items, reconciled_by) select @Occurrence, count(*), @ReconciledBy from inventory.stock_items",
new { command.Occurrence, ReconciledBy = context.Actor.Id },
transaction,
cancellationToken: cancellationToken
)
);
return Result.Success();
}
}Each is declared in its Module's Composition Root under a durable name, AddPouryaKitDelayedCommand or AddPouryaKitRecurringCommand. The name is its queue and half of its Receipts' key, so keep it when the command is renamed. Schedule a delayed or recurring command walks through both.
What the Kit guarantees
| Guarantee | Stated in |
|---|---|
| Nothing schedules before the commit; when a delayed command runs; on whose authority | Delayed commands |
| When occurrences fall; on whose authority; what a failed occurrence does to the cadence | Recurring commands |
| What a restart, a missed occurrence or two starting Hosts do | Restart semantics |
| Testing what is not yet due | Deliveries |
Failure messages
Next steps
Composition and startup checks
A Host refuses its own mistakes before it serves, when its container is built and when it starts, and each failure names its fix.
Stream
An aggregate whose stored state is the events it appended, the Kit's second write model, which commits through its own Unit of Work on Marten.