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, and the views, streams and aggregates it may read. 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. This page covers what it can expose, declaring one, serving it, and calling it.

An Api can expose four kinds of entry. Commands are what a client sends to change something. Views, streams and aggregates are what a client reads.

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, watchAccountaggregate · get, events
CommandAggregateViewStreamApiClient
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 store you serve yourself.

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({ target: accountId, amount: 500 });

A view becomes a namespace with four reads: get for the current state, watch for the state and every change, events for its emitted events, history first, and list for a page of them. get also takes atLeast, to read your own write. Its middleware and 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 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);

An aggregate can be read directly too: its current state with get and watch, and its committed events with events. It’s only allowed on an Api if it declares a read contract, because its state is otherwise private to its rules. The contract names the middleware and the check every read must pass.

packages/domain/src/Account/Account.aggregate.ts
read: {
middleware: [Session],
rejects: [NotOwner],
validate: Effect.fn(function* (_request, s) {
yield* mustOwn(s._tag === "Open" ? s.customerId : null);
}),
},
packages/server/test/apiEntries.ts
const account = yield* bank.Account.get(accountId);
const history = bank.Account.events(accountId, { after: 0 });

Reading an aggregate directly is handy for an account’s own screen. For anything shaped for a question, like a statement or a dashboard, prefer a view: it keeps the aggregate’s state free to change with its rules.

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

src/BankApi.api.ts
export class BankApi extends Domain.Api(
"BankApi",
RegisterCustomer,
DepositMoney,
WithdrawMoney,
AccountSummary,
) {}

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.

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",
FreezeAccount,
ChangeOwner,
ApproveTransfer,
AccountSummary,
AccountActivity,
) {}

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 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.DurableObjects),
);
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 and the input together. Each view or aggregate is a namespace with its reads.

packages/domain/test/depositAndWatch.ts
import * as Effect from "effect/Effect";
import * as Stream from "effect/Stream";
import { AccountId } from "../src/Account/Account.id.ts";
import { CustomerApi } from "../src/CustomerApi.api.ts";
export const depositAndWatch = Effect.gen(function* () {
const bank = yield* CustomerApi.client;
const accountId = AccountId.make("c-1-main");
const { reply } = yield* bank.DepositMoney({ target: accountId, amount: 1_000 });
yield* Effect.log(`balance ${reply.balance}`);
yield* bank.AccountSummary.watch(accountId).pipe(
Stream.take(2),
Stream.runForEach((summary) => Effect.log(`balance is now ${summary?.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.

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
import * as Effect from "effect/Effect";
import type { AccountId } from "@bank/domain/Account/Account.id";
import { type CustomerClient, mfa } from "./flows.ts";
export const withdraw = Effect.fn(function* (bank: CustomerClient, accountId: AccountId) {
const receipt = yield* bank.WithdrawMoney({ target: accountId, amount: 2_000 }, mfa).pipe(
Effect.catchTag("InsufficientFunds", (r) => Effect.fail(`only ${r.balance} available`)),
);
return receipt.reply.balance;
});

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.

To test an Api end to end, including its middleware and every rejection, see Testing an Api.