Skip to content

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 in apps/server/src/<feature>/http.ts. One api.ts assembles 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 with catchTag. 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 in params ({ ...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

  • contracts declares a few shared wire errors, one per status a client acts on: NotFound { resource, id } (404), Conflict { reason } (409), Forbidden.
  • Domain errors carry no httpApiStatus and no HTTP types, so the same error works from RPC or a job. The handler translates each expected one with catchTag. 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:

packages/contracts/src/errors.ts
export class NotFound extends Schema.TaggedError<NotFound>()(
"@app/http/NotFound",
{ resource: Schema.String, id: Schema.String },
{ httpApiStatus: 404 },
) {}
// labeling/http.ts
Effect.catchTag("@app/labeling/RuleNotFound", (e) => new Http.NotFound({ resource: "rule", id: e.ruleId }))

Source: notes/08-application-surfaces/http-api.md · Decision 2, amended

Impact: MEDIUM clients learn what was wrong, and nothing internal

  • A middleware built with HttpApiMiddleware.layerSchemaErrorTransform branches on the error’s kind. 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.catchDefect turns 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 path

Source: 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.Service with provides: CurrentPrincipal and a declared HttpApiSecurity scheme, 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.ts fails 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 so

Source: notes/08-application-surfaces/http-api.md · Decision 4, amended

Impact: LOW one docs page, one boundary, one client

  • One app, one client today: one HttpApi means one docs page, one error boundary, one HttpApiClient.
  • The /v1 prefix 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 HttpRouter routes outside the API.
  • Accepted cost: the per-audience split, when it comes, happens under pressure. It is mechanical: move groups to a second HttpApi.make with 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

  • 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 HttpApi per 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).