Skip to content

PATTERN Cited by 2 sources

Directive-based field lifecycle

Problem

A new GraphQL field goes through three stages between creation and non-breaking stability:

  1. Shape is still changing. Names, types, optionality may flip. No client should use it yet.
  2. Shape is locked enough for an experiment. One or two UI surfaces will try it; if the shape still isn't right, a breaking change should only force migration of those surfaces.
  3. Shape is stable. Any persisted query may use it; further changes require the usual non-breaking discipline.

Most GraphQL organisations handle stage 1 with branch deployments, stage 2 with feature flags, and stage 3 with @deprecated. Each of those tools is deployed at a different altitude (repo branch, runtime config, schema metadata); they don't compose cleanly, and branch deployments in particular are "a nightmare in reality" when the GraphQL layer aggregates 3-5 downstream services per feature (Source: sources/2022-02-16-zalando-graphql-persisted-queries-and-schema-stability).

Solution

Encode all three lifecycle stages as custom schema directives on the field itself, and enforce the lifecycle at the persist step of an automatic-persisted- queries pipeline running in gate mode. The stage a field is in is a fact about the schema, not about a branch or a config store.

Zalando's three-stage instantiation uses three directives:

directive @draft on FIELD_DEFINITION
directive @component(name: String!) on QUERY
directive @allowedFor(componentNames: [String!]!) on FIELD_DEFINITION

Stage 1 — @draft

type Product {
  fancyNewField: FancyNewType @draft
}

Effect: persist-time validator refuses any query that touches fancyNewField. Field is deployable to mainline and to production infra; it just cannot be referenced from a persisted query. Breakable at will. See concepts/draft-schema-field.

Stage 2 — @allowedFor

type Product {
  fancyProp: String @allowedFor(componentNames: ["web-product-card"])
}

query productCard @component(name: "web-product-card") {
  product {
    fancyProp
  }
}

Effect: persist-time validator only accepts queries whose @component(name: …) is in the field's @allowedFor(componentNames: […]) list. Field ships to production but only on the named UI surface(s); a subsequent breaking change has a known, minimal migration set. See concepts/component-scoped-field-access.

Stage 3 — stable

Both annotations are removed. The field joins the "non-breaking contract in form of persisted queries". Any persisted query may reference it; subsequent changes follow the usual non-breaking discipline (add, don't remove; deprecate before deleting; etc.) (Source: sources/2022-02-16-zalando-graphql-persisted-queries-and-schema-stability).

Why all three stages live in directives

The Zalando post's key claim is that encoding the lifecycle in schema directives gives two properties at once:

  1. Mainline discipline. The unreleased schema is already in mainline and deployed. Feature teams building cross-service changes do not maintain parallel feature branches across 3-5 repos.
  2. Persist-time enforcement, not runtime. The check is a static property of the schema + query-set. No runtime evaluation overhead; no surprise prod miss.

Both properties fall out of the fact that the enforcement point is the persist step, which runs once per query at UI build time.

Why it requires gate-mode persisted queries

The pattern does not work with cache-mode APQ. If unknown hashes fall back to raw-query execution, a client can bypass the @draft check by sending a raw query that references the draft field. The directives only bite when every production query must come from the persisted- queries DB — i.e., under patterns/disable-graphql-in-production.

When this pattern fits

  • You already run persisted queries in gate mode (i.e., patterns/disable-graphql-in-production is in place).
  • Schema evolution rate is high enough that traditional add-only + deprecate discipline leaves too many "probably unused but we're not sure" fields.
  • Cross-service feature work makes branch deployments painful at your topology.

When this pattern does not fit

  • Public GraphQL API where clients are external. The persist step — and therefore the directives — cannot reach those clients.
  • Ecosystems that already lean on feature flags at the resolver level. Runtime flags and schema-level directives can coexist but duplicate concerns.
  • Small schemas where the operator can keep the whole set in their head.

Contrast with non-GraphQL analogues

  • Expand-migrate- contract (database schema) — lifecycle is expand (additive)migrate (shift traffic)contract (remove old), enforced by ordered deploys. Directive- based field lifecycle is the GraphQL analogue: draft (unreachable)component-scoped (narrow adoption)stable (broad adoption), enforced by persist-time validators.
  • Feature flags — runtime, per-request decisions. Directive-based lifecycle is build-time, per-query. They solve different problems; feature flags gate behaviour, directives gate query shape.
  • @deprecated — stock GraphQL directive for the exit of the lifecycle. @draft is the entry symmetric counterpart.

Gotchas

  • Directive drift across services. If the schema is federated, each subgraph needs the directive declared; a missing declaration silently turns the check into a no-op.
  • Directive strength is only at persist time. A draft field returned by a resolver is still returned if someone manages to persist a query. The check is at the persist validator.
  • Lifecycle is monotonic. Zalando does not describe demoting a stable field back to @allowedFor / @draft if it turns out to be wrong. In practice, use @deprecated + add a new field, same as regular GraphQL non-breaking evolution.
  • Cross-domain coordination. A field might be stable for one consumer team but still experimental for another. The lifecycle directives are field-level, so this is handled by keeping the field experimental at the boundary (adding a sibling field for the early- stable consumer) rather than one field in two stages.

Place in the broader directive taxonomy

The lifecycle trio (@draft@allowedFor → stable) is one axis of Zalando's schema-directive governance discipline, not the whole of it. The 2023-10-18 directive survey (sources/2023-10-18-zalando-understanding-graphql-directives-practical-use-cases-at-zalando) catalogues parallel axes the same mechanism enforces:

All of these share the same load-bearing property: the schema becomes a declarative source of truth for cross-cutting policy, enforced by a mix of build-time linters and runtime resolver wrappers.

Seen in

Last updated · 501 distilled / 1,218 read