Skip to content

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.

src/CurrentActor.context.ts
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.

src/Session.middleware.ts
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.

src/NotOwner.rejection.ts
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.

src/DepositMoney.command.ts
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", {
src/DepositMoney.command.ts
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 },
}) {}
src/WithdrawMoney.command.ts
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", {
src/WithdrawMoney.command.ts
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.

src/Account.aggregate.ts
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";
src/Account.aggregate.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.
src/Account.aggregate.ts
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.

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

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

src/AccountSummary.view.ts
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 },
src/AccountSummary.view.ts
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.

test/Account.test.ts
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";
test/Account.test.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" })),
test/Account.test.ts
it.effect("refuses to open an account twice", () =>
story(
asSam,
Story.given(account, new AccountOpened({ owner: "sam" })),
Story.when(account, new OpenAccount({ owner: "sam" })),
test/Account.test.ts
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 })),
test/Account.test.ts
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,
test/views.test.ts
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";
test/views.test.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 })),
test/views.test.ts
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 })),
test/registration.test.ts
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" })),
test/registration.test.ts
),
);
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.

src/BankApi.api.ts
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/BankApi.test.ts
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.

Terminal window
bun vitest run

Every request now has a customer, every command and read checks it, and clients reach the bank through one typed Api.