Part 3: React with a policy
Every new customer should get a main account. Model customers, then add a policy that opens the account whenever a customer registers.
Name customer ids
Section titled “Name customer ids”import * as Schema from "effect/Schema";
export const CustomerId = Schema.String.pipe(Schema.brand("CustomerId"));Like the account, a customer has its own kind of id.
Register a customer
Section titled “Register a customer”import * as Domain from "alchemy/Domain";import * as Schema from "effect/Schema";import { CustomerId } from "./Customer.id.ts";
export class CustomerRegistered extends Domain.Event("CustomerRegistered", { name: Schema.String,}) {}
export class AlreadyRegistered extends Domain.Rejection("AlreadyRegistered") {}
export class RegisterCustomer extends Domain.Command("RegisterCustomer", { target: CustomerId, input: { name: Schema.String }, events: [CustomerRegistered], rejects: [AlreadyRegistered],}) {}RegisterCustomer lives in its own file, with the event it records and the rejection it returns.
Model a customer
Section titled “Model a customer”import * as Domain from "alchemy/Domain";import * as Schema from "effect/Schema";import { CustomerId } from "./Customer.id.ts";import { AlreadyRegistered, CustomerRegistered, RegisterCustomer } from "./RegisterCustomer.command.ts";
export class Customer extends Domain.Aggregate("Customer", { id: CustomerId, state: Schema.Struct({ name: Schema.NullOr(Schema.String) }), initial: { name: null }, commands: [RegisterCustomer], decide: { RegisterCustomer: (s, cmd) => s.name !== null ? new AlreadyRegistered() : [new CustomerRegistered({ name: cmd.name })], }, evolve: { CustomerRegistered: (_, e) => ({ name: e.name }), },}) {}A customer is an aggregate with the same shape as the account: an id, the commands it handles, and decide and evolve.
Declare the policy
Section titled “Declare the policy”import * as Domain from "alchemy/Domain";import { Customer } from "./Customer.aggregate.ts";import { CustomerRegistered } from "./RegisterCustomer.command.ts";
export class MainAccountProvisioning extends Domain.Policy("MainAccountProvisioning", { from: Customer, on: [CustomerRegistered],}) {}A policy is “whenever this happens, do that”. This one listens to customers, for CustomerRegistered.
Implement it
Section titled “Implement it”import * as Domain from "alchemy/Domain";import * as Effect from "effect/Effect";import { Account } from "./Account.aggregate.ts";import { AccountId } from "./Account.id.ts";import { Customer } from "./Customer.aggregate.ts";import { OpenAccount } from "./OpenAccount.command.ts";import { CustomerRegistered } from "./RegisterCustomer.command.ts"; on: [CustomerRegistered],}) {}
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)); }); }),);toLayer resolves the account client once, and returns the handler that runs for each registration. source is the customer who registered, and becomes the account’s owner.
A policy can see the same event twice. Its commands get ids derived from that event, so a second run opens nothing new, and catching AlreadyOpen lets it finish quietly.
Host the policy
Section titled “Host the policy”import { Account } from "./Account.aggregate.ts";import { AccountSummary } from "./AccountSummary.view.ts";import { Customer } from "./Customer.aggregate.ts";import { MainAccountProvisioning, MainAccountProvisioningLive,} from "./MainAccountProvisioning.policy.ts";import { Statement } from "./Statement.view.ts";
export class Bank extends Domain.Model("Bank", { aggregates: [Account], aggregates: [Account, Customer], views: [AccountSummary, Statement], policies: [MainAccountProvisioning],}) {}
export const BankHandlers = MainAccountProvisioningLive;The model lists the policy, and BankHandlers is its implementation. You provide it wherever you host the model.
Give every story the policies
Section titled “Give every story the policies”import { Story } from "alchemy/Domain/Story";import { Account } from "../src/Account.aggregate.ts";import { Bank } from "../src/Bank.model.ts";import { Bank, BankHandlers } from "../src/Bank.model.ts";import { DepositMoney, MoneyDeposited } from "../src/DepositMoney.command.ts";import { NotOpen } from "../src/NotOpen.rejection.ts";import { InsufficientFunds, WithdrawMoney } from "../src/WithdrawMoney.command.ts";
const story = Story.make(Bank);const story = Story.make(Bank, { layer: BankHandlers });const account = Account.ref("a-1");import { Account } from "../src/Account.aggregate.ts";import { AccountSummary } from "../src/AccountSummary.view.ts";import { Bank } from "../src/Bank.model.ts";import { Bank, BankHandlers } from "../src/Bank.model.ts";import { DepositMoney } from "../src/DepositMoney.command.ts";import { AccountOpened } from "../src/OpenAccount.command.ts";import { WithdrawMoney } from "../src/WithdrawMoney.command.ts";
const story = Story.make(Bank);const story = Story.make(Bank, { layer: BankHandlers });const account = Account.ref("a-1");A story runs the whole model, policies included, so every story now takes BankHandlers. Leave it out and the type checker tells you.
Test registration
Section titled “Test registration”import { it } from "@effect/vitest";import { Story } from "alchemy/Domain/Story";import { Account } from "../src/Account.aggregate.ts";import { AccountSummary } from "../src/AccountSummary.view.ts";import { Bank, BankHandlers } from "../src/Bank.model.ts";import { Customer } from "../src/Customer.aggregate.ts";import { AccountOpened } from "../src/OpenAccount.command.ts";import { CustomerRegistered, RegisterCustomer } from "../src/RegisterCustomer.command.ts";
const story = Story.make(Bank, { layer: BankHandlers });const sam = Customer.ref("c-1");const samsAccount = Account.ref("c-1-main");
it.effect("registering opens a main account", () => story( Story.when(sam, new RegisterCustomer({ name: "sam" })), Story.then(sam, new CustomerRegistered({ name: "sam" })), Story.then(samsAccount, new AccountOpened({ owner: "c-1" })), Story.view(AccountSummary, samsAccount, { owner: "c-1", balance: 0 }), ),);The policy runs for real. The registration’s events, the account the policy opened, and the summary that followed are all checked in one story.
bun vitest runThree aggregates and views are now connected by a policy, and each step can be checked in a story.