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.
Install
Section titled “Install”bun add https://pkg.alchemy.run/alchemy/9e3c058 effectbun add -d vitest @effect/vitestGive your coding agent the skill
Section titled “Give your coding agent the skill”bun add -d skills-npmbunx skills-npm setupalchemy 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.
Name account ids
Section titled “Name account ids”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.
Record that an account opened
Section titled “Record that an account opened”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.
Refuse to open an account twice
Section titled “Refuse to open an account twice”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.
Ask to open an account
Section titled “Ask to open an account”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.
Declare the account
Section titled “Declare the account”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.
Group it in a model
Section titled “Group it in a model”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 opening an account
Section titled “Test opening an account”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.
bun vitest runBoth stories pass.
Record deposits
Section titled “Record deposits”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.
Refuse an account that isn’t open
Section titled “Refuse an account that isn’t open”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.
Ask to deposit
Section titled “Ask to deposit”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", { 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.
Decide deposits
Section titled “Decide deposits”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";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 deposits
Section titled “Test deposits”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"; 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.
Withdraw money
Section titled “Withdraw money”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.
Decide withdrawals
Section titled “Decide withdrawals”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([ state: State, initial: { _tag: "Unopened" }, commands: [OpenAccount, DepositMoney], commands: [OpenAccount, DepositMoney, WithdrawMoney], decide: { OpenAccount: (s, cmd) => ? 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 an overdraft
Section titled “Test an overdraft”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); 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 })), ),);bun vitest runAll five stories pass. You have an account that opens once, takes deposits, and never goes below zero.