Skip to content

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.

src/Customer.id.ts
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.

src/RegisterCustomer.command.ts
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.

src/Customer.aggregate.ts
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.

src/MainAccountProvisioning.policy.ts
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.

src/MainAccountProvisioning.policy.ts
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";
src/MainAccountProvisioning.policy.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.

src/Bank.model.ts
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.

test/Account.test.ts
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";
test/Account.test.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");
test/views.test.ts
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";
test/views.test.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.test.ts
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.

Terminal window
bun vitest run

Three aggregates and views are now connected by a policy, and each step can be checked in a story.