Part 4: Expose an Api
Customers will reach the bank from outside. Give every request a signed-in customer, check ownership on every command and read, and expose the bank through an Api.
Know who is asking
Section titled “Know who is asking”import * as Domain from "alchemy/Domain";import * as Schema from "effect/Schema";import { CustomerId } from "./Customer.id.ts";
export class CurrentActor extends Domain.RequestContext<CurrentActor>()( "Bank/CurrentActor", Schema.Struct({ customerId: CustomerId }),) {}CurrentActor is the customer behind a request. It has a schema because it travels with each command to the account that decides it.
Authenticate requests
Section titled “Authenticate requests”import * as Effect from "effect/Effect";import * as Headers from "effect/http/Headers";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as RpcMiddleware from "effect/rpc/RpcMiddleware";import * as Schema from "effect/Schema";import { CurrentActor } from "./CurrentActor.context.ts";import { CustomerId } from "./Customer.id.ts";
export class Unauthorized extends Schema.TaggedError<Unauthorized>()("Unauthorized", {}) {}
export class Session extends RpcMiddleware.Service<Session, { provides: CurrentActor }>()( "Bank/Session", { error: Unauthorized },) {}
// Demo only: trusts `Authorization: Bearer <customer id>`. Verify a real session in production.export const SessionLive = Layer.succeed(Session, (effect, { headers }) => Option.match(Headers.get(headers, "authorization"), { onNone: () => Effect.fail(new Unauthorized()), onSome: (value) => value.startsWith("Bearer ") ? Effect.provideService(effect, CurrentActor, { customerId: CustomerId.make(value.slice("Bearer ".length)), }) : Effect.fail(new Unauthorized()), }),);
export const sessionClient = (customerId: string) => RpcMiddleware.layerClient(Session, ({ request, next }) => next({ ...request, headers: Headers.set(request.headers, "authorization", `Bearer ${customerId}`) }), );Session is middleware: it runs before a command or read and provides CurrentActor. SessionLive is the server side, and sessionClient attaches the header from a client. Part 6 replaces the header with a browser cookie.
Refuse someone else’s account
Section titled “Refuse someone else’s account”import * as Domain from "alchemy/Domain";
export class NotOwner extends Domain.Rejection("NotOwner") {}NotOwner is the answer for someone else’s account. Deposits and withdrawals both return it, so it gets its own file.
Guard deposits and withdrawals
Section titled “Guard deposits and withdrawals”import { AccountId } from "./Account.id.ts";import { NotOpen } from "./NotOpen.rejection.ts";import { NotOwner } from "./NotOwner.rejection.ts";import { Session } from "./Session.middleware.ts";
export class MoneyDeposited extends Domain.Event("MoneyDeposited", {export class DepositMoney extends Domain.Command("DepositMoney", { target: AccountId, middleware: [Session], input: { amount: Schema.Int }, events: [MoneyDeposited], rejects: [NotOpen], rejects: [NotOpen, NotOwner], reply: { balance: Schema.Int },}) {}import { AccountId } from "./Account.id.ts";import { NotOpen } from "./NotOpen.rejection.ts";import { NotOwner } from "./NotOwner.rejection.ts";import { Session } from "./Session.middleware.ts";
export class MoneyWithdrawn extends Domain.Event("MoneyWithdrawn", {export class WithdrawMoney extends Domain.Command("WithdrawMoney", { target: AccountId, middleware: [Session], input: { amount: Schema.Int }, events: [MoneyWithdrawn], rejects: [NotOpen, InsufficientFunds], rejects: [NotOpen, NotOwner, InsufficientFunds], reply: { balance: Schema.Int },}) {}Each command lists its middleware, here Session, and every way it can fail. Both change the same way, each in its own file.
Check the owner
Section titled “Check the owner”import * as Domain from "alchemy/Domain";import * as Effect from "effect/Effect";import * as Schema from "effect/Schema";import { AccountId } from "./Account.id.ts";import { CurrentActor } from "./CurrentActor.context.ts";import { DepositMoney, MoneyDeposited } from "./DepositMoney.command.ts";import { NotOpen } from "./NotOpen.rejection.ts";import { NotOwner } from "./NotOwner.rejection.ts";import { AccountOpened, AlreadyOpen, OpenAccount } from "./OpenAccount.command.ts";import { InsufficientFunds, MoneyWithdrawn, WithdrawMoney } from "./WithdrawMoney.command.ts";]);type State = typeof State.Type;
// The open account, if the current customer owns it.const owned = Effect.fn(function* (s: State) { if (s._tag !== "Open") return yield* new NotOpen(); const actor = yield* CurrentActor; if (actor.customerId !== s.owner) return yield* new NotOwner(); return s;});
// An event can only follow an open account; anything else is a bug. 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 })], 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 })], DepositMoney: Effect.fn(function* (s, cmd) { const open = yield* owned(s); return [new MoneyDeposited({ amount: cmd.amount, balanceAfter: open.balance + cmd.amount })]; }), WithdrawMoney: Effect.fn(function* (s, cmd) { const open = yield* owned(s); if (cmd.amount > open.balance) { return yield* new InsufficientFunds({ balance: open.balance, requested: cmd.amount }); } return [new MoneyWithdrawn({ amount: cmd.amount, balanceAfter: open.balance - cmd.amount })]; }), }, evolve: {decide can be an Effect.fn that reads what the middleware provides. owned checks the account is open and belongs to the customer asking. OpenAccount has no middleware: only the policy sends it.
Refuse to register someone else
Section titled “Refuse to register someone else”import * as Schema from "effect/Schema";import { CustomerId } from "./Customer.id.ts";import { Session } from "./Session.middleware.ts";
export class CustomerRegistered extends Domain.Event("CustomerRegistered", {export class AlreadyRegistered extends Domain.Rejection("AlreadyRegistered") {}
export class NotYou extends Domain.Rejection("NotYou") {}
export class RegisterCustomer extends Domain.Command("RegisterCustomer", { target: CustomerId, middleware: [Session], input: { name: Schema.String }, events: [CustomerRegistered], rejects: [AlreadyRegistered], rejects: [AlreadyRegistered, NotYou],}) {}RegisterCustomer now needs a session, and can refuse with NotYou, which only it returns.
Register only yourself
Section titled “Register only yourself”import * as Domain from "alchemy/Domain";import * as Effect from "effect/Effect";import * as Schema from "effect/Schema";import { CurrentActor } from "./CurrentActor.context.ts";import { CustomerId } from "./Customer.id.ts";import { AlreadyRegistered, CustomerRegistered, RegisterCustomer } from "./RegisterCustomer.command.ts";import { AlreadyRegistered, CustomerRegistered, NotYou, RegisterCustomer,} from "./RegisterCustomer.command.ts";
export class Customer extends Domain.Aggregate("Customer", { commands: [RegisterCustomer], decide: { RegisterCustomer: (s, cmd) => s.name !== null ? new AlreadyRegistered() : [new CustomerRegistered({ name: cmd.name })], RegisterCustomer: Effect.fn(function* (s, cmd, { id }) { const actor = yield* CurrentActor; if (actor.customerId !== id) return yield* new NotYou(); if (s.name !== null) return yield* new AlreadyRegistered(); return [new CustomerRegistered({ name: cmd.name })]; }), }, evolve: {The third argument to decide holds the id of the customer being registered, so a customer can register only themselves.
Let only the owner read the summary
Section titled “Let only the owner read the summary”import * as Domain from "alchemy/Domain";import * as Effect from "effect/Effect";import * as Schema from "effect/Schema";import { Account } from "./Account.aggregate.ts";import { CurrentActor } from "./CurrentActor.context.ts";import { NotOwner } from "./NotOwner.rejection.ts";import { Session } from "./Session.middleware.ts";
export class AccountSummary extends Domain.View("AccountSummary", { from: [Account], key: Account, middleware: [Session], rejects: [NotOwner], state: Schema.Struct({ owner: Schema.NullOr(Schema.String), balance: Schema.Int }), initial: { owner: null, balance: 0 }, MoneyWithdrawn: (v, e) => ({ ...v, balance: e.balanceAfter }), }, validate: Effect.fn(function* (_request, summary) { const actor = yield* CurrentActor; if (summary?.owner && summary.owner !== actor.customerId) return yield* new NotOwner(); }),}) {}A view guards its reads the same way. validate runs on every read, and again on every change while a client watches. A summary that has no owner yet reads as empty, so a new customer can watch for their account to appear.
Act as a customer in stories
Section titled “Act as a customer in stories”import { Account } from "../src/Account.aggregate.ts";import { Bank, BankHandlers } from "../src/Bank.model.ts";import { CurrentActor } from "../src/CurrentActor.context.ts";import { CustomerId } from "../src/Customer.id.ts";import { DepositMoney, MoneyDeposited } from "../src/DepositMoney.command.ts";import { NotOpen } from "../src/NotOpen.rejection.ts";const story = Story.make(Bank, { layer: BankHandlers });const asSam = Story.provide(CurrentActor, { customerId: CustomerId.make("sam") });const account = Account.ref("a-1");
it.effect("opens an account", () => story( asSam, Story.when(account, new OpenAccount({ owner: "sam" })), Story.then(account, new AccountOpened({ owner: "sam" })),it.effect("refuses to open an account twice", () => story( asSam, Story.given(account, new AccountOpened({ owner: "sam" })), Story.when(account, new OpenAccount({ owner: "sam" })),it.effect("deposits into an open account and replies with the balance", () => story( asSam, Story.given(account, new AccountOpened({ owner: "sam" })), Story.when(account, new DepositMoney({ amount: 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())), story( asSam, Story.when(account, new DepositMoney({ amount: 100 })), Story.rejected(new NotOpen()), ),);
it.effect("refuses to withdraw more than the balance", () => story( asSam, Story.given( account,import { AccountSummary } from "../src/AccountSummary.view.ts";import { Bank, BankHandlers } from "../src/Bank.model.ts";import { CurrentActor } from "../src/CurrentActor.context.ts";import { CustomerId } from "../src/Customer.id.ts";import { DepositMoney } from "../src/DepositMoney.command.ts";import { AccountOpened } from "../src/OpenAccount.command.ts";const story = Story.make(Bank, { layer: BankHandlers });const asSam = Story.provide(CurrentActor, { customerId: CustomerId.make("sam") });const account = Account.ref("a-1");
it.effect("the summary follows the balance", () => story( asSam, Story.given(account, new AccountOpened({ owner: "sam" })), Story.when(account, new DepositMoney({ amount: 100 })),it.effect("the statement has one line per movement", () => story( asSam, Story.given(account, new AccountOpened({ owner: "sam" })), Story.when(account, new DepositMoney({ amount: 100 })),import { AccountSummary } from "../src/AccountSummary.view.ts";import { Bank, BankHandlers } from "../src/Bank.model.ts";import { CurrentActor } from "../src/CurrentActor.context.ts";import { Customer } from "../src/Customer.aggregate.ts";import { CustomerId } from "../src/Customer.id.ts";import { AccountOpened } from "../src/OpenAccount.command.ts";import { CustomerRegistered, RegisterCustomer } from "../src/RegisterCustomer.command.ts";import { CustomerRegistered, NotYou, 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");const as = (customerId: string) => Story.provide(CurrentActor, { customerId: CustomerId.make(customerId) });
it.effect("registering opens a main account", () => story( as("c-1"), Story.when(sam, new RegisterCustomer({ name: "sam" })), Story.then(sam, new CustomerRegistered({ name: "sam" })), ),);
it.effect("a customer can only register themselves", () => story(as("c-2"), Story.when(sam, new RegisterCustomer({ name: "sam" })), Story.rejected(new NotYou())),);A story has no middleware, so Story.provide stands in for it, giving the following commands their CurrentActor.
Expose an Api
Section titled “Expose an Api”import * as Domain from "alchemy/Domain";import { AccountSummary } from "./AccountSummary.view.ts";import { DepositMoney } from "./DepositMoney.command.ts";import { RegisterCustomer } from "./RegisterCustomer.command.ts";import { WithdrawMoney } from "./WithdrawMoney.command.ts";
export class BankApi extends Domain.Api( "BankApi", RegisterCustomer, DepositMoney, WithdrawMoney, AccountSummary,) {}An Api lists what a client may reach. Nothing in it needs a handler: the account decides the commands, and the model hosts the view.
Test the Api
Section titled “Test the Api”import { expect, it } from "@effect/vitest";import * as Domain from "alchemy/Domain";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import type * as Scope from "effect/Scope";import * as Stream from "effect/Stream";import { AccountId } from "../src/Account.id.ts";import { Bank, BankHandlers } from "../src/Bank.model.ts";import { BankApi } from "../src/BankApi.api.ts";import { CustomerId } from "../src/Customer.id.ts";import { SessionLive, sessionClient } from "../src/Session.middleware.ts";
// The bank in memory, with the same middleware a Worker would serve.const World = SessionLive.pipe( Layer.provideMerge(Bank.layer(BankHandlers).pipe(Layer.provide(Domain.InMemory))),);
const signedInAs = (customerId: string) => BankApi.testClient.pipe(Effect.provide(sessionClient(customerId)));
const inBank = <A, E>(effect: Effect.Effect<A, E, Scope.Scope | Layer.Success<typeof World>>) => effect.pipe(Effect.scoped, Effect.provide(World));
it.live("a customer registers, deposits, and reads only their own balance", () => inBank( Effect.gen(function* () { const sam = yield* signedInAs("c-1"); yield* sam.RegisterCustomer({ target: CustomerId.make("c-1"), name: "sam" });
// The policy opens the account shortly after; wait for its summary. const accountId = AccountId.make("c-1-main"); yield* sam.AccountSummary.watch(accountId).pipe( Stream.filter((summary) => summary?.owner === "c-1"), Stream.runHead, );
const { reply } = yield* sam.DepositMoney({ target: accountId, amount: 100 }); expect(reply.balance).toBe(100);
const alex = yield* signedInAs("c-2"); const refused = yield* alex.AccountSummary.get(accountId).pipe(Effect.flip); expect(refused._tag).toBe("NotOwner"); }), ),);testClient calls the Api in the same process, through the real middleware. Each client is signed in as one customer, and someone else’s balance comes back as NotOwner.
bun vitest runEvery request now has a customer, every command and read checks it, and clients reach the bank through one typed Api.