Skip to content

Part 1: Model an account

Build a bank in six parts, starting with an account that opens, takes deposits and refuses overdrafts. This part runs on your machine: no cloud account needed yet.

If commands, aggregates and events are new to you, read What is Domain-Driven Design? first.

Terminal window
bun add https://pkg.alchemy.run/alchemy/9e3c058 effect
bun add -d vitest @effect/vitest
Terminal window
bun add -d skills-npm
bunx skills-npm setup

alchemy ships an alchemy-domain skill, and skills-npm links it into your coding agent’s skills folder on every install, matching the alchemy version you have. Your agent then knows the building blocks and conventions this tutorial uses. For other ways to install it, see Build it with your coding agent.

src/Account.id.ts
import * as Schema from "effect/Schema";
export const AccountId = Schema.String.pipe(Schema.brand("AccountId"));

A branded id keeps an account id from being mixed up with any other string. The commands and the account all use it, so it gets a file of its own.

src/OpenAccount.command.ts
import * as Domain from "alchemy/Domain";
import * as Schema from "effect/Schema";
export class AccountOpened extends Domain.Event("AccountOpened", { owner: Schema.String }) {}

An event is a fact, named in the past tense. Everything the account knows will come from its events.

Each command gets a file named after it, with a .command.ts suffix, holding the command, the events it records and the rejections only it returns. This event is what OpenAccount records, so it starts src/OpenAccount.command.ts.

src/OpenAccount.command.ts
export class AccountOpened extends Domain.Event("AccountOpened", { owner: Schema.String }) {}
export class AlreadyOpen extends Domain.Rejection("AlreadyOpen") {}

A rejection is a typed reason to say no. Callers receive it exactly as declared.

src/OpenAccount.command.ts
import * as Domain from "alchemy/Domain";
import * as Schema from "effect/Schema";
import { AccountId } from "./Account.id.ts";
export class AccountOpened extends Domain.Event("AccountOpened", { owner: Schema.String }) {}
export class AlreadyOpen extends Domain.Rejection("AlreadyOpen") {}
export class OpenAccount extends Domain.Command("OpenAccount", {
target: AccountId,
input: { owner: Schema.String },
events: [AccountOpened],
rejects: [AlreadyOpen],
}) {}

A command asks one account for a change. It lists every event it may record and every rejection it may return.

src/Account.aggregate.ts
import * as Domain from "alchemy/Domain";
import * as Schema from "effect/Schema";
import { AccountId } from "./Account.id.ts";
import { AccountOpened, AlreadyOpen, OpenAccount } from "./OpenAccount.command.ts";
const State = Schema.Union([
Schema.TaggedStruct("Unopened", {}),
Schema.TaggedStruct("Open", { owner: Schema.String, balance: Schema.Int }),
]);
type State = typeof State.Type;
export class Account extends Domain.Aggregate("Account", {
id: AccountId,
state: State,
initial: { _tag: "Unopened" },
commands: [OpenAccount],
decide: {
OpenAccount: (s, cmd) =>
s._tag !== "Unopened" ? new AlreadyOpen() : [new AccountOpened({ owner: cmd.owner })],
},
evolve: {
AccountOpened: (_, e) => ({ _tag: "Open" as const, owner: e.owner, balance: 0 }),
},
}) {}

The account is an aggregate. decide takes a command and the current state, and returns events or a rejection. evolve folds each event into the next state.

src/Bank.model.ts
import * as Domain from "alchemy/Domain";
import { Account } from "./Account.aggregate.ts";
export class Bank extends Domain.Model("Bank", {
aggregates: [Account],
}) {}

A model is everything you host together. You’ll add views and policies to it in the next parts.

test/Account.test.ts
import { it } from "@effect/vitest";
import { Story } from "alchemy/Domain/Story";
import { Account } from "../src/Account.aggregate.ts";
import { Bank } from "../src/Bank.model.ts";
import { AccountOpened, AlreadyOpen, OpenAccount } from "../src/OpenAccount.command.ts";
const story = Story.make(Bank);
const account = Account.ref("a-1");
it.effect("opens an account", () =>
story(
Story.when(account, new OpenAccount({ owner: "sam" })),
Story.then(account, new AccountOpened({ owner: "sam" })),
),
);
it.effect("refuses to open an account twice", () =>
story(
Story.given(account, new AccountOpened({ owner: "sam" })),
Story.when(account, new OpenAccount({ owner: "sam" })),
Story.rejected(new AlreadyOpen()),
),
);

A story runs your model in memory. given sets up history, when sends a command, and then and rejected check the outcome.

Terminal window
bun vitest run

Both stories pass.

src/DepositMoney.command.ts
import * as Domain from "alchemy/Domain";
import * as Schema from "effect/Schema";
export class MoneyDeposited extends Domain.Event("MoneyDeposited", {
amount: Schema.Int,
balanceAfter: Schema.Int,
}) {}

The event carries the balance after the deposit, so anyone reading it knows the new balance without adding anything up.

src/NotOpen.rejection.ts
import * as Domain from "alchemy/Domain";
export class NotOpen extends Domain.Rejection("NotOpen") {}

Deposits need an open account, and so will withdrawals. A rejection two commands share gets a file of its own, instead of living in either command’s file.

src/DepositMoney.command.ts
import * as Domain from "alchemy/Domain";
import * as Schema from "effect/Schema";
import { AccountId } from "./Account.id.ts";
import { NotOpen } from "./NotOpen.rejection.ts";
export class MoneyDeposited extends Domain.Event("MoneyDeposited", {
src/DepositMoney.command.ts
balanceAfter: Schema.Int,
}) {}
export class DepositMoney extends Domain.Command("DepositMoney", {
target: AccountId,
input: { amount: Schema.Int },
events: [MoneyDeposited],
rejects: [NotOpen],
reply: { balance: Schema.Int },
}) {}

reply declares what the caller gets back once the deposit is recorded: the new balance.

src/Account.aggregate.ts
import * as Schema from "effect/Schema";
import { AccountId } from "./Account.id.ts";
import { DepositMoney, MoneyDeposited } from "./DepositMoney.command.ts";
import { NotOpen } from "./NotOpen.rejection.ts";
import { AccountOpened, AlreadyOpen, OpenAccount } from "./OpenAccount.command.ts";
src/Account.aggregate.ts
type State = typeof State.Type;
// An event can only follow an open account; anything else is a bug.
const mustBeOpen = (s: State) => {
if (s._tag !== "Open") throw new Error(`expected an open account, got ${s._tag}`);
return s;
};
export class Account extends Domain.Aggregate("Account", {
id: AccountId,
state: State,
initial: { _tag: "Unopened" },
commands: [OpenAccount],
commands: [OpenAccount, DepositMoney],
decide: {
OpenAccount: (s, cmd) =>
s._tag !== "Unopened" ? new AlreadyOpen() : [new AccountOpened({ owner: cmd.owner })],
DepositMoney: (s, cmd) =>
s._tag !== "Open"
? new NotOpen()
: [new MoneyDeposited({ amount: cmd.amount, balanceAfter: s.balance + cmd.amount })],
},
evolve: {
AccountOpened: (_, e) => ({ _tag: "Open" as const, owner: e.owner, balance: 0 }),
MoneyDeposited: (s, e) => ({ ...mustBeOpen(s), balance: e.balanceAfter }),
},
reply: {
DepositMoney: (s) => ({ balance: mustBeOpen(s).balance }),
},
}) {}

evolve never refuses an event, because it already happened. An event that can’t follow the current state is a bug, so mustBeOpen throws and the command that caused it is rolled back. reply answers from the state after the deposit.

test/Account.test.ts
import { Account } from "../src/Account.aggregate.ts";
import { Bank } from "../src/Bank.model.ts";
import { DepositMoney, MoneyDeposited } from "../src/DepositMoney.command.ts";
import { NotOpen } from "../src/NotOpen.rejection.ts";
import { AccountOpened, AlreadyOpen, OpenAccount } from "../src/OpenAccount.command.ts";
test/Account.test.ts
Story.rejected(new AlreadyOpen()),
),
);
it.effect("deposits into an open account and replies with the balance", () =>
story(
Story.given(account, new AccountOpened({ owner: "sam" })),
Story.when(account, new DepositMoney({ amount: 100 })),
Story.then(account, new MoneyDeposited({ amount: 100, balanceAfter: 100 })),
Story.replied({ balance: 100 }),
),
);
it.effect("refuses a deposit into an account that isn't open", () =>
story(Story.when(account, new DepositMoney({ amount: 100 })), Story.rejected(new NotOpen())),
);

replied checks the last command’s reply.

src/WithdrawMoney.command.ts
import * as Domain from "alchemy/Domain";
import * as Schema from "effect/Schema";
import { AccountId } from "./Account.id.ts";
import { NotOpen } from "./NotOpen.rejection.ts";
export class MoneyWithdrawn extends Domain.Event("MoneyWithdrawn", {
amount: Schema.Int,
balanceAfter: Schema.Int,
}) {}
export class InsufficientFunds extends Domain.Rejection("InsufficientFunds", {
balance: Schema.Int,
requested: Schema.Int,
}) {}
export class WithdrawMoney extends Domain.Command("WithdrawMoney", {
target: AccountId,
input: { amount: Schema.Int },
events: [MoneyWithdrawn],
rejects: [NotOpen, InsufficientFunds],
reply: { balance: Schema.Int },
}) {}

A withdrawal follows the same shape as a deposit, with one more way to say no: InsufficientFunds carries the balance and the amount asked for.

src/Account.aggregate.ts
import { NotOpen } from "./NotOpen.rejection.ts";
import { AccountOpened, AlreadyOpen, OpenAccount } from "./OpenAccount.command.ts";
import { InsufficientFunds, MoneyWithdrawn, WithdrawMoney } from "./WithdrawMoney.command.ts";
const State = Schema.Union([
src/Account.aggregate.ts
state: State,
initial: { _tag: "Unopened" },
commands: [OpenAccount, DepositMoney],
commands: [OpenAccount, DepositMoney, WithdrawMoney],
decide: {
OpenAccount: (s, cmd) =>
src/Account.aggregate.ts
? new NotOpen()
: [new MoneyDeposited({ amount: cmd.amount, balanceAfter: s.balance + cmd.amount })],
WithdrawMoney: (s, cmd) =>
s._tag !== "Open"
? new NotOpen()
: cmd.amount > s.balance
? new InsufficientFunds({ balance: s.balance, requested: cmd.amount })
: [new MoneyWithdrawn({ amount: cmd.amount, balanceAfter: s.balance - cmd.amount })],
},
evolve: {
AccountOpened: (_, e) => ({ _tag: "Open" as const, owner: e.owner, balance: 0 }),
MoneyDeposited: (s, e) => ({ ...mustBeOpen(s), balance: e.balanceAfter }),
MoneyWithdrawn: (s, e) => ({ ...mustBeOpen(s), balance: e.balanceAfter }),
},
reply: {
DepositMoney: (s) => ({ balance: mustBeOpen(s).balance }),
WithdrawMoney: (s) => ({ balance: mustBeOpen(s).balance }),
},
}) {}

The account checks the balance it holds right now. Withdrawals to one account run one at a time, so two of them can never both spend the same money.

test/Account.test.ts
import { NotOpen } from "../src/NotOpen.rejection.ts";
import { AccountOpened, AlreadyOpen, OpenAccount } from "../src/OpenAccount.command.ts";
import { InsufficientFunds, WithdrawMoney } from "../src/WithdrawMoney.command.ts";
const story = Story.make(Bank);
test/Account.test.ts
story(Story.when(account, new DepositMoney({ amount: 100 })), Story.rejected(new NotOpen())),
);
it.effect("refuses to withdraw more than the balance", () =>
story(
Story.given(
account,
new AccountOpened({ owner: "sam" }),
new MoneyDeposited({ amount: 100, balanceAfter: 100 }),
),
Story.when(account, new WithdrawMoney({ amount: 150 })),
Story.rejected(new InsufficientFunds({ balance: 100, requested: 150 })),
),
);
Terminal window
bun vitest run

All five stories pass. You have an account that opens once, takes deposits, and never goes below zero.