Why architecture matters here
GraphQL's power is also its danger: a single endpoint that lets clients ask for arbitrary graphs of data. That flexibility is exactly why the server's architecture has to be more disciplined than a REST server's, not less. Three properties matter above all, and Caliban's design addresses each structurally rather than by convention. The first is schema-resolver consistency. In stacks where the SDL is written by hand and resolvers are wired separately, every field is an opportunity for the two to diverge, and the divergence surfaces as a runtime error to a client. Caliban derives the schema from the resolver types at compile time, so a field that doesn't resolve, or resolves to the wrong type, is a compilation failure on your machine, not a 500 in production.
The second property is bounded, batched data access. A GraphQL query like 'list 100 authors and each of their books' naively produces 1 query for authors and 100 for books — the N+1 explosion that turns an innocent-looking query into a database stampede. This is not an edge case; it is the default behavior of naive resolvers, and it is the single most common way GraphQL servers fall over. Caliban's ZQuery makes batching the default rather than a manual optimization: independent fetches at the same level are collected, deduplicated, and issued as one batched request per data source, so the same query becomes 1 + 1 instead of 1 + 100.
The third property is governed cost. Because clients compose queries, they can — accidentally or maliciously — request deeply nested, hugely expensive graphs. A server without cost controls has effectively published a denial-of-service API. Caliban's wrapper mechanism lets you attach depth limits, field-count limits, and query-cost analysis as composable middleware that runs before execution, rejecting abusive queries cheaply. The architecture matters because all three properties — consistency, batching, cost control — are the difference between a GraphQL API that scales and one that becomes an incident; Caliban makes each of them a first-class, typed part of the server rather than an afterthought bolted on later.
A fourth property quietly follows from the first three: evolvability. Because the schema is derived from types, adding a field is adding a case-class member, removing one is deleting it, and the compiler immediately flags every resolver and caller the change touches. Refactoring a GraphQL API — normally a delicate exercise in keeping SDL, resolvers, and client expectations in sync by hand — becomes ordinary typed refactoring the compiler supervises. The rendered SDL, diffed in CI, turns every schema change into a visible, reviewable artifact, so a breaking change is caught at review time by a red diff rather than discovered by a client in production after it ships.
The architecture: every piece explained
Top row: schema from types. You write Scala case classes — a Queries record whose fields are the root queries, each field a function from arguments to a ZIO effect; likewise Mutations and Subscriptions. Schema derivation is a compile-time typeclass (Schema[R, A]) that inspects those types via Scala's macro/derivation machinery and builds the GraphQL type system: case classes become object types, sealed traits become unions or enums, Option becomes nullability, function arguments become field arguments. The derived GraphQL SDL is generated from this — you can render it for clients and codegen, but you never hand-edit it. An HTTP/WS adapter exposes the resulting GraphQLInterpreter over http4s, zio-http, or Play, handling the transport, request parsing, and WebSocket protocol for subscriptions.
Middle row: execution. An incoming query is parsed and validated against the derived schema — unknown fields, type mismatches, and malformed arguments are rejected before any resolver runs. Execution then walks the query tree, invoking field resolvers that are ordinary ZIO effects: they can require an environment R (database pool, auth context), fail with typed errors, and run concurrently for sibling fields. Where a resolver returns a ZQuery instead of a plain effect, Caliban's executor collects all ZQuery requests at a given level and hands them to their DataSource implementations in a single batched, deduplicated call — this is the N+1 fix, expressed as data rather than as manual dataloader wiring. The data sources themselves are your databases, downstream services, and caches, each with a batch function that turns a set of keys into a set of results.
Bottom rows: policy and streaming. Wrappers are the middleware layer — functions that wrap parsing, validation, execution, or individual field resolution — and they compose, so authentication, distributed tracing, query-cost analysis, depth limiting, and apollo-style metrics stack cleanly without touching resolver code. Subscriptions are resolvers that return a ZStream; the adapter bridges that stream to the GraphQL-over-WebSocket protocol, pushing each emitted element to the client and cancelling the stream (via ZIO interruption) when the socket closes. The ops strip is the production surface: query cost analysis to price incoming queries, depth and complexity limits to reject abusive ones, persisted queries to constrain clients to an approved set, and tracing to see where time goes across resolvers and data sources.
The ZQuery model deserves a closer look, because it is what distinguishes Caliban from a dataloader bolt-on. A ZQuery is not an effect that runs immediately; it is a description of the data a resolver needs, expressed so the executor can inspect and combine many such descriptions before running any of them. Sibling requests at the same level are gathered, deduplicated by key, and dispatched to each DataSource's batch function as a single call; the results are then scattered back to the resolvers that asked. Because this is data rather than imperative wiring, batching composes across nesting levels automatically — you do not manually create and thread a loader per field, you simply describe the fetch and let the executor batch it — which is why N+1 prevention in Caliban feels like the default rather than a discipline.
End-to-end flow
Follow a query end to end. A client sends: '{ authors(first: 100) { name books { title, reviews(first: 3) { rating } } } }'. The zio-http adapter receives it and hands the raw query to the GraphQLInterpreter. Parsing turns it into an AST; validation checks it against the derived schema — authors exists, first is an Int, books and reviews are valid selections — and, critically, the cost-analysis wrapper runs here: it estimates the query's complexity (100 authors x their books x 3 reviews) and, if it exceeds the configured budget, rejects it with a clear error before a single row is read. Suppose it's within budget.
Execution begins. The authors resolver runs one ZIO effect that fetches 100 authors — one query. Now the executor descends into the books field for all 100 authors simultaneously. A naive server fires 100 queries here; Caliban does not, because the books resolver returns a ZQuery keyed on author id. The executor collects all 100 keys, hands them to the books DataSource's batch function, which issues a single WHERE author_id IN (...) query, and distributes the results back to each author. The same happens one level deeper for reviews: all the review requests across all books are batched and deduplicated into one fetch. What looked like a 1 + 100 + 300 query explosion executes as 1 + 1 + 1.
Cross-cutting concerns rode along invisibly. An auth wrapper read the bearer token during request setup and put a user identity into the ZIO environment, so any resolver touching private fields could check it via ZIO.service. A tracing wrapper opened a span per field, so the resulting trace shows the authors fetch, the batched books fetch, and the batched reviews fetch as three spans with their real latencies — the N+1 fix is visible as three fast spans instead of 401 slow ones. The response is assembled in the exact shape the query requested and returned as JSON.
Now a subscription. A client subscribes to '{ orderUpdates(customerId: 42) { status } }'. The resolver returns a ZStream backed by a hub of order events filtered to customer 42. The WebSocket adapter forwards each emitted update to the client as it happens. When the client disconnects, the adapter interrupts the ZIO fiber running the stream; ZIO's structured concurrency guarantees the stream's finalizers run — unsubscribing from the hub, releasing any resources — so a dropped connection can't leak a subscription. If a resolver deep in a query fails with a typed error, Caliban places a GraphQL error at that field's path and returns partial data for the rest, exactly as the spec requires, rather than failing the whole response — and the failure, its cause, and the field path are in the trace for the on-call to read.
The federation and evolution story is worth one concrete note, because it is how this scales past a single team. Since Caliban can render the derived schema as SDL and supports the common federation conventions, a Caliban service can participate in a larger graph stitched from many teams' subgraphs — each team owning its types and resolvers, a gateway composing them. The same compile-time guarantee holds inside each subgraph: its slice of the schema matches its resolvers by construction. What crosses the network between subgraphs is the SDL contract, versioned and diffed, so a team can evolve its internals freely as long as the published portion of its schema stays compatible — the boundary discipline that lets a large GraphQL surface grow without collapsing into coordination overhead.