Skip to content

Project layout

A domain grows into many small pieces: commands, events, rejections, views, policies. Lay them out so a file’s name tells you what it holds before you open it, and so the model never depends on how it’s hosted.

Every file holds one building block, and its name is the block’s name followed by its kind:

WithdrawMoney.command.ts
Account.aggregate.ts
AccountSummary.view.ts
TransferExecution.policy.ts
ExecuteTransfer.saga.ts
Ledger.projection.ts
AccountCard.stream.ts
FraudCheck.external.ts
CustomerApi.api.ts
Bank.model.ts

A model’s other pieces follow the same rule: Session.middleware.ts, CurrentActor.context.ts, Forbidden.rejection.ts. Plain values, like Money.ts, and helpers, like mustOwn.ts, keep plain names.

Keep a command with its events and rejections

Section titled “Keep a command with its events and rejections”

A command, the events it records and the rejections only it returns change together, so they share the command’s file:

packages/domain/src/Account/WithdrawMoney.command.ts
export class DailyLimitExceeded extends Domain.Rejection("DailyLimitExceeded", {
remaining: Cents,
}) {}
export class MoneyWithdrawn extends Domain.Event("MoneyWithdrawn", {
amount: Cents,
balanceAfter: Cents,
}) {}
/** Take money out of an account, within the balance and the daily limit. */
export class WithdrawMoney extends Domain.Command("WithdrawMoney", {

A rejection several commands return gets its own file, and so does an aggregate’s id, next to the aggregate:

packages/domain/src/Account/WithdrawMoney.command.ts
import { AccountId } from "./Account.id.ts";
import { Frozen } from "./Frozen.rejection.ts";
import { InsufficientFunds } from "./InsufficientFunds.rejection.ts";

Each folder is one part of the business, holding its aggregates and everything about them. The model sits beside the folders and lists what they hold:

src/
Account/ Account.aggregate.ts, WithdrawMoney.command.ts, Statement.view.ts, ...
Customer/ Customer.aggregate.ts, RegisterCustomer.command.ts, UsernameReservation.policy.ts, ...
Transfer/ Transfer.aggregate.ts, ExecuteTransfer.saga.ts, TransferExecution.policy.ts, ...
Fraud/ FraudCheck.external.ts, DailyOutflow.view.ts, FraudReview.policy.ts
Bank.model.ts
CustomerApi.api.ts

The domain package imports only alchemy/Domain and effect. Its stories run the whole model in memory, so it never needs a cloud account to test:

packages/
domain/ @bank/domain the model, its Apis and its stories
server/ @bank/server hosts the model and implements what it depends on
web/ @bank/web the foldkit app
alchemy.run.ts deploys the server and the web app together

Other packages import its files by path, through its exports:

packages/server/src/BankWorker.ts
import { Bank, BankHandlers } from "@bank/domain/Bank.model";

The server package hosts the model on Durable Objects and implements what the domain only declares: middleware, external systems, stores, and where its telemetry goes. Swapping one implementation never touches the model:

packages/server/src/BankWorker.ts
const BankLive = Layer.mergeAll(
SessionLive,
MfaChallengeLive,
Stripe.ConsumeEventsLive,
Observability,
).pipe(
Layer.provideMerge(Bank.layer(BankHandlers)),
Layer.provideMerge(D1LedgerStore),

The foldkit app imports the Api and its ids from the domain package, so the browser gets typed commands and live views with no hand-written client:

packages/web/src/entry.ts
import { CustomerId } from "@bank/domain/Customer/Customer.id";
import { CustomerApi } from "@bank/domain/CustomerApi.api";

A second bounded context, like lending next to banking, is a second domain package with its own model. The same server package can host both. See examples/cloudflare-domain for the whole project.