PouryaKit
Guides

Schedule a delayed or recurring command

Schedule one of a Module's commands to run later from the Operation that causes it, or declare one to run on a cadence as a system Actor.

What you build

Either kind is an ordinary command with an ordinary handler, declared in its Module's Composition Root under a durable name:

Delayed and recurring commands explains the two kinds of authority.

The name is durable

The commands stored before you rename a delayed or recurring command's declaration, or rename or move its type, are set aside as dead letters and never run: Delayed commands says why.

When to use it

A delayed command when an Operation causes something that must happen later, such as a quote's expiry. A recurring command for work on a cadence that no Operation asks for, such as a nightly reconciliation. A tenant-scoped Module declares no recurring command.

Steps: a delayed command

Schedule it from the Operation that causes it

Inject IDelayedCommands into a command's handler, a Domain Event Handler, a Notification Handler or an Integration Event Handler, and call Schedule with the command and the time it is due. It only records the command: the Unit of Work writes it in the same commit as the change, or not at all.

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();
    }
}

Declare it in the Composition Root

Declare each delayed command once, under its name, with AddPouryaKitDelayedCommand, as Billing declares ExpireQuoteCommand: see Delayed commands. A Module schedules only commands it declared.

Handle it for the state it finds

The Kit never removes a scheduled command, so cancelling is the handler's business: check the state, as ExpireQuote refuses a quote that is no longer open.

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();
    }
}

Steps: a recurring command

Write the command and its handler

The command carries the occurrence it runs. The handler runs as the system Actor of the declaration, with no tenant.

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();
    }
}

Declare it with its system Actor and bind its cadence

Declare it once with AddPouryaKitRecurringCommand: its name, the system Actor every occurrence runs as, and the function that builds the command for each occurrence. Bind the cadence from a configuration section on the options builder it returns, as Inventory does. Recurring commands shows Inventory's declaration and the section it binds.

Set the cadence in the Host's configuration

Set the cadence in that section of the Host's configuration. A missing or invalid cadence stops the Host when it starts: it never runs at a default.

Testing

The message store compares due times with the system clock, not the Host's TimeProvider, so a test that needs a delayed command to run uses a short real delay. The test fixture does not wait for a command not yet due. A test that needs occurrences to run starts its own Host, on a database of its own, with a short cadence. See Deliveries.

What the Kit guarantees

GuaranteeStated in
When a delayed command runs, on whose authority, and once in the databaseDelayed commands
When occurrences fall, on whose authority, and what a failure does to the cadenceRecurring commands
Restarts and missed occurrencesRestart semantics

Failure messages

Next steps

On this page