Skip to content

Part 2: Read it with a view

The account decides, but it doesn’t answer questions. Add two views: a summary with the balance, and a statement with one line per movement.

src/AccountSummary.view.ts
import * as Domain from "alchemy/Domain";
import * as Schema from "effect/Schema";
import { Account } from "./Account.aggregate.ts";
export class AccountSummary extends Domain.View("AccountSummary", {
from: [Account],
key: Account,
state: Schema.Struct({ owner: Schema.NullOr(Schema.String), balance: Schema.Int }),
initial: { owner: null, balance: 0 },
evolve: {
AccountOpened: (v, e) => ({ ...v, owner: e.owner }),
MoneyDeposited: (v, e) => ({ ...v, balance: e.balanceAfter }),
MoneyWithdrawn: (v, e) => ({ ...v, balance: e.balanceAfter }),
},
}) {}

A view folds events into state, one copy per key. key: Account gives every account its own summary.

src/Bank.model.ts
import * as Domain from "alchemy/Domain";
import { Account } from "./Account.aggregate.ts";
import { AccountSummary } from "./AccountSummary.view.ts";
export class Bank extends Domain.Model("Bank", {
aggregates: [Account],
views: [AccountSummary],
}) {}
test/views.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 } 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 account = Account.ref("a-1");
it.effect("the summary follows the balance", () =>
story(
Story.given(account, new AccountOpened({ owner: "sam" })),
Story.when(account, new DepositMoney({ amount: 100 })),
Story.when(account, new WithdrawMoney({ amount: 30 })),
Story.view(AccountSummary, account, { owner: "sam", balance: 70 }),
),
);

view checks a key’s state after everything before it has been applied. A partial object checks only the fields you give.

src/Statement.view.ts
import * as Domain from "alchemy/Domain";
import * as Schema from "effect/Schema";
export class StatementLine extends Domain.Event("StatementLine", {
kind: Schema.Literals(["deposit", "withdrawal"]),
amount: Schema.Int,
balance: Schema.Int,
}) {}

A view can record events of its own. One StatementLine describes any movement of money.

src/Statement.view.ts
import * as Domain from "alchemy/Domain";
import * as Schema from "effect/Schema";
import { Account } from "./Account.aggregate.ts";
export class StatementLine extends Domain.Event("StatementLine", {
src/Statement.view.ts
balance: Schema.Int,
}) {}
export class Statement extends Domain.View("Statement", {
from: [Account],
key: Account,
events: [StatementLine],
emit: {
MoneyDeposited: (e) =>
new StatementLine({ kind: "deposit", amount: e.amount, balance: e.balanceAfter }),
MoneyWithdrawn: (e) =>
new StatementLine({ kind: "withdrawal", amount: e.amount, balance: e.balanceAfter }),
},
}) {}

emit takes each account event and returns the line to record. The statement keeps no state of its own; its lines are its history.

src/Bank.model.ts
import { Account } from "./Account.aggregate.ts";
import { AccountSummary } from "./AccountSummary.view.ts";
import { Statement } from "./Statement.view.ts";
export class Bank extends Domain.Model("Bank", {
aggregates: [Account],
views: [AccountSummary],
views: [AccountSummary, Statement],
}) {}
test/views.test.ts
import { DepositMoney } from "../src/DepositMoney.command.ts";
import { AccountOpened } from "../src/OpenAccount.command.ts";
import { Statement } from "../src/Statement.view.ts";
import { WithdrawMoney } from "../src/WithdrawMoney.command.ts";
test/views.test.ts
),
);
it.effect("the statement has one line per movement", () =>
story(
Story.given(account, new AccountOpened({ owner: "sam" })),
Story.when(account, new DepositMoney({ amount: 100 })),
Story.when(account, new WithdrawMoney({ amount: 30 })),
Story.emitted(Statement, account, [
{ kind: "deposit", amount: 100, balance: 100 },
{ kind: "withdrawal", amount: 30, balance: 70 },
]),
),
);

emitted checks every event a view key has emitted, in order.

Terminal window
bun vitest run

The account decides, and two views answer: what the balance is, and how it got there.