PATTERN Cited by 1 source
Directive-based HTTP endpoint partitioning¶
Problem¶
A vanilla GraphQL deployment collapses every operation —
every mutation, every query, every subscription — onto a
single HTTP path, canonically POST /graphql. This is the
ergonomic ideal from GraphQL's perspective but breaks the
edge ecosystem built around REST:
- Rate limiters keyed on URL path cannot discriminate
between cheap reads and expensive writes; they fire
uniformly at
/graphqlor not at all. - Bot / WAF rules operating on path patterns treat the whole graph as one endpoint; domain-specific rules (e.g. "block excessive address-change attempts") have no path to target.
- Caching layers (CDN / reverse proxy) cannot assign different TTLs to different operation families on a single POST path.
- External consumers (partners, webhooks) expecting REST-shaped URLs for specific operations see a single catch-all endpoint.
Pattern¶
Declare a schema directive on FIELD_DEFINITION that
takes an allowlist of HTTP paths; the directive's
resolver-wrap reads the request pathname from the GraphQL
context and rejects the field's resolution if the pathname
is not in the allowlist.
directive @requireExplicitEndpoint(
endpoints: [String!]!
) on FIELD_DEFINITION
type Mutation {
updateDeliveryAddress(
id: ID!
newAddress: CustomerAddress!
): UpdateDeliveryAddressResult
@requireExplicitEndpoint(endpoints: ["/customer-addresses"])
}
A client calling updateDeliveryAddress through the
catch-all /graphql endpoint fails with an explicit
endpoint-mismatch error; the same mutation succeeds when
called through /customer-addresses. The GraphQL server
still serves both paths — they hit the same parser and
resolver graph — but the directive enforces that each
field is reachable only through its declared paths
(Source: sources/2023-10-18-zalando-understanding-graphql-directives-practical-use-cases-at-zalando).
What this buys¶
- Restores REST-tool compatibility. Edge-layer rate
limiters, bot filters, caches, and WAF rules can target
domain-specific paths.
/customer-addressesgets an aggressive bot-protection rule;/product-searchgets a high-throughput cache policy;/graphqlgets the default-permissive rule. - Shrinks attack surface. A query that attempts to
pivot from a public field (served at
/graphql) to a sensitive mutation is blocked at the schema-gate level without needing a separate HTTP-firewall rule. - Partitions operational blast radius. A runaway client
hammering
/customer-addressesdoes not overwhelm the catch-all endpoint's rate quota. - Enables domain-specific observability. Traffic to
/customer-addressesis a proxy for address-update volume; a single/graphqllog stream requires operation-name parsing to derive the same signal.
Trade-offs¶
- Client-side coupling. Every caller of
updateDeliveryAddressmust know to send to/customer-addresses, not/graphql. The UBFF's persisted-query registration flow is the natural place to record this — build-time tooling can detect the directive and emit the right path into the client bundle. - Proliferation risk. Each per-domain path creates a
new URL surface for edge rules to maintain. The
discipline is to partition along operational lines (
rate-limit / cache / WAF axis), not one-path-per-
mutation; Zalando's
/customer-addressesis a family, not a single operation. - Transport-coupling to HTTP. The directive reads the pathname from the resolver context; subscription transports (WebSocket) or internal gRPC bindings of the same graph would not have a path to check. Zalando's post does not address this; the directive is HTTP-specific.
- Schema-declares-deployment-shape. The set of endpoints is in the schema, not in deployment config. Adding a new endpoint requires a schema change + deploy. Some teams prefer the opposite axis (deployment declares which fields it exposes), at the cost of losing the schema-as-policy-source property.
Relationship to disable-GraphQL-in-production¶
patterns/disable-graphql-in-production closes the door
on arbitrary GraphQL at the query level (only persisted-
query IDs accepted at the production endpoint).
@requireExplicitEndpoint closes a different door at
the HTTP-path level (even a persisted query can only
execute at its declared paths). The two compose naturally:
together they mean the production endpoint accepts only
known query IDs, each executing only at its designated
path(s), no wildcards in either dimension.
Seen in¶
- Zalando UBFF — canonical instance.
@requireExplicitEndpoint(endpoints: [String!]!) on FIELD_DEFINITIONwith HTTP-pathname-based enforcement. Example:updateDeliveryAddressrestricted to/customer-addresses(sources/2023-10-18-zalando-understanding-graphql-directives-practical-use-cases-at-zalando, systems/zalando-graphql-ubff).
Related¶
- concepts/graphql-schema-directive — the primitive carrying the allowlist.
- patterns/directive-based-field-authorization · patterns/directive-based-pii-redaction — sibling schema-directive patterns addressing adjacent concerns.
- patterns/disable-graphql-in-production — companion axis: close the query-level door as well as the path-level door.
- systems/graphql · systems/zalando-graphql-ubff