Skip to content

Deadlines

A saga that waits needs a deadline. A large transfer waits for a support agent’s approval, and expires if nobody approves it within 24 hours. Schedule a command for the deadline, and let the aggregate decide whichever comes first.

Declare a command that reports the deadline

Section titled “Declare a command that reports the deadline”

A scheduled command fires when nobody is waiting for its answer, so it never rejects. It reports a fact, and records nothing when the fact no longer applies.

packages/domain/src/Transfer/ExpireTransfer.command.ts
import * as Domain from "alchemy/Domain";
import { TransferFailed } from "./FailTransfer.command.ts";
import { TransferId } from "./Transfer.id.ts";
/**
* The approval window closed. Scheduled by `TransferApprovalDeadline`; it
* reports a fact, so it never rejects, and it records nothing if the transfer
* was approved in time.
*/
export class ExpireTransfer extends Domain.Command("ExpireTransfer", {
target: TransferId,
events: [TransferFailed],
}) {}

A policy schedules the command when the waiting starts.

packages/domain/src/Transfer/TransferApprovalDeadline.policy.ts
import * as Domain from "alchemy/Domain";
import * as Effect from "effect/Effect";
import { ExpireTransfer } from "./ExpireTransfer.command.ts";
import { TransferRequested } from "./RequestTransfer.command.ts";
import { Transfer } from "./Transfer.aggregate.ts";
/** How long a support agent has to approve a large transfer. */
export const APPROVAL_WINDOW = "24 hours";
/**
* Whenever a transfer needs approval, expire it if nobody approves it in time.
* The Transfer decides the race: approval and expiry are commands on the same
* instance, so exactly one of them wins.
*/
export class TransferApprovalDeadline extends Domain.Policy("TransferApprovalDeadline", {
from: Transfer,
on: [TransferRequested],
}) {}
export const TransferApprovalDeadlineLive = TransferApprovalDeadline.toLayer(
Effect.gen(function* () {
const transfers = yield* Transfer;
return Effect.fn(function* ({ source, event }) {
if (!event.needsApproval) return;
yield* transfers.schedule(source, new ExpireTransfer(), { after: APPROVAL_WINDOW });
});
}),
);

The schedule is durable: it fires even if everything restarted in between. Scheduling again with the same command id replaces the earlier one.

Approval and expiry are both commands to the same transfer. One may come from a support agent at any moment, the other from the schedule after 24 hours.

Support agentapprovesScheduleset by the policyApproveTransferany timeExpireTransferafter 24 hoursTransferone at a time
CommandAggregateClient
Both commands reach the same transfer, which handles one at a time.

The transfer handles one command at a time, so whichever arrives first changes its state, and its decide turns the other away. Approval is only accepted while the transfer is awaiting approval:

packages/domain/src/Transfer/Transfer.aggregate.ts
ApproveTransfer: Effect.fn(function* (s, _, { id }) {
const approvedBy = yield* CurrentActor;
if (approvedBy._tag !== "Agent") return yield* new NotPermitted();
if (s._tag !== "AwaitingApproval") return yield* new NotAwaitingApproval();

Expiry fails the transfer only if it’s still waiting, and records nothing otherwise:

packages/domain/src/Transfer/Transfer.aggregate.ts
ExpireTransfer: (s, _, { id }) =>
s._tag === "AwaitingApproval"
? [new TransferFailed({ transferId: id, reason: "not approved in time" })]
: [],

So an approved transfer ignores its expiry, and an expired transfer rejects a late approval with NotAwaitingApproval.

A story has its own clock. Story.clock moves it, and any scheduled command that’s now due fires.

packages/domain/test/deadlines.test.ts
// A minute before the deadline, nothing has happened.
Story.clock("2026-01-02T08:59:00Z"),
Story.then(transfer),
// At the deadline, the transfer expires.
Story.clock("2026-01-02T09:00:00Z"),
Story.then(transfer, new TransferFailed({ transferId: transfer.id, reason: "not approved in time" })),
// Approval now comes too late.
Story.provide(CurrentActor, agent("support-7")),
Story.when(transfer, new ApproveTransfer()),
Story.rejected(new NotAwaitingApproval()),

Story.then(transfer) with no events checks that nothing new was recorded.