Field Note 69current

Register the query shape, don't send it

GraphQL is right that a developer should declare the fields and shape they want, and wrong about when. Register that document with the server instead of sending it: it can then be planned and indexed for, and the registered set is a schema you generate the caller's own SDK from.

By
Theo Zourzouvillys
Published
Est. reading time
Tags
apidesignarchitectureinterop
My Personal LLM Policy: Extract, not generate

The thinking is mine, whether it’s years old or from this week. What a model does is get it out of my head and onto the page — writing time I’d otherwise never spend, not substance I didn’t have. I read every line, I can defend any sentence, and the errors are mine: the same bar I hold everything here to, model or no model.

TL;DR

Keep GraphQL’s declaration; change when it arrives. The developer still writes down the fields they want and the shape to get them in. That document is registered with the server ahead of traffic instead of travelling on every request.

  • A shape the server holds before the first call is one it can compile, plan, index for and price. A shape that arrives with the request is one it can only defend itself against.
  • Development, test and staging stay fully dynamic — change the query on every request, or post the whole document to an endpoint that just answers it. Registration is what happens on the way to production, and it is a build step rather than something a person does by hand.
  • Registering is not instant. It may build an index or materialise a projection over a lot of existing data, so a shape has a readiness state and a client cannot call a version until it reports ready. This is closer to a schema migration than to adding a row to a safelist.
  • The registered shapes are themselves a schema: a closed, typed surface describing what one caller actually asks for. Generate that caller’s SDK from it — and because every client has declared the schema it expects, compatibility becomes something you can check rather than something you hope holds.
  • The prior art is persisted queries and trusted documents. What tends to be left on the table is treating registration as the moment the server learns the shape, rather than as a bandwidth saving with a safelist attached.

Context

There are two ends of an axis here and both of them are uncomfortable.

At one end the server owns the shape outright. Endpoints return what they return, and a client that needs a slightly different view of the same data either waits for a server change or fetches the whole object and discards most of it. That position never holds, because the pressure on it is constant and the release valve is cheap: an expand parameter here, an include and a fields there, and a few years later there is a query language in the codebase that nobody designed, nobody wrote down, and nobody can plan against. Partial responsesGoogle AIP-157 — Partial responsesCovers giving a caller control over what a large or expensive resource returns, by two routes: a field mask passed as a system parameter — query string, header or metadata — for fine-grained control over which fields come back, and a view enumeration offering predefined scopes such as BASIC and FULL. Selection within a shape the service already owns.google.aip.dev ↗ are the disciplined version of that valve — a field mask, or a named view like BASIC and FULL — and they reach only as far as selecting within a shape the service already owns.

At the other end the caller owns it. GraphQL’s insight — the part I think is straightforwardly right — is that the developer knows the shape they want and should be able to state it: these fields, this nesting, this list bounded this way. What it also does is put that statement on the wire. Every request then carries a query the server is meeting for the first time. It cannot have planned for it, it cannot have built an index for it, and it cannot say what it will cost until it is already paying. Depth limits, complexity scoring and query budgets are all attempts to bound something whose shape you learn at the moment you have to answer it (ZFN-13Field Note · currentZFN-13 — Fail fast and push back: retries, load shedding, and flow controlBuild client retries (backoff, jitter, Retry-After) from day one. Under overload, shed fast and push the failure back to the source to retry — don't retry internally and amplify it. Flow-control everywhere, bound every queue, and don't take more work than you can finish in time.Why it's cited here: What an unregistered shape costs you. A query whose plan you first see at execution time is work the caller sizes and you absorb.Open ZFN-13 →, ZFN-53Field Note · currentZFN-53 — Make abuse cost money: attack the unit economics, not the identityAbuse at scale is a business with a P&L. Detection is an arms race you eventually lose, because the attacker gets unlimited free queries against your classifier. Instead find the metered input they can't substitute away from, and inflate it — per attempt, dialled by risk.Why it's cited here: The same exposure read as abuse rather than as load. An arbitrary-shape endpoint lets the cheap side of the transaction choose how expensive the expensive side is.Open ZFN-53 →).

The declaration and the delivery are separable, and nearly all of the value is in the declaration.

Recommendation

Have the developer declare the shape, then register it with the server rather than sending it. The authored artefact looks like the document it replaces — the fields, the nesting, the shape of the response — and it is written by the same person for the same reason. It simply arrives before the traffic does, gets a stable id, and the request carries that id and its variables (ZFN-51Field Note · currentZFN-51 — Design the request envelope before the first endpointAuth, idempotency keys, trace IDs, vector clocks: context about a request, not part of it. Define an envelope alongside the payload in the schema on day one — transport-native, per-hop vs propagated, owned by generated SDKs and middleware. The retrofit is what costs you.Why it's cited here: Where the registration id and its variables actually ride. The shape moves out of the request body, so the request needs a defined place to name which shape it is invoking.Open ZFN-51 →).

The registration step

A shape held in advance is a shape you can do the expensive work on once. This is what a database does with a prepared statementPostgreSQL — PREPARECreates a server-side prepared statement. The statement is parsed, analysed and rewritten once when PREPARE runs, so the work that would otherwise be repeated on every submission of the same statement is done once and reused across executions.postgresql.org ↗: parse it, resolve it against the schema, choose a plan, keep the plan. Registration moves parsing, validation, authorization analysis and planning off the hot path and onto a shape you can take your time over. A join gets planned as a join instead of discovered as an N+1 walk at request time, because there is a moment at which the whole traversal is visible and nothing is waiting on it.

Registration is the last honest moment to say no. A registered shape can be checked against the indexes that actually exist, and the missing one becomes a build failure rather than a slow endpoint somebody finds in a dashboard a month later. It can be costed once and carry that cost as a quota, which is a far better position than scoring a stranger’s query while it runs. It can be checked against what the caller is permitted to see, once, rather than field by field on every traversal. None of that is available when the shape and the data arrive together.

Registering a shape is asynchronous, and the shape has a state. Accepting a registration is not the same as being ready to answer it. Preparing one may mean building an index, or materialising a projection across a large amount of data that already exists, and that is minutes or hours rather than milliseconds. So a shape moves through submitted, preparing and ready, and a client cannot call a version until that version reports ready — which makes registration itself a long-running operation, with a handle to poll or subscribe to rather than a response to wait on (ZFN-67Field Note · currentZFN-67 — The API I'd build todayWhat a new API needs before the first endpoint: operations kept apart, idempotency keys that replay the response, state tokens, DPoP, quota in requests and in work, regions, an archive. Cheap to decide before you have callers, a migration afterwards. Specified in ZBP-7.Why it's cited here: The checklist this note fills a gap in. It argues that customer-extended schema has to be a day-one decision; registration is the mechanism that lets those extensions be planned and typed rather than dumped in a blob.Open ZFN-67 →). I think this is the part that catches people out, because every intuition borrowed from safelisting says registration is a write to a list. It behaves far more like a schema migration, and it wants the same respect: start it early, watch it, and expect the occasional one to be genuinely slow.

This is persisted queries, generalized. RelayRelay — Persisted QueriesRelay's compiler converts every query and mutation operation text in the application to an md5 hash at build time; the client then sends a `doc_id` instead of the operation text, and the server looks the text up in a query map. The documentation notes this lets the server allowlist queries, restricting the operations a client can execute.relay.dev ↗ has extracted operations at build time and shipped hashes for years, and safelistingApollo GraphOS — Persisted QueriesDescribes a persisted query list as an allowlist of trusted operations: the router fetches the list and enforces safelisting against it, and can optionally require clients to send operation IDs rather than full operation strings.apollographql.com ↗ the registered set is a standard production posture. I am not claiming novelty in the mechanism. The claim is about what it is for: these are usually presented as a transport optimization with a security benefit attached, where the server’s job is to look the text up and then behave exactly as it would have. Treat registration instead as the point at which the server learns the shape, and what the server does on the other side of it is different work.

Development and production

Ad-hoc querying is the correct development experience, so keep it. Exploring a data model, a one-off answer to a support question, a console somebody opens twice a year — all of these want an unregistered query, and the reasons to restrict them are absent before production. Development, test and staging should run fully dynamic: the client sends its query document with the request and is free to change it between one call and the next, and there is an endpoint that takes a whole document and simply answers it. Nothing is registered, nothing is pinned, nothing has to be prepared in advance. That is GraphQL, and in that environment GraphQL is the right thing to be.

Registration is therefore a promotion step. It is what a shape goes through on its way to production, not a tax on writing one. The developer iterates dynamically until the shape is the shape they want, and registering it is how they say they are done — which is a much easier thing to ask for than “declare everything up front”, and gets you the same artefact at the end.

The gap between those two modes is what will hurt, so close it mechanically. A shape that worked in a console yesterday and returns an error in production today is the obvious failure, and it will happen on the day somebody is in a hurry. So registration belongs to the build: the same pass that generates the client extracts the shapes and publishes them, and nobody registers one by hand (ZFN-14Field Note · currentZFN-14 — Define every API with a schema, and generate the clientsDefine every API with a machine-readable schema (OpenAPI, Protobuf, GraphQL) as the source of truth, and generate clients and server stubs from it — never hand-roll request-building and JSON parsing. Hand-written clients drift and break silently; check schema compatibility in CI.Why it's cited here: The rule this note reroutes. Generating clients from a schema is settled; the argument here is about which schema you generate from once the shapes are known per caller.Open ZFN-14 →). A shape missing in production is then a build that did not run, which is a class of problem with existing habits around it.

For a public API, the third party registers too. That is more friction than accepting any query anyone sends, and I think it is friction in the right place — what you get back is a per-customer contract that concretely exists, rather than one you reconstruct from traffic on the day you want to change something. It also means “who is still using this field” is answered by a lookup instead of a log query.

Versions and views

Every client declares the schema it expects, so compatibility stops being a guess. A registered shape names the version it was written against, and the registry therefore knows — precisely, per client, rather than in aggregate — what each caller depends on. Backward compatibility becomes a check instead of a policy: a field cannot be withdrawn while a registered shape still references it, and that is answered when the withdrawal is proposed rather than by a log query and a hopeful email. Forward compatibility is the same fact read the other way. A client pinned to a shape keeps being served that shape while the schema underneath it moves, because what it asked for is written down somewhere the server can honour.

One client is several views, and each should be registerable on its own. A list, a detail screen, a settings page and a nightly export all read the same resources through quite different shapes, and collapsing them into one registration per application throws away everything the split is good for. Keep the view as the unit. Then each one is costed on its own, versioned on its own, and rolled forward on its own — and one view that is still building an index does not hold back the four that are ready.

The schema that falls out

The registered set is a schema in its own right. The service schema describes everything the system could be asked; a caller’s registered shapes describe what that caller does ask. The second is much smaller, closed and fully typed, and it did not exist as an artefact until the registration step created it.

Generate the caller’s SDK from the caller’s schema. ZFN-14Field Note · currentZFN-14 — Define every API with a schema, and generate the clientsDefine every API with a machine-readable schema (OpenAPI, Protobuf, GraphQL) as the source of truth, and generate clients and server stubs from it — never hand-roll request-building and JSON parsing. Hand-written clients drift and break silently; check schema compatibility in CI.Why it's cited here: The rule this note reroutes. Generating clients from a schema is settled; the argument here is about which schema you generate from once the shapes are known per caller.Open ZFN-14 → argues for generating clients from a machine-readable schema and I still hold all of it; this changes which schema you feed in. A client generated from the service schema is mostly surface one caller will never touch — optional fields, variants, resources they have no access to — and the ergonomics problem that follows is why people hand-write wrappers around generated clients in the first place. Generate from the registered set and the result is that caller’s own types, carrying the fields they asked for and nothing else, with a compile error when a shape they registered is withdrawn.

It gives per-tenant extension somewhere to land. ZFN-67Field Note · currentZFN-67 — The API I'd build todayWhat a new API needs before the first endpoint: operations kept apart, idempotency keys that replay the response, state tokens, DPoP, quota in requests and in work, regions, an archive. Cheap to decide before you have callers, a migration afterwards. Specified in ZBP-7.Why it's cited here: The checklist this note fills a gap in. It argues that customer-extended schema has to be a day-one decision; registration is the mechanism that lets those extensions be planned and typed rather than dumped in a blob.Open ZFN-67 → argues that customers extending the schema is a day-one decision rather than something to bolt on later, and this is the half of it that makes the extensions usable: a customer who has defined their own fields writes shapes that reference them, and those fields are typed in that customer’s SDK on the same path as everything else, instead of being reachable only through an untyped blob nailed to the side of the response.

Consequences

Easier:

  • Cost is knowable before traffic rather than estimated during it, and the estimate has something stable to attach itself to.
  • Index coverage becomes a build-time property. “Is there an index behind this” acquires an owner and a moment.
  • The safelist is a by-product. You did not build an allowlist; you have one.
  • SDKs get small and specific, which is mostly a documentation win — the surface a developer reads is the surface they use.
  • Compatibility is computable. Because every client has declared the shape and the schema version it expects, “can this field go yet” is a question you ask the registry rather than infer from traffic.

Harder:

  • You now operate a registry, and a registry is state with a lifecycle: register, supersede, garbage-collect. Shapes nobody calls still consume plans, indexes and review attention until removing them is somebody’s job.
  • Rollout gains an ordering constraint and a clock. Shapes go out before the client that calls them and are withdrawn after the last caller stops — expand/migrate/contract wearing a different hat (ZFN-62Field Note · currentZFN-62 — Expand, migrate, contract: schema changes in three movesEvery deploy runs two code versions against one database — and rollback runs yesterday's code on today's schema. No schema change may break either. So every migration is three shippable moves: expand (additive), migrate (backfill, verify), contract (remove, later, deliberately).Why it's cited here: The ordering that makes a registry safe to change. Shapes go out before the client that uses them and are withdrawn after the last one stops calling.Open ZFN-62 →) — and because preparing one can take minutes or hours, registering early stops being good manners and becomes a release requirement. A deploy that ships a client ahead of its shapes is a deploy that fails in production having passed everywhere else.
  • The development/production asymmetry is a real trap and stays one. Closing it mechanically reduces how often it fires, not how sharp it is.
  • Emergency queries need a designed answer. Somebody will need an unregistered shape while an incident is in progress, and “no” is not it — an audited, privileged, rate-limited ad-hoc path is.

New obligations:

  • A registered shape is a published contract and has to be treated like one, including deprecation with a date and telemetry that proves the last caller has gone (ZFN-47Field Note · currentZFN-47 — Govern the contract between teams, not the code inside themTeams own services end to end; one team owns the gateway that dispatches to them. Govern exactly one thing centrally — the contract at the boundary (schema, identity, errors, idempotency) — and enforce it at runtime. Don't mandate libraries; ship them as an opt-in blueprint.Why it's cited here: What a registry becomes once someone else's build depends on it. A registered shape is a published contract, not a cache entry.Open ZFN-47 →).
  • Submitting a registration has to be fast and boring, even though preparing one need not be. Everything above assumes developers reach for it without thinking, and if registering a shape takes a ticket they will design around it and you get four shapes where one would have done. Slow is fine; slow and manual is not.
  • A shape that is preparing is a normal state, so everything that touches one has to have an answer for it — the deploy that is waiting, the dashboard that shows it, and the developer who wants to know whether it is stuck or merely large.
  • Somebody owns the answer to “why was this shape rejected”, and it has to be legible at the moment of rejection rather than reconstructible afterwards.

References

  • PostgreSQL — PREPAREPostgreSQL — PREPARECreates a server-side prepared statement. The statement is parsed, analysed and rewritten once when PREPARE runs, so the work that would otherwise be repeated on every submission of the same statement is done once and reused across executions.postgresql.org ↗ — the mechanical analogy, and the reason this is less a new idea than an old one applied a layer up.
  • Relay — Persisted QueriesRelay — Persisted QueriesRelay's compiler converts every query and mutation operation text in the application to an md5 hash at build time; the client then sends a `doc_id` instead of the operation text, and the server looks the text up in a query map. The documentation notes this lets the server allowlist queries, restricting the operations a client can execute.relay.dev ↗ — build-time extraction and hashing, with allowlisting named as the security benefit.
  • Apollo GraphOS — Persisted QueriesApollo GraphOS — Persisted QueriesDescribes a persisted query list as an allowlist of trusted operations: the router fetches the list and enforces safelisting against it, and can optionally require clients to send operation IDs rather than full operation strings.apollographql.com ↗ — the registered set enforced as a safelist at the router.
  • Google AIP-157 — Partial responsesGoogle AIP-157 — Partial responsesCovers giving a caller control over what a large or expensive resource returns, by two routes: a field mask passed as a system parameter — query string, header or metadata — for fine-grained control over which fields come back, and a view enumeration offering predefined scopes such as BASIC and FULL. Selection within a shape the service already owns.google.aip.dev ↗ — the far end of the axis: field masks and named views, selecting within a shape the service owns.
  • ZFN-14Field Note · currentZFN-14 — Define every API with a schema, and generate the clientsDefine every API with a machine-readable schema (OpenAPI, Protobuf, GraphQL) as the source of truth, and generate clients and server stubs from it — never hand-roll request-building and JSON parsing. Hand-written clients drift and break silently; check schema compatibility in CI.Why it's cited here: The rule this note reroutes. Generating clients from a schema is settled; the argument here is about which schema you generate from once the shapes are known per caller.Open ZFN-14 → — schema-first and generated clients. This note is about which schema.
  • ZFN-51Field Note · currentZFN-51 — Design the request envelope before the first endpointAuth, idempotency keys, trace IDs, vector clocks: context about a request, not part of it. Define an envelope alongside the payload in the schema on day one — transport-native, per-hop vs propagated, owned by generated SDKs and middleware. The retrofit is what costs you.Why it's cited here: Where the registration id and its variables actually ride. The shape moves out of the request body, so the request needs a defined place to name which shape it is invoking.Open ZFN-51 → — where the shape id and its variables ride.
  • ZFN-66Field Note · currentZFN-66 — Agonize over the interface, not the choice behind itRe-implementing a decision now costs hours. Changing an interface costs everyone standing on it. Spend deliberation at the boundary; hold the choice behind it loosely — on one condition: you know a decision was made, and where it lives. Unnoticed ones are the expensive kind.Why it's cited here: Why the friction of a registration step is the right kind of friction. A query shape is a boundary other people stand on, which is exactly where deliberation is meant to be spent.Open ZFN-66 → — why a step that slows a boundary change down is the right place to spend deliberation.
  • ZFN-13Field Note · currentZFN-13 — Fail fast and push back: retries, load shedding, and flow controlBuild client retries (backoff, jitter, Retry-After) from day one. Under overload, shed fast and push the failure back to the source to retry — don't retry internally and amplify it. Flow-control everywhere, bound every queue, and don't take more work than you can finish in time.Why it's cited here: What an unregistered shape costs you. A query whose plan you first see at execution time is work the caller sizes and you absorb.Open ZFN-13 → / ZFN-53Field Note · currentZFN-53 — Make abuse cost money: attack the unit economics, not the identityAbuse at scale is a business with a P&L. Detection is an arms race you eventually lose, because the attacker gets unlimited free queries against your classifier. Instead find the metered input they can't substitute away from, and inflate it — per attempt, dialled by risk.Why it's cited here: The same exposure read as abuse rather than as load. An arbitrary-shape endpoint lets the cheap side of the transaction choose how expensive the expensive side is.Open ZFN-53 → — what an unplannable query costs, read as load and read as abuse.
  • ZFN-62Field Note · currentZFN-62 — Expand, migrate, contract: schema changes in three movesEvery deploy runs two code versions against one database — and rollback runs yesterday's code on today's schema. No schema change may break either. So every migration is three shippable moves: expand (additive), migrate (backfill, verify), contract (remove, later, deliberately).Why it's cited here: The ordering that makes a registry safe to change. Shapes go out before the client that uses them and are withdrawn after the last one stops calling.Open ZFN-62 → — the ordering that makes withdrawing a shape safe.
  • ZFN-67Field Note · currentZFN-67 — The API I'd build todayWhat a new API needs before the first endpoint: operations kept apart, idempotency keys that replay the response, state tokens, DPoP, quota in requests and in work, regions, an archive. Cheap to decide before you have callers, a migration afterwards. Specified in ZBP-7.Why it's cited here: The checklist this note fills a gap in. It argues that customer-extended schema has to be a day-one decision; registration is the mechanism that lets those extensions be planned and typed rather than dumped in a blob.Open ZFN-67 → — the wider checklist, including the case for treating customer-extended schema as a day-one decision.

Changelog

  • 2026-08-30: Amended. Development and staging run fully dynamic, with an endpoint that answers a whole query document, so registration is a promotion step rather than a tax on writing a shape; registering is asynchronous and a shape carries a readiness state; per-client declared schemas make backward and forward compatibility checkable; views are registered individually rather than one registration per client.
  • 2026-08-30: First published as a Field Note.