Handlers
A handler implements one endpoint. You write handlers with HttpApiBuilder.group,
which gives you a builder whose .handle(name, fn) method is type-checked
against the API definition: the endpoint name must exist, and the handler’s
inputs and outputs must match the endpoint’s schemas. The result is a Layer
that provides that group’s routes.
A service for the business logic
Section titled “A service for the business logic”Keep request handling thin: put the real work in a service
so it stays testable and reusable. Note the use of Effect.fn for methods that
return Effects, and a single wrapper error (UsersError) so the service’s
failure channel stays small.
import { Context, Effect, Layer, Ref } from "effect"import { User, UserId } from "../domain/User.ts"import { SearchQueryTooShort, UserNotFound, UsersError } from "../domain/UserErrors.ts"
export class Users extends Context.Service<Users, { list(search: string | undefined): Effect.Effect<Array<User>, UsersError> getById(id: UserId): Effect.Effect<User, UsersError> create(input: { readonly name: string; readonly email: string }): Effect.Effect<User, UsersError>}>()("acme/Users") { static readonly layer = Layer.effect( Users, Effect.gen(function*() { const users = new Map<number, User>() const nextId = yield* Ref.make(1)
const list = Effect.fn("Users.list")(function*(search: string | undefined) { const all = Array.from(users.values()) if (search === undefined || search.length === 0) return all if (search.length < SearchQueryTooShort.minimumLength) { // Wrap domain errors in the single UsersError reason union return yield* new UsersError({ reason: new SearchQueryTooShort() }) } const q = search.toLowerCase() return all.filter((u) => u.name.toLowerCase().includes(q)) })
const getById = Effect.fn("Users.getById")(function*(id: UserId) { const user = users.get(id) if (user === undefined) { return yield* new UsersError({ reason: new UserNotFound() }) } return user })
const create = Effect.fn("Users.create")(function*(input: { readonly name: string; readonly email: string }) { const id = yield* Ref.getAndUpdate(nextId, (n) => n + 1) const user = new User({ id: UserId.make(id), ...input }) users.set(user.id, user) return user })
return Users.of({ list, getById, create }) }) )}Implementing the group
Section titled “Implementing the group”HttpApiBuilder.group(api, groupName, build) builds handlers for one group. The
build function runs as an Effect — yield* your dependencies, then return the
chained .handle(...) calls. Each handler receives the decoded inputs
(params, query, payload, headers) and returns an Effect producing the
endpoint’s success type.
import { Effect, Layer } from "effect"import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"import { Api } from "../../api/Api.ts"import { CurrentUser } from "../../api/Authorization.ts"import { Users } from "../Users.ts"
export const UsersApiHandlers = HttpApiBuilder.group( Api, "users", Effect.fn(function*(handlers) { // Resolve dependencies once for the whole group const users = yield* Users
return handlers .handle("list", ({ query }) => users.list(query.search).pipe( // The `list` endpoint declares no errors, so any failure here must be // turned into an unexpected defect (becomes a 500). Effect.orDie ))
.handle("getById", ({ params }) => // `params.id` is already decoded to a branded UserId users.getById(params.id).pipe( // Surface only the UserNotFound reason as a typed failure; treat the // rest as defects. Effect.catchReasons("UsersError", { UserNotFound: (e) => Effect.fail(e) }, Effect.die) ))
.handle("create", ({ payload }) => users.create(payload).pipe(Effect.orDie))
.handle( "search", Effect.fn(function*({ payload }) { // Built-in HttpApiError types behave like any tagged error if (payload.search === "bad-request") { return yield* new HttpApiError.RequestTimeout() } return yield* users.list(payload.search).pipe( // Re-fail one reason, die on the others Effect.catchReason( "UsersError", "SearchQueryTooShort", Effect.fail, Effect.die ) ) }) )
// Endpoints with no inputs receive an empty object. This handler simply // returns a service provided by middleware (see the auth page). .handle("me", () => CurrentUser) })).pipe( // Provide everything the handlers need Layer.provide(Users.layer))Matching the endpoint’s error channel
Section titled “Matching the endpoint’s error channel”A handler may only fail with errors the endpoint declared. Anything else must
become a defect (an unexpected 500). The combinators that bridge your service
errors to the endpoint’s error channel are the standard ones from
Error Management:
Effect.orDie— the endpoint declares no errors; convert any failure to a defect.Effect.catch— the catch-all handler (the v4 replacement forcatchAll).Effect.catchTag/Effect.catchTags— handle specific tagged errors.Effect.catchReason/Effect.catchReasons— handle reasons inside a wrapper error likeUsersErrorwithout unwrapping it.
If you prefer to flatten a wrapper error’s reasons up to the top level so you can
use catchTag/catchTags, use Effect.unwrapReason:
import { Effect } from "effect"
users.create(payload).pipe( // Lift UsersError's reasons (UserNotFound | SearchQueryTooShort) to the top Effect.unwrapReason("UsersError"), Effect.catchTags({ UserNotFound: Effect.die, SearchQueryTooShort: Effect.die }))Wiring the group into the API
Section titled “Wiring the group into the API”HttpApiBuilder.group returns a Layer. You provide all your group layers to
HttpApiBuilder.layer(Api) when assembling the server — covered next in
Serving & clients. A trivial group can be
implemented inline:
import { Effect } from "effect"import { HttpApiBuilder } from "effect/unstable/httpapi"import { Api } from "../api/Api.ts"
export const SystemApiHandlers = HttpApiBuilder.group( Api, "system", Effect.fn(function*(handlers) { return handlers.handle("health", () => Effect.void) }))To secure these endpoints and inject the current user, continue to Middleware & auth.