Skip to content

Policy

A policy is one of the business’s standing rules about what to do when something happens. Whenever a customer registers, the bank opens their main account. Whenever an account’s withdrawals for the day cross a threshold, the bank has it scored for fraud, and freezes it if the risk is high.

The name comes from business policy, and that’s the point: a policy doesn’t just react, it decides. It’s triggered by an event, works out whether and how to act, and acts by sending commands to aggregates or calling systems outside the model.

That’s a different kind of rule from an aggregate’s. An aggregate enforces invariants, which must hold the instant a command commits and can refuse it. A policy applies a procedure, which runs after the event and decides what happens next. This page covers how to declare one, how to implement it, and the one property every policy must have, which is that running it twice does no harm.

A policy names its source, an aggregate or a view, and the events from it that it reacts to.

src/MainAccountProvisioning.policy.ts
export class MainAccountProvisioning extends Domain.Policy("MainAccountProvisioning", {
from: Customer,
on: [CustomerRegistered],
}) {}

The declaration is all the model needs to deliver the right events. Add it to the model’s policies.

from can also list several sources. Each source’s events still arrive in that source’s order, but there is no order between them: see Partition it by key.

The implementation is separate from the declaration, because it usually depends on things outside the model’s definition: a client for another aggregate, a payment provider, a fraud service. toLayer takes an Effect that resolves those once, and returns the handler that runs for each event.

src/MainAccountProvisioning.policy.ts
export const MainAccountProvisioningLive = MainAccountProvisioning.toLayer(
Effect.gen(function* () {
const accounts = yield* Account;
return Effect.fn(function* ({ source }) {
yield* accounts
.send(AccountId.make(`${source.id}-main`), new OpenAccount({ owner: source.id }))
.pipe(Effect.catchTag("AlreadyOpen", () => Effect.void));
});
}),
);

The handler receives the event and its source, the instance that recorded it. Here the source is the customer who registered, and the policy opens an account whose id is derived from theirs.

You pass the implementation to the model when you run it: Bank.layer(BankHandlers). See Domain Model.

Customer c-1aggregatepolicyon CustomerRegisteredAccount c-1-mainOpenAccount
AggregatePolicy
The registration commits first. The policy runs shortly after and opens the account.

A policy never runs in the same step as the event it reacts to. The customer’s registration commits on its own; then the event is delivered to the policy, which sends a separate command to a separate aggregate.

So there’s a moment when the customer exists and their account doesn’t yet. That’s fine for opening an account, and it’s the only way to coordinate two aggregates, since each is its own consistency boundary. It’s not fine for a rule that must hold the instant a command commits, like “a frozen account can’t be withdrawn from”. That rule belongs in the aggregate’s decide, even when it’s about the aggregate’s own events. See Aggregate.

commit 1Customer c-1policyMainAccountProvisioningcommit 2Account c-1-main
AggregatePolicy
Two commits, in two aggregates. Between them, the customer exists and the account doesn't yet.

Every event reaches every policy that reacts to it, in order for each source instance, even across restarts. To guarantee that, the model may deliver an event more than once: if a handler fails or the host restarts midway, it’s run again.

Two things make a second run harmless. First, every command a policy sends gets an id derived from the event that caused it. A second run sends the same command with the same id, and the aggregate returns the receipt it stored the first time instead of deciding again. See Command.

Second, a policy handles the rejections it expects from a repeat. Opening an account that’s already open is refused with AlreadyOpen, so the policy treats that as done:

src/MainAccountProvisioning.policy.ts
.pipe(Effect.catchTag("AlreadyOpen", () => Effect.void));

Calls to outside systems need the same care. Pass them an idempotency key derived from the event, so the payment provider charges once however many times it’s asked.

By default, a policy is partitioned by source instance: everything one customer records is handled in order, one event at a time, and different customers run in parallel.

When a rule spans several sources, choose the partition yourself with key, and route each event to it with keyOf, exactly as a view does. Here one welcome flow per customer hears both the registration and every account the customer opens, in one ordered queue.

src/Welcome.policy.ts
export class Welcome extends Domain.Policy("Welcome", {
from: [Customer, Account],
key: Customer,
keyOf: {
AccountOpened: ({ event }) => Customer.ref(CustomerId.make(event.owner)),
},
on: [CustomerRegistered, AccountOpened],
}) {}

CustomerRegistered comes from a customer, so it needs no route. AccountOpened comes from an account, so keyOf names the customer it belongs to. The handler receives that customer as key.

Customer c-1CustomerRegisteredAccount c-1-mainAccountOpenedWelcome c-1one ordered queue
AggregatePolicy
Two sources, one partition per customer. Within it, events from different sources have no order between them.

The key is also the policy’s unit of concurrency. Pick the narrowest thing whose events must be handled in sequence: everything for one key runs one at a time, and more keys mean more parallelism.

What a policy means lives on its declaration. How it runs lives on its Layer. toLayer takes options, such as a retry schedule for a run that fails before the platform’s own redelivery takes over.

src/Welcome.policy.ts
export const WelcomeLive = Welcome.toLayer(
Effect.succeed(
Effect.fn(function* ({ key, event }) {
yield* Effect.log(`welcome ${key.id}: ${event._tag}`);
}),
),
{ retry: Schedule.exponential("1 second").pipe(Schedule.upTo({ duration: "1 minute" })) },
);

A policy can listen to a view as well as an aggregate. That lets it react to patterns no single event shows.

The example bank wants to review an account when its withdrawals for one day add up past a threshold, even if each one is small. No withdrawal event says that. So a view, DailyOutflow, keeps a running total per account per day, and emits DailyOutflowExceeded when the total crosses the line. The policy listens for that.

packages/domain/src/Fraud/FraudReview.policy.ts
export class FraudReview extends Domain.Policy("FraudReview", {
from: DailyOutflow,
on: [DailyOutflowExceeded],
}) {}
Account a-1aggregateDailyOutflow a-1viewFraudReviewpolicyFraudCheckexternal systemFreezeAccountcommand
CommandAggregateViewPolicyExternal system
DailyOutflow emits DailyOutflowExceeded; FraudReview scores the account and freezes it if it's risky.

To decide whether the account is risky, the policy asks a fraud-scoring service. It doesn’t call a vendor’s SDK directly. It depends on a Domain.ExternalSystem, which names the outside system and the calls the model makes to it.

packages/domain/src/Fraud/FraudCheck.external.ts
export class FraudCheck extends Domain.ExternalSystem("FraudCheck", {
score: { args: { accountId: AccountId, amount: Cents }, success: { risk: Schema.Number } },
}) {}

The policy resolves it like any other service, then calls it per event.

packages/domain/src/Fraud/FraudReview.policy.ts
const fraud = yield* FraudCheck;
const accounts = yield* Account;
return Effect.fn(function* ({ source, event }) {
const { risk } = yield* fraud.score({ accountId: source.id, amount: event.total });

An external system is implemented by a Layer, so production can use the vendor’s HTTP API while a test uses something else. In a story, each call waits until the test resolves it, so you can check what the policy does with every possible answer. See External System.

Commands sent by people pass through middleware that says who they are. A policy has no caller, so when a command’s decide needs to know who’s acting, the policy says so itself.

packages/domain/src/Fraud/FraudReview.policy.ts
yield* accounts.send(source, new FreezeAccount({ reason: `fraud risk ${risk}` })).pipe(
Effect.provideService(CurrentActor, system("FraudReview")),

The freeze is then recorded as done by FraudReview, so the account’s history says why it was frozen and by what.

One policy covers one reaction. A process with several steps, like a transfer that debits one account, credits another, and refunds the first if the second fails, is a saga, and a policy decides when to start one.

Transfer t-1TransferRequestedAccount a-1DebitTransferAccount a-2CreditTransferAccount a-1RefundTransfer
Aggregate
Each step is a policy reacting to the last. If the credit is refused, a refund undoes the debit.

Next, Api decides which commands and reads each kind of client can reach.