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
httpApiStatusannotation. 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
NoDepsversion and a fully-provided version. Tests can supply alternative implementations (like an in-memory store) to theNoDepsversion.
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.