HttpApi and handlers
One HttpApi serves the whole app. This page answers how it splits into groups, what a
handler is allowed to do, and where errors and auth attach.
One group per feature, with a thin handler
Section titled “One group per feature, with a thin handler”Impact: HIGH logic stays reusable from RPC, jobs and tests
- Each feature gets one group: its contract in
packages/contracts/src/<feature>.ts, its handler inapps/server/src/<feature>/http.ts. Oneapi.tsassembles the groups. - A handler is an adapter and nothing else. It yields the feature’s service, calls it, maps the
result with
toPublic*, and maps expected errors withcatchTag. No SQL, no business rule, no transaction. - Endpoints use the options object:
HttpApiEndpoint.post(name, path, { params, payload, success, error }). Path ids are branded schemas inparams({ ...OrgParams, ruleId: RuleId }), so a malformed id is a 400 before any query runs. - Accepted cost: a large feature produces a large group. Don’t move logic into the handler to compensate.
❌ Incorrect — the handler runs the business rule itself:
getRule: ({ params }) => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient const rows = yield* sql`SELECT * FROM labeling_rules WHERE id = ${params.ruleId}` if (rows.length === 0) return yield* new Http.NotFound({ resource: "rule", id: params.ruleId }) // …permission check, mapping, more queries}),✅ Correct — call the service, map the result and the errors:
export const layer = HttpApiBuilder.group(Api, "labeling", Effect.fnUntraced(function* (handlers) { const rules = yield* LabelingRules.LabelingRules return handlers.handleAll({ getRule: ({ params }) => rules.get(params.id).pipe( Effect.map(toPublicRule), Effect.catchTag("@app/labeling/RuleNotFound", (e) => new Http.NotFound({ resource: "rule", id: e.ruleId })), ), })}))Source: notes/08-application-surfaces/http-api.md · Decision 1, amended
Map domain errors onto shared status-shaped wire errors
Section titled “Map domain errors onto shared status-shaped wire errors”Impact: HIGH no domain fields leak; services stay free of HTTP
contractsdeclares a few shared wire errors, one per status a client acts on:NotFound { resource, id }(404),Conflict { reason }(409),Forbidden.- Domain errors carry no
httpApiStatusand no HTTP types, so the same error works from RPC or a job. The handler translates each expected one withcatchTag. An uncaught domain error is a compile error, because a handler’s error type must match the endpoint’s declared errors. - Infrastructure failures are turned into a defect explicitly with
catchTags({ … }, internalFailure). The handler does not log; the API boundary does (see error boundaries). - A retryable create takes a client-generated branded id. A replay with the same body returns the
stored row; the same id with a different body is a domain conflict, mapped onto
Conflict.
❌ Incorrect — the domain error carries its HTTP status and goes on the wire as-is:
export class RuleNotFound extends Schema.TaggedError<RuleNotFound>()( "@app/labeling/RuleNotFound", { ruleId: RuleId }, { httpApiStatus: 404 }, // service layer now depends on HTTP) {}✅ Correct — a shared wire error in contracts, mapped in the handler:
export class NotFound extends Schema.TaggedError<NotFound>()( "@app/http/NotFound", { resource: Schema.String, id: Schema.String }, { httpApiStatus: 404 },) {}
// labeling/http.tsEffect.catchTag("@app/labeling/RuleNotFound", (e) => new Http.NotFound({ resource: "rule", id: e.ruleId }))Source: notes/08-application-surfaces/http-api.md · Decision 2, amended
Attach one error boundary at API level
Section titled “Attach one error boundary at API level”Impact: MEDIUM clients learn what was wrong, and nothing internal
- A middleware built with
HttpApiMiddleware.layerSchemaErrorTransformbranches on the error’skind. A request decode failure (Params,Headers,Query,Payload) becomes a declared@app/http/InvalidRequest(400) whose body names the failing field. - A response encode failure (
Body,ResponseHeaders) is our bug, so it becomes@app/http/Unexpected(500) with a reference, like a defect. Effect.catchDefectturns every defect into@app/http/Unexpected(500), carrying a log or trace reference and no internal detail. It is the one place defects are logged.- Accepted cost: one more middleware and two more error classes. Every change to the 500 body needs care, because it is where an internal message could leak.
❌ Incorrect — every schema error reported as the client’s fault:
layerSchemaErrorTransform(ErrorBoundary, (e) => Effect.fail(InvalidRequest.fromSchemaError(e))) // a handler's encode bug becomes a 400✅ Correct — 400 for the request, 500 for our response:
layerSchemaErrorTransform(ErrorBoundary, (e) => e.kind === "Body" || e.kind === "ResponseHeaders" ? Effect.fail(Unexpected.fromCause(e)) // 500 + reference : Effect.fail(InvalidRequest.fromSchemaError(e))) // 400 + failing field's pathSource: notes/08-application-surfaces/http-api.md · Decision 3, amended
Attach auth at group level, and call .middleware() last
Section titled “Attach auth at group level, and call .middleware() last”Impact: HIGH an endpoint added later is never silently public
- The auth middleware is an
HttpApiMiddleware.Servicewithprovides: CurrentPrincipaland a declaredHttpApiSecurityscheme, so it shows in OpenAPI and the client. - Public endpoints live in their own group whose name says so (
labelingPublic). Org-less endpoints (sign-in, the caller’s own org list) also get a separately named group. .middleware()and.prefix()apply only to what was already added. Always call them last.api.contract.test.tsfails on any non-public endpoint without the auth middleware.- The last
.middleware()call runs first. A middleware that requires another’s service is added before it: a tenant group is.add,.prefix("/orgs/:orgId"),.middleware(OrgScope),.middleware(Authentication)(see multi-tenancy).
❌ Incorrect — getRule is added after the middleware, and OrgScope runs before auth:
export const LabelingApi = HttpApiGroup.make("labeling") .add(listRules, createRule) .middleware(Authentication) .add(getRule) // silently public .middleware(OrgScope) // runs first: CurrentPrincipal is not there yet✅ Correct — middleware last, in dependency order; public endpoints in a named group:
export const ProjectsApi = HttpApiGroup.make("projects") .add(get, list, create) // params: { ...OrgParams, id: ProjectId } .prefix("/orgs/:orgId") .middleware(OrgScope) // requires CurrentPrincipal, so added before… .middleware(Authentication) // …the middleware that provides it
export const LabelingPublicApi = HttpApiGroup.make("labelingPublic") .add(publicBadge) // public, and the name says soSource: notes/08-application-surfaces/http-api.md · Decision 4, amended
Run one HttpApi, versioned with /api/v1
Section titled “Run one HttpApi, versioned with /api/v1”Impact: LOW one docs page, one boundary, one client
- One app, one client today: one
HttpApimeans one docs page, one error boundary, oneHttpApiClient. - The
/v1prefix costs nothing now and gives an old client somewhere to stay when the first breaking change ships (to/v2, see API evolution). - Webhooks, OAuth callbacks, SSE and health checks stay raw
HttpRouterroutes outside the API. - Accepted cost: the per-audience split, when it comes, happens under pressure. It is mechanical:
move groups to a second
HttpApi.makewith its own boundary.
❌ Incorrect — unversioned paths, so a breaking change has nowhere to go:
export class Api extends HttpApi.make("app") .add(LabelingApi) .add(BillingApi) {}✅ Correct — one API, prefixed after every .add:
export class Api extends HttpApi.make("app") .add(LabelingApi) .add(LabelingPublicApi) .add(BillingApi) .prefix("/api/v1") // after every .add .middleware(ErrorBoundary) {}Source: notes/08-application-surfaces/http-api.md · Decision 5
Deferred
Section titled “Deferred”- Sub-groups within one feature (by audience or sub-resource) — trigger: more than 12 endpoints in one group.
- A feature-specific wire class (
@app/http/<feature>/<Error>) for one domain error — trigger: a client must branch on that specific error. - One
HttpApiper audience, and one group per REST resource — trigger: a second audience exists (an API consumer that is not our web client and is not deployed from this repo).