Building an HTTP API

End-to-end walkthrough: define a schema-first HttpApi, implement handlers, add middleware, serve it, and test with an in-memory client.

Define the API

Keep API definitions separate from server implementation. This lets you share the API definition between server and client without leaking server code.

Domain Model

import { Schema } from "effect"
import { Model } from "effect/unstable/schema"

export const UserId = Schema.String.pipe(Schema.brand("UserId"))
export type UserId = typeof UserId.Type

export class User extends Model.Class<User>("User")({
  id: Model.UuidV4Insert(UserId),
  name: Schema.String,
  email: Schema.String,
  createdAt: Model.DateTimeInsert,
  updatedAt: Model.DateTimeUpdate
}) {}

Model.Class derives variants for different boundaries: User (database), User.json (API response), User.jsonCreate (create payload), User.jsonUpdate (update payload).

Error Types

import { Schema } from "effect"

export class UserNotFound extends Schema.TaggedError<UserNotFound>()("UserNotFound", {
  message: Schema.String
}) {}

export class Unauthorized extends Schema.TaggedError<Unauthorized>()(
  "Unauthorized",
  { message: Schema.String },
  { httpApiStatus: 401 }
) {}

Tip: Define HTTP status codes directly on error classes with the httpApiStatus annotation. This keeps the mapping co-located with the error definition.

Middleware Definition

import { Context, Schema } from "effect"
import { HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
import type { User } from "../domain/User.ts"

export class CurrentUser extends Context.Service<CurrentUser, User>()("acme/CurrentUser") {}

export class Authorization extends HttpApiMiddleware.Service<Authorization, {
  provides: CurrentUser
  requires: never
}>()("acme/Authorization", {
  requiredForClient: true,
  security: { bearer: HttpApiSecurity.bearer },
  error: Unauthorized
}) {}

Middleware can provide services to downstream endpoints (like injecting CurrentUser) and define security schemes for OpenAPI docs.

API Group and Root API

import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { User, UserId } from "../domain/User.ts"
import { UserNotFound } from "../domain/UserErrors.ts"
import { Authorization } from "./Authorization.ts"

export class UsersApiGroup extends HttpApiGroup.make("users")
  .add(
    HttpApiEndpoint.get("list", "/", {
      query: { search: Schema.optional(Schema.String) },
      success: Schema.Array(User.json)
    }),
    HttpApiEndpoint.get("getById", "/:id", {
      params: { id: UserId },
      success: User.json,
      error: UserNotFound.pipe(HttpApiSchema.asNoContent({ decode: () => new UserNotFound() }))
    }),
    HttpApiEndpoint.post("create", "/", {
      payload: User.jsonCreate,
      success: User.json
    }),
    HttpApiEndpoint.patch("update", "/:id", {
      params: { id: UserId },
      payload: User.jsonUpdate,
      success: User.json,
      error: UserNotFound.pipe(HttpApiSchema.asNoContent({ decode: () => new UserNotFound() }))
    })
  )
  .middleware(Authorization)
  .prefix("/users")
  .annotateMerge(OpenApi.annotations({ title: "Users", description: "User management endpoints" }))
{}
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
import { UsersApiGroup } from "./Users.ts"
import { SystemApi } from "./System.ts"

export class Api extends HttpApi.make("user-api")
  .add(UsersApiGroup)
  .add(SystemApi)
  .annotateMerge(OpenApi.annotations({ title: "Acme User API" }))
{}

Gotcha: Path parameters are automatically coerced from strings using Schema.toCodecStringTree. Schemas that decode from other types (like branded strings or numbers) work in path params without extra configuration.

Implement Handlers

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) {
    const users = yield* Users

    return handlers.handleAll({
      list: ({ query }) =>
        users.list(query.search).pipe(Effect.orDie),

      getById: ({ params }) =>
        users.getById(params.id).pipe(
          Effect.catchTags({
            NoSuchElementError: () => Effect.fail(new UserNotFound())
          })
        ),

      create: ({ payload }) =>
        users.create(payload).pipe(Effect.orDie),

      update: ({ params, payload }) =>
        users.update(params.id, payload).pipe(
          Effect.catchTags({
            NoSuchElementError: () => Effect.fail(new UserNotFound())
          })
        ),

      me: () => CurrentUser
    })
  })
).pipe(
  Layer.provide([Users.layer, AuthorizationLayer])
)

Tip: Split handlers into a NoDeps version and a fully-provided version. Tests can supply alternative implementations (like an in-memory store) to the NoDeps version.

Serve the API

import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"
import { Effect, Layer } from "effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { HttpApiBuilder, HttpApiScalar } from "effect/unstable/httpapi"
import { createServer } from "node:http"
import { Api } from "./api/Api.ts"
import { UsersApiHandlers } from "./server/Users/http.ts"

const SystemApiHandlers = HttpApiBuilder.group(
  Api,
  "system",
  Effect.fn(function*(handlers) {
    return handlers.handleAll({
      health: () => Effect.void
    })
  })
)

const ApiRoutes = HttpApiBuilder.layer(Api, {
  openapiPath: "/openapi.json"
}).pipe(
  Layer.provide([UsersApiHandlers, SystemApiHandlers])
)

const DocsRoute = HttpApiScalar.layer(Api, { path: "/docs" })

const AllRoutes = Layer.mergeAll(ApiRoutes, DocsRoute)

export const HttpServerLayer = HttpRouter.serve(AllRoutes).pipe(
  Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 }))
)

Layer.launch(HttpServerLayer).pipe(
  NodeRuntime.runMain
)

For serverless environments, convert to a web handler:

export const { handler, dispose } = HttpRouter.toWebHandler(
  AllRoutes.pipe(Layer.provide(HttpServer.layerServices))
)

Test with In-Memory Client

HttpApiTest.groups builds a typed client wired directly to handlers. No HTTP server, no database.

import { assert, layer } from "@effect/vitest"
import { Effect, Layer } from "effect"
import { HttpClientRequest, HttpServer } from "effect/unstable/http"
import { HttpApiMiddleware, HttpApiTest } from "effect/unstable/httpapi"
import { Api } from "./fixtures/api/Api.ts"
import { Authorization } from "./fixtures/api/Authorization.ts"
import { Users } from "./fixtures/server/Users.ts"
import { UsersApiHandlersNoDeps } from "./fixtures/server/Users/http.ts"

const HandlersLayer = UsersApiHandlersNoDeps.pipe(
  Layer.provide(Users.layerMemory),
  Layer.provideMerge(AuthorizationLayer)
)

const GoodAuth = HttpApiMiddleware.layerClient(
  Authorization,
  ({ next, request }) => next(HttpClientRequest.bearerToken(request, "dev-token"))
)

const makeClient = HttpApiTest.groups(Api, ["users"])

layer(Layer.mergeAll(HandlersLayer, HttpServer.layerServices))("UsersApi", (it) => {
  it.effect("creates and fetches a user", () =>
    Effect.gen(function*() {
      const client = yield* makeClient

      const created = yield* client.users.create({
        payload: { name: "Alice", email: "alice@acme.dev" }
      })
      assert.strictEqual(created.name, "Alice")

      const fetched = yield* client.users.getById({
        params: { id: created.id }
      })
      assert.deepStrictEqual(fetched, created)
    }).pipe(Effect.provide(GoodAuth)))

  it.effect("returns 404 for missing user", () =>
    Effect.gen(function*() {
      const client = yield* makeClient
      const error = yield* client.users.getById({
        params: { id: UserId.make("nonexistent") }
      }).pipe(Effect.flip)
      assert.strictEqual(error._tag, "UserNotFound")
    }).pipe(Effect.provide(GoodAuth)))
})

Gotcha: The typed client mirrors your API definition. Renames and schema changes are checked end-to-end at compile time. If you change an endpoint name, the test client call breaks immediately.