Skip to content

PATTERN Cited by 1 source

Directive-driven entity codegen

Problem

An entity-based GraphQL schema — Zalando's UBFF is the canonical wiki instance — (concepts/entity-based-page-composition) repeats the same structural boilerplate for every entity type:

  • An id: ID! field with an ID-wrapping resolver (wrap the natural ID / SKU in the entity:<type>:<sku> format).
  • A __typename resolver for generic entity-lookup dispatchers.
  • TypeScript type definitions for the ID, the entity, and the union of all entities.
  • Consistent name / casing conventions tying the typename to the ID prefix.

Writing this by hand for dozens of entity types is error-prone: every new entity needs the full boilerplate, missing a piece breaks the generic resolvers, drift between typename and ID prefix breaks debug workflows.

Pattern

Declare a schema directive that marks a type as an entity, and drive code generation plus runtime ID wrapping from the marker.

directive @resolveEntityId(
  "An optional override name for the entity name in its ID"
  override: String
) on OBJECT

type Product implements Entity @resolveEntityId {
  id: ID!
}

Two implementation layers:

  1. Build-time TypeScript codegen. A codegen step walks the schema, finds every @resolveEntityId-marked type, and emits:
  2. The ID type definition (branded string type like type ProductId = string & { __brand: "ProductId" }).
  3. The id resolver that wraps the natural ID with the prefix.
  4. The __typename resolver.
  5. Entries in a generic Entity union type / dispatcher lookup table.
  6. Runtime ID wrapping. When a resolver returns an entity, the framework post-processes the returned value through the generated id resolver, producing the entity:product:1234 wrapped form. The data layer stores bare SKUs; the API surface returns prefixed IDs.

The optional override: String argument handles typename-rename scenarios: if Product is renamed to Article but the external ID must remain entity:product:1234 to stay stable for consumers, @resolveEntityId(override: "product") preserves the original prefix (Source: sources/2023-10-18-zalando-understanding-graphql-directives-practical-use-cases-at-zalando).

Why directive-driven codegen beats copy-paste boilerplate

  • Authoritative marker, not convention. Every entity-shaped type is explicitly annotated; the schema reader sees "this is an entity" at the type declaration, not inferred from structural cues.
  • Generic dispatchers become trivially enumerable. A generic lookup(id: ID!): Entity query walks the set of @resolveEntityId-marked types to build its dispatcher table; adding a new entity requires only annotating the type.
  • Rename safety. The override argument decouples the GraphQL typename from the external ID prefix, so schema-level renames do not break consumer-visible IDs.
  • Single source of truth for naming conventions. Mapping rules (lowercase the typename, produce a branded ID type named <Typename>Id, generate predictable resolver function names) live in the codegen, not scattered through hand-written boilerplate.

Trade-offs

  • Codegen as a build dependency. The build must run before every schema change lands; missing a regenerate produces resolver/schema drift. Mitigation: check in the generated output and diff-verify in CI.
  • Opaque to pure GraphQL consumers. A consumer reading only the schema sees the @resolveEntityId marker but does not see the generated boilerplate. For documentation, the directive's purpose must be documented elsewhere (an architecture doc, the pattern page).
  • Coupling of ID-format convention to the directive. Changing the ID-format convention (e.g. dropping the entity: prefix, switching to slash delimiters) requires coordinated codegen + runtime updates and a consumer-facing migration.

Generalization beyond entity IDs

The pattern generalises: any schema directive can drive codegen. Examples (not all from the Zalando source):

  • Validation. A @validate(pattern: String!) directive on ARGUMENT_DEFINITION drives generated validator functions.
  • Serialization. A @serializeAs(format: String!) directive drives custom JSON / binary encoding.
  • Cache policy. A @cache(ttl: Int!) directive drives generated cache-layer wrappers.

The key insight is that the schema is not only a runtime contract but also a source of truth for build-time code emission — directives are the declarative hooks that let teams extract machine-verifiable policy from otherwise- prose-only conventions.

Seen in

Last updated · 501 distilled / 1,218 read