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.
Name a file after its noun and its kind
Section titled “Name a file after its noun and its kind”Every file holds one building block, and its name is the block’s name followed by its kind:
WithdrawMoney.command.tsAccount.aggregate.tsAccountSummary.view.tsTransferExecution.policy.tsExecuteTransfer.saga.tsLedger.projection.tsAccountCard.stream.tsFraudCheck.external.tsCustomerApi.api.tsBank.model.tsA 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:
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", {Give shared pieces their own file
Section titled “Give shared pieces their own file”A rejection several commands return gets its own file, and so does an aggregate’s id, next to the aggregate:
import { AccountId } from "./Account.id.ts";import { Frozen } from "./Frozen.rejection.ts";import { InsufficientFunds } from "./InsufficientFunds.rejection.ts";Group files by subdomain
Section titled “Group files by subdomain”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.tsKeep the model in its own package
Section titled “Keep the model in its own package”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 appalchemy.run.ts deploys the server and the web app togetherOther packages import its files by path, through its exports:
import { Bank, BankHandlers } from "@bank/domain/Bank.model";Host it from a server package
Section titled “Host it from a server package”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:
const BankLive = Layer.mergeAll( SessionLive, MfaChallengeLive, Stripe.ConsumeEventsLive, Observability,).pipe( Layer.provideMerge(Bank.layer(BankHandlers)), Layer.provideMerge(D1LedgerStore),Build the frontend in a web package
Section titled “Build the frontend in a web package”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:
import { CustomerId } from "@bank/domain/Customer/Customer.id";import { CustomerApi } from "@bank/domain/CustomerApi.api";Add a package per model
Section titled “Add a package per model”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.