Skip to content

Api

An Api is the interface a model publishes. It lists what one kind of client may reach: the commands it may send, the views and streams it may read, and the queries it may ask. Everything else in the model stays private.

An Api has no handlers of its own. Aggregates already decide commands and views already answer reads, so an Api only chooses which of them to expose.

A bounded context is meant to be used: by a web app, by a support console, by another context. DDD calls the interface it offers them a published language. It’s the part of the model other people may depend on, and it’s chosen on purpose.

Publishing a slice of the model, rather than all of it, is what keeps the model free to change. Clients depend on DepositMoney and AccountSummary. They don’t depend on how Account stores its state, which policies exist, or which projection feeds which table. You can rework all of those without breaking a client, as long as what you published still works.

Customer appa clientCustomerApithe published partDepositMoneypublishedAccountSummarypublishedOpenAccountinternala policyinternal
CommandViewPolicyApiClient
Clients reach what the Api lists. Everything else stays inside the model.

It’s also the first line of the model’s security. What isn’t published can’t be reached from outside at all.

An Api can expose four kinds of entry. Commands are what a client sends to change something. Views and streams are what a client reads, one key at a time. Queries read across keys, from a store a projection wrote. An aggregate’s state is never exposed: a view projects what clients may see of it, so the aggregate stays free to change with its rules.

Exposing an entry doesn’t bypass its security. Each entry declares its own middleware and checks, and they run on every call that comes through the Api.

Clienttyped from the ApiCustomerApiDomain.ApiDepositMoneycommand · sendAccountSummaryview · get, watchAccountCardstream · peek, watchAccountLedgerquery · ask
CommandViewQueryStreamApiClient
One Api, four kinds of entry. Each is guarded by its own middleware and rules.

Policies, projections and external systems are never on an Api. They run inside the model, and a client reaches what they produce through the entries above: a projection’s stream, a view a policy feeds, or a query over a projection’s store.

A command becomes a method that takes its target and input together, and returns the receipt with the reply. Its middleware runs first, and its aggregate’s decide has the final say.

packages/server/test/apiEntries.ts
const { reply } = yield* bank.DepositMoney(accountId, { amount: 500 });

A view becomes a namespace with five reads: get for the current state, watch for the state and every change, each with its cursor, events for its emitted events (history, a CaughtUp frame, then live), list for a page of them, and snapshot for the state with the cursor it is valid at. Streams send the same shapes; see Know when a live read is current. get, list and snapshot also take atLeast, to read your own write, and fail with ConsistencyTimeout if the view hasn’t applied it within 10 seconds. Its middleware and its Layer’s validate decide who may read which key, on every read and on every change while a client watches.

packages/server/test/apiEntries.ts
const summary = yield* bank.AccountSummary.get(accountId);
const summaries = bank.AccountSummary.watch(accountId);
const page = yield* bank.Statement.list(accountId, { order: "desc", take: 10 });

A stream becomes a namespace like a view’s, with peek in place of get: the latest entry is the current value. It’s guarded the same way, by its middleware and its Layer’s validate, judged against the latest entry. See Stream.

packages/server/test/apiEntries.ts
const card = yield* bank.AccountCard.peek(accountId);
const cards = bank.AccountCard.watch(accountId);

A query becomes a method that takes its input, and options to page (take, after) and to read your own write (atLeast). Its middleware runs first, then its handler checks the caller and reads the store. See Query.

packages/server/test/CustomerApi.test.ts
const first = yield* sam.AccountLedger({ account: accountId }, { atLeast: receipt, take: 1 });
const rest = yield* sam.AccountLedger({ account: accountId }, { after: first.cursor! });

The tutorial’s bank exposes registration, deposits, withdrawals and the account summary.

src/BankApi.api.ts
export class BankApi extends Domain.Api("BankApi", {
model: Bank,
commands: ["RegisterCustomer", "DepositMoney", "WithdrawMoney"],
views: ["AccountSummary"],
}) {}

model is the model the Api is over. commands, views, streams and queries name what of it a client may reach, and naming something the model doesn’t hold is a type error. A kind you leave out is served whole, so Domain.Api("BankApi", { model: Bank }) serves every command, view, stream and query. [] serves none of a kind.

Leaving something out is how you keep it internal. OpenAccount isn’t listed, because only the MainAccountProvisioning policy opens accounts. No client can send it, however it’s built.

Everything built from an Api shows the descriptions of what it serves: an agent’s MCP tools, the CLI’s help, and the OpenAPI document of toHttpApi. Each comes from the annotations of the declaration, so describe each command, view, stream and query where you declare it.

An Api’s own last argument is its annotations too. Its description heads the CLI’s root help and is the OpenAPI document’s info.description:

src/BankApi.ts
export class BankApi extends Domain.Api(
"BankApi",
{ model: Bank, commands: ["WithdrawMoney"], views: ["AccountSummary"] },
{ description: "What a customer of the bank reaches." },
) {}

Different clients need different surfaces. In the example bank, customers and support agents use the same model through two Apis.

packages/domain/src/SupportApi.api.ts
export class SupportApi extends Domain.Api("SupportApi", {
model: Bank,
commands: ["FreezeAccount", "ChangeOwner", "ApproveTransfer"],
views: ["AccountSummary", "AccountActivity"],
streams: [],
queries: ["AccountLedger"],
}) {}

FreezeAccount and ChangeOwner are on the support Api and not the customer one. AccountSummary is on both.

Customer appweb appSupport consoleinternal toolCustomerApifor customersSupportApifor supportBankmodel
ApiClient
One model, published to two kinds of client.

Being on an Api doesn’t mean anyone may use an entry for anything. Each entry still guards itself. A command’s middleware establishes who’s calling and its decide checks whether they may; a view’s middleware and its Layer’s validate decide what they may see. So AccountSummary can sit on both Apis, and a customer still reads only their own accounts.

An Api is served from a Worker. The Api class is an Effect: yield* BankApi gives you its fetch, and the Worker returns it as its own. This is the tutorial’s bank:

src/BankWorker.ts
const BankLive = SessionLive.pipe(
Layer.provideMerge(Bank.layer(BankHandlers)),
Layer.provide(DomainCloudflare.layerDurableObjects()),
);
export default class BankWorker extends Cloudflare.Worker<BankWorker>()(
"BankWorker",
{ main: import.meta.url },
Effect.gen(function* () {
const api = yield* BankApi;
return { fetch: api.fetch };
}).pipe(Effect.provide(BankLive)),
) {}

BankLive is everything the handler needs: the model hosted on Durable Objects, with its policies and projections, and an implementation of every middleware the Api’s entries declare, here the session check. Deploying the Worker deploys the Durable Objects with it.

Commands, get, peek and list are plain requests. Live reads, watch and events, are upgraded to WebSockets connected to the Durable Object that owns the key, which pushes each change as it commits.

A Worker can serve several Apis by routing on the path. The example bank serves customers on /api and support agents on /support:

packages/server/src/BankWorker.ts
if (path.startsWith("/api")) return yield* customerApi.fetch;
if (path.startsWith("/support")) return yield* supportApi.fetch;

See Cloudflare Durable Objects for how the model is hosted.

Api.client builds a client typed from the Api’s entries. Each command is a method that takes the target, the input, and optional call options like headers. Each query is a method that takes its input. Each view or stream is a namespace with its reads.

packages/domain/test/depositAndWatch.ts
export const depositAndWatch = Effect.gen(function* () {
const bank = yield* CustomerApi.client;
const accountId = AccountId.make("c-1-main");
const { reply } = yield* bank.DepositMoney(accountId, { amount: 1_000 });
yield* Effect.log(`balance ${reply.balance}`);
yield* bank.AccountSummary.watch(accountId).pipe(
Stream.take(2),
Stream.runForEach(({ state }) => Effect.log(`balance is now ${state?.balance}`)),
);
});

The client needs to know how to reach the server. Domain.protocolWebSocket(url) sends commands and one-off reads over HTTP and opens a WebSocket for each live read; if the connection drops, the client reconnects and resumes from the last change it saw. Domain.protocolHttp(url) uses HTTP for everything, streaming live reads as long-running responses.

Each protocol also has a shortcut on the Api that provides it:

const bank = yield* CustomerApi.clientWebSocket(url);
// is short for
// CustomerApi.client.pipe(Effect.provide(Domain.protocolWebSocket(url)))

Every call’s error type says exactly what can fail. It’s one of the rejections the entry declares, like InsufficientFunds; an error from its middleware, like Unauthorized; or an RpcClientError when the server can’t be reached. Because each is a tagged error, Effect.catchTag handles the cases you expect and leaves the rest to propagate.

packages/server/test/withdraw.ts
export const withdraw = Effect.fn(function* (bank: CustomerClient, accountId: AccountId) {
const receipt = yield* bank.WithdrawMoney(accountId, { amount: 2_000 }, mfa).pipe(
Effect.catchTag("InsufficientFunds", (r) => Effect.fail(`only ${r.balance} available`)),
);
return receipt.reply.balance;
});

CustomerApi.commands lists the Api’s command classes, in order. CustomerApi.rejections is one Schema for every rejection they declare, so a UI message can hold a refusal as data:

const Refused = Schema.TaggedStruct("Refused", {
commandId: Schema.String,
rejection: CustomerApi.rejections, // InsufficientFunds | NotOwner | ...
});

Domain.Api.Any is the type every Api fits. Use it for helpers you write once for all your Apis:

const actionsOf = <A extends Domain.Api.Any>(api: A) =>
api.commands.map((command) => ({ tag: command.tag, rejects: command.rejects }));

An app usually wants one client for its whole lifetime. CustomerApi.Client is the client as a service, and CustomerApi.layerClient provides it. The app’s entry point provides it once, with the protocol to use:

packages/web/src/entry.ts
return CustomerApi.layerClient.pipe(
Layer.provide(Domain.protocolWebSocket(`${location.origin}/api`)),
Layer.provide(Socket.layerWebSocketConstructorGlobal),
);

Anything that sends a command asks for the client, and doesn’t care how it’s connected:

packages/web/src/Bank.ts
const bank = yield* CustomerApi.Client;

See Integrate with foldkit for a full browser app.

Code in the same process as the model, like a webhook route, a scheduled job or a script, calls the Api with clientInProcess. It’s the same typed client over Domain.protocolInProcess, which serves the Api right there: nothing goes over the network. Every entry’s middleware still runs, from the middleware Layers in scope.

Often that code has already checked who is calling, for example by verifying a webhook signature. Then it provides a middleware Layer that trusts that caller, to this client only. The Api the Worker serves keeps its real middleware.

src/PaymentsWebhook.ts
const asSystem = Layer.succeed(Session, (effect) =>
Effect.provideService(effect, CurrentActor, system("payments-webhook")),
);
export const depositFromWebhook = Effect.fn(function* (accountId: AccountId, amount: number) {
const bank = yield* CustomerApi.clientInProcess.pipe(Effect.provide(asSystem));
return yield* bank.DepositMoney(accountId, { amount });
}, Effect.scoped);

The caller’s decide and validate read the CurrentActor the trusted Layer provides, exactly as they read the one SessionLive provides.

clientInProcess needs a Scope. In a Worker, build it per request, like the served Api: Workers forbid sharing I/O across requests. In a long-lived process, CustomerApi.layerClientInProcess provides CustomerApi.Client in process for the life of the Layer, so code that asks for the client runs the same in process or over the network.

Tests use the same client. To test an Api end to end, including its middleware and every rejection, see Testing an Api.

For internal tools, three conveniences publish the calls you list as they are: a plain HTTP JSON API with an OpenAPI document (Plain HTTP and OpenAPI), tools for an agent (MCP), and a command line (CLI). A public or partner API is your own HttpApi that calls the Api in process, as in Shape a public API.

Next, Story: how to state each rule as an example, and run it.