Policy
A policy is one of the business’s standing rules about what to do when something happens. In the example bank, whenever a customer registers, their main account is opened, and whenever an account’s withdrawals for the day cross a threshold, it’s scored for fraud and frozen 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. Every policy must have one property: running it twice does no harm.
Find the policies in your business
Section titled “Find the policies in your business”Policies hide in sentences that start with “whenever” or “when … then”. Whenever a customer registers, open their main account. When money settles on a closed account, refund it. When a large transfer is requested, wait for an agent to approve it. People in the business say these all the time, often as “we always …” or “the rule is that …”.
Each sentence has two halves. The “whenever” names an event, which is what the policy listens for. The “then” names what the business does next, which becomes a command to an aggregate or a call to a system outside the model. Between them sits whatever judgment the business applies: the fraud rule only freezes an account when the score is high.
Writing these down as policies, instead of burying them in the code that handles a request, keeps them visible. The registration command only registers. The rule about main accounts has its own name, its own file and its own tests, and changing it touches nothing else.
Policy or invariant?
Section titled “Policy or invariant?”Before you write a policy, check that the rule can tolerate a delay. A policy runs shortly after the event, so there’s always a moment when the event has happened and the policy hasn’t acted yet.
“Open a main account for every new customer” is fine with that: for a moment, a customer has no account. “A frozen account can’t be withdrawn from” isn’t. If a policy enforced it, a withdrawal could slip through between the freeze and the policy noticing. Rules that must hold at every instant are invariants, and they belong in an aggregate’s decide. Rules about what happens next are policies.
Declare what triggers it
Section titled “Declare what triggers it”A policy names its source, an aggregate or a view, and the events from it that it reacts to.
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.
Describe it
Section titled “Describe it”A policy’s last argument is its annotations. No client calls a policy, so its description is for the people and the tools that read the model, as MainAccountProvisioning.annotations.
export class MainAccountProvisioning extends Domain.Policy( "MainAccountProvisioning", { from: Customer, on: [CustomerRegistered] }, { description: "Opens a main account for every new customer." },) {}Implement it as a Layer
Section titled “Implement it as a Layer”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.
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.
It runs after the commit
Section titled “It runs after the commit”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.
Make it safe to run twice
Section titled “Make it safe to run twice”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:
.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.
Partition it by key
Section titled “Partition it by key”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.
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.
key can also be a string Schema, such as an org id. keyOf then returns the value, and the handler gets it as key.
export class OrgLimits extends Domain.Policy("OrgLimits", { from: Account, key: OrgId, keyOf: { MoneyWithdrawn: ({ event }) => event.orgId }, on: [MoneyWithdrawn],}) {}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.
Tune it on its Layer
Section titled “Tune it on its Layer”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.
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" })) },);React to a view
Section titled “React to a view”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.
export class FraudReview extends Domain.Policy("FraudReview", { from: DailyOutflow, on: [DailyOutflowExceeded],}) {}Call an external system
Section titled “Call an external system”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.
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.
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 answers it, so you can check what the policy does with every possible answer. See External System.
Act as the system
Section titled “Act as the 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.
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.
Chain policies into a process
Section titled “Chain policies into a process”One policy covers one reaction, and it should stay small: hear one kind of event, decide one thing, send one command or make one call. When you find a policy waiting for a second event, tracking how far along something is, or undoing what it did earlier, it has grown into a process. 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. The policy stays small: it hears that a transfer was requested and starts the saga.
Next, Saga: a process that takes several steps, and undoes them when one fails.