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:
- a delayed command, which an Operation schedules and which runs once due, as that Operation's continuation;
- a recurring command, which runs at every occurrence of a cadence as the system Actor its declaration names.
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.
// 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.
// 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.
// 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
| Guarantee | Stated in |
|---|---|
| When a delayed command runs, on whose authority, and once in the database | Delayed commands |
| When occurrences fall, on whose authority, and what a failure does to the cadence | Recurring commands |
| Restarts and missed occurrences | Restart semantics |
Failure messages
Next steps
Publish and subscribe to an Integration Event
Tell other Modules what happened, with an Integration Event declared in Contracts, published from a Notification Handler and applied in each subscribing Module.
Secure an Operation
Declare the permission a command or query requires, register its check, and say which permissions each Actor holds.