Skip to content

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 /graphql or 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-addresses gets an aggressive bot-protection rule; /product-search gets a high-throughput cache policy; /graphql gets 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-addresses does not overwhelm the catch-all endpoint's rate quota.
  • Enables domain-specific observability. Traffic to /customer-addresses is a proxy for address-update volume; a single /graphql log stream requires operation-name parsing to derive the same signal.

Trade-offs

  • Client-side coupling. Every caller of updateDeliveryAddress must 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-addresses is 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

Last updated · 501 distilled / 1,218 read