Why architecture matters here

The architectural claim Tapir makes is that an API contract should be a program value, not prose. The costs of the alternative are familiar and chronic. Hand-written OpenAPI drifts within weeks of the first hotfix; annotation-driven frameworks bind the contract to the server's routing internals (so there is a spec, but only the server's behavior defines truth); generated clients lag the spec they were generated from. Every drift becomes an integration incident discovered at runtime by a consumer. When the contract is a typed value, the compiler enforces coherence: change a field's type and the server logic, the derived client, and the emitted documentation all change together or the build fails. Contract review becomes code review; breaking-change detection becomes diffing generated OpenAPI in CI.

The separation of description from logic pays in ways that compound. Testing: server logic is a plain function — unit-test it without HTTP; the description itself can be property-tested (round-trip codecs) and mock-served for consumer tests. Multi-backend freedom: the same endpoints served by http4s in one service and Netty in a latency-critical one; a migration between effect systems changes the interpreter line, not the API. Organizational scaling: endpoint values live in a shared 'API module' that both server and consumers depend on — the Scala monorepo's answer to protobuf IDLs, with the same discipline benefits (versioned, reviewed, owned) and full HTTP expressiveness.

And errors-as-types is the quiet quality win. HTTP APIs traditionally treat errors as an afterthought — a 500 with a string — because the framework makes success the only typed path. Tapir's oneOf error outputs force the error surface into the contract: consumers see exactly which failures exist, which status codes carry them, and what their bodies contain; the compiler ensures the server can only return declared failures. Teams that adopt this discipline discover their error handling was the least-designed part of their API — and fix it at the type level, once.

Advertisement

The architecture: every piece explained

Top row: the description algebra. An endpoint starts from a method and path — endpoint.get.in("orders" / path[OrderId]("id")) — and accumulates typed inputs: query[Option[Int]]("limit"), header[Auth], jsonBody[CreateOrder]; inputs compose into a tuple (or mapped case class) forming I. Outputs mirror them: status codes, headers, jsonBody[Order]. Schemas and codecs come from derivation: a Schema[Order] (structure, for docs and validation) and JSON codecs (circe/jsoniter/zio-json — pluggable) derive from case classes, with customization for naming, formats, validation bounds, and discriminators for ADTs. Error outputs use oneOf[ApiError] with variants mapped to status codes — oneOfVariant(statusCode(NotFound), jsonBody[OrderMissing]) — making the error ADT part of the wire contract.

Middle row: the interpreters. The server interpreter for your stack (http4s, Netty, ZIO HTTP, Play, Vert.x, even AWS Lambda) consumes endpoint.serverLogic(logic) — where logic is I => F[Either[E, O]] — and produces native routes: decoding inputs (with automatic 400s on decode failure), invoking logic, encoding the Either into the declared outputs. The client interpreter turns the same endpoint into an sttp request function I => F[Either[E, O]] — consumers in the monorepo call the API as a typed function; external consumers use the OpenAPI. The OpenAPI interpreter walks the description into a spec document (with Swagger/Redoc UI bundles served by — naturally — more endpoints), so docs deploy with the code that defines them. Server logic stays effect-polymorphic; the same endpoints serve IO or ZIO by choosing the interpreter flavor.

Bottom rows: the production concerns. Security inputs: endpoint.securityIn(auth.bearer[String]()) plus serverSecurityLogic splits authentication (token → F[Either[E, User]]) from business logic (User then I => ...), so auth is declared in the contract (and appears in OpenAPI's security schemes) and enforced uniformly. Observability attaches at the interpreter: metrics interceptors (request counts/latencies labeled by endpoint), tracing interceptors opening spans per endpoint with the description's name, uniform error-logging — cross-cutting concerns configured once per server, not per route. The ops strip: contract-first CI (generated OpenAPI diffed against the previous release; breaking changes gate merges), schema evolution discipline (additive fields optional-first), and interpreter parity tests (the same endpoint served and client-called in-process — round-trip tests that catch codec asymmetries).

Tapir — endpoints as data, interpreted everywhereone description: server, client, docsEndpoint[I, E, O]typed descriptionInputs/outputs DSLpath, query, body, headersSchemas + codecsderive from case classesError outputsoneOf typed failuresServer interpreterhttp4s / Netty / ZIO / PlayClient interpretersttp derived clientsOpenAPI interpreterdocs from the same valueServer logicI => F[Either[E, O]]Security inputsauth as typed inputsObservabilitymetrics + tracing interceptorsOps — contract-first CI + schema evolution + interpreter parityservecalldocumentattachauthenticateobserveverifyoperateoperate
Tapir: an Endpoint value describes inputs, outputs, and errors; interpreters turn the same value into a server route, a client, and OpenAPI docs.
Advertisement

End-to-end flow

Build an orders API the Tapir way and watch the three artifacts stay one. The API module declares the contract: getOrder, listOrders, createOrder, cancelOrder — each an Endpoint value with typed inputs, a shared ApiError ADT (NotFound, Forbidden, Conflict, Invalid) mapped via oneOf, and bearer auth as a security input. The server module attaches logic: createOrder.serverSecurityLogic(authenticate).serverLogic(user => body => service.create(user, body)) — the service layer speaks domain types and Either, never HTTP. One interpreter call mounts everything on http4s alongside the generated Swagger UI; the metrics interceptor labels every request by endpoint name; tracing spans carry the same names into the waterfall.

The consumer side shows the payoff. The mobile-BFF team (same monorepo) depends on the API module and derives an sttp client: calling createOrder is a typed function whose Either mirrors the server's — their error handling switches on the same ADT, and a contract change that breaks them breaks their compile, not their Friday deploy. The partner team (external) consumes the OpenAPI: generated from the same values, published per release, with CI diffing specs between versions — the quarter's one breaking change (a field type correction) was caught by the diff gate, routed through a v2 endpoint instead, and the deprecation of v1 tracked in the same codebase.

The subtle wins accumulate in maintenance. A new required header for fraud screening is added to the security input: every endpoint inherits it, the OpenAPI documents it, the in-repo clients get it automatically, and the external spec diff announces it. A load-related migration moves two hot endpoints from http4s to the Netty interpreter for lower overhead — twenty lines changed, zero contract impact, verified by the parity suite that serves-and-calls every endpoint in-process asserting round-trip equality. And when a consumer reports 'the docs say limit is optional but I get 400' — the classic drift bug — the answer is a five-minute investigation ending in their stale cached spec, because the server, client, and docs were provably generated from one value; the class of bug where the artifacts disagree simply has nowhere to live.