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 theentity:<type>:<sku>format). - A
__typenameresolver 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:
- Build-time TypeScript codegen. A codegen step walks
the schema, finds every
@resolveEntityId-marked type, and emits: - The ID type definition (branded string type like
type ProductId = string & { __brand: "ProductId" }). - The
idresolver that wraps the natural ID with the prefix. - The
__typenameresolver. - Entries in a generic
Entityunion type / dispatcher lookup table. - Runtime ID wrapping. When a resolver returns an
entity, the framework post-processes the returned value
through the generated
idresolver, producing theentity:product:1234wrapped 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!): Entityquery walks the set of@resolveEntityId-marked types to build its dispatcher table; adding a new entity requires only annotating the type. - Rename safety. The
overrideargument 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
@resolveEntityIdmarker 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 onARGUMENT_DEFINITIONdrives 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¶
- Zalando UBFF — canonical instance.
@resolveEntityId on OBJECTwith optionaloverride: String. Drives TypeScript codegen (ID type definitions,id+__typenameresolvers) and runtime ID wrapping to theentity:<typename-lowercase>:<sku>format, e.g.entity:product:1234(sources/2023-10-18-zalando-understanding-graphql-directives-practical-use-cases-at-zalando, systems/zalando-graphql-ubff).
Related¶
- concepts/entity-id-convention — the ID format the codegen produces.
- concepts/graphql-schema-directive — the primitive carrying the marker.
- concepts/entity-based-page-composition — Zalando's higher-level pattern that depends on consistent entity IDs threading through the graph.
- patterns/directive-based-field-authorization · patterns/directive-based-pii-redaction — sibling directive-driven patterns at the field-level.
- systems/graphql · systems/zalando-graphql-ubff