PouryaKit
Concepts

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:

OpenQuoteCommandHandler.cs
// 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:

ExpireQuoteCommandHandler.cs
// 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:

ReconcileStockCommandHandler.cs
// 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

GuaranteeStated in
Nothing schedules before the commit; when a delayed command runs; on whose authorityDelayed commands
When occurrences fall; on whose authority; what a failed occurrence does to the cadenceRecurring commands
What a restart, a missed occurrence or two starting Hosts doRestart semantics
Testing what is not yet dueDeliveries

Failure messages

Next steps

On this page