SYSTEM Cited by 1 source
Zalando Unified Backend-For-Frontends (UBFF) GraphQL¶
What it is¶
Zalando's single-service unified GraphQL schema that acts as the Unified Backend-For-Frontends for every Web and mobile-App feature team across Europe's largest fashion e-commerce platform. Development began in H1 2018; it has been in production since end of 2018. By February 2021 it spans 12+ domains in a shared-ownership monorepo, serves >80% of Web and >50% of App use cases, and is consumed by 200+ developers across 25-30 feature teams (Source: ).
Architectural shape¶
Web clients iOS/Android App clients
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ UBFF GraphQL │ │ UBFF GraphQL │
│ (Web deploy) │ │ (App deploy) │ ← per-platform
└──────┬───────┘ └──────┬───────┘ bulkhead
│ │
└──────────┬────────────┘
▼
presentation-layer backends
(domain + platform-specific logic)
│
▼
domain backends
(Product, Campaign, …)
Two deployment shapes of the same codebase — one serving Web, one serving mobile Apps — implement the Bulkhead pattern. Behind the UBFF sits a presentation layer of domain/platform- specific backend services; the UBFF itself follows a strict No Business Logic principle and only aggregates and shapes.
Core design choices¶
- One graph, one service. Unlike Apollo Federation, there is no runtime composition of subgraphs. One GraphQL service, one schema, one deployment artifact (two deployment shapes for platform bulkheading). The post is explicit: "instead of having multiple Graphs connected via a library and gateway we have a single service at Zalando which connects all the domains in a single schema Graph."
- Entity system as first-class abstraction. Content and
domain models are exposed as named entities —
Product,Campaign, … — that cross-link into "a dense graph." The entity model is the schema's organising principle; the post flags it as the subject of its own future article (not detailed here). - JIT-compiled execution. Runs on
graphql-jit — an open-source JIT
GraphQL executor Zalando built and released as
zalando-incubator/graphql-jit. - Shared-ownership monorepo. One repository owned by 12+ domain teams under a documented contribution framework; contributor count grew 50 → 150+ in 2020.
- No business logic in the GraphQL layer. Enforced principle; domain/platform logic moves to presentation- layer backends behind the graph.
How they handle the "god component" risk¶
The post names the risk explicitly: a 12+ domain monorepo is a God Component architectural smell. Two mitigations:
- Architectural — shared ownership with an explicit contribution framework prevents any one team from unilaterally reshaping the graph.
- Operational — reliability patterns (Circuit Breakers, Timeouts, Retry) plus the Bulkhead pattern deployed as "separate deployments for Web and mobile Apps" — a Web regression cannot take the mobile app down and vice versa.
Adoption programme¶
Four levers the platform team used to scale adoption 50 → 150+ contributors and 70 → 200 consumers in 2020:
- One-stop-shop Documentation — single structured docs site (following Divio's documentation framework) with embedded GraphQL editor, schema documentation, Voyager for schema exploration, and practice exercises.
- Support chat — a dedicated channel for user and contributor queries.
- Trainings — company-wide GraphQL adoption training with 150+ participants.
- Consultation — platform team provides schema-design consultation hours for new domains ("GraphQL schema design is always a tricky topic even for frontend developers who can use GraphQL").
Positioning against peers¶
The post names and classifies five peer one-graph setups:
| Org | Shape | Note |
|---|---|---|
| Zalando (this) | Single service, unified schema | Chosen: unified tooling, single deployment, single governance point |
| GitHub | Single GraphQL API | Covers repos, users, marketplace |
| Shopify | Two unified graphs (StoreFront + Admin) | Split by audience (customers vs partners) |
| Airbnb | Working towards unified schema | Presented at GraphQL Summit 2019 |
| Expedia | REST → GraphQL central data graph | Motivated by "developers spending more time figuring out which service to call than shipping features" |
| Apollo Federation | Library + gateway across N subgraphs | Explicit not-chosen alternative |
| Netflix | One-graph in Studio ecosystem | Named peer |
Operational numbers (2021-02 snapshot)¶
| Dimension | Value |
|---|---|
| First production deploy | End of 2018 |
| Domains in the graph | 12+ |
| Contributors to monorepo | 150+ (from 50 in 2020) |
| Consuming developers | 200+ (from 70 in 2020) |
| Feature teams served | 25-30 |
| Web use cases served | >80% |
| App use cases served | >50% |
| Platform-specific deployments | 2 (Web + App) |
| Adoption training attendance | 150+ |
Gaps in the public record¶
- Schema size (type / field / resolver count) — undisclosed.
- Latency SLOs, QPS, p99 — undisclosed.
- Resolver fan-out / depth limits — undisclosed.
graphql-jitperformance delta vs reference executor — undisclosed.- Entity system internals — deferred to future post.
- Presentation-layer backend topology — described as "the presentation layer" with no further detail on per-domain or per-platform service topology.
- Schema evolution / deprecation tooling — partially filled by the 2022-02 persisted-queries + schema- stability post (see section below); hash algorithm, DB storage, persist-endpoint governance, and normalisation semantics still undisclosed.
Persisted queries + schema stability discipline (2022-02 follow-up)¶
A second companion post, GraphQL persisted queries and Schema stability (2022-02-16), describes the second layer of UBFF's API-design discipline. There are two load-bearing moves:
1. Production endpoint accepts only query IDs¶
Zalando frames this as "to disable GraphQL in production." At UI merge-to-main time, the build pipeline sends each query to a persist endpoint on the UBFF service, which returns a stable ID (hash of the normalised query text). The UI bundle ships with the ID in place of the query. The production request body is:
— no query key accepted. Unknown IDs are rejected (not
cache-mode APQ; see
patterns/disable-graphql-in-production). The effect is
that the production query set is the
persisted-queries DB
— finite, inspectable, versioned. From that fall out
exact schema-
usage observability, per-query SLOs, and the ability to
prove a field is unused before removing it
(Source: ).
2. Three directives encode a field lifecycle in the schema¶
Zalando uses three custom directives to mark where a field sits in the stability lifecycle:
| Directive | Target | Effect | Stage |
|---|---|---|---|
@draft |
FIELD_DEFINITION |
Persist-time validator refuses any query touching the field | 1 — unpersistable |
@allowedFor(componentNames: [String!]!) |
FIELD_DEFINITION |
Only queries tagged with @component(name:) in the allowlist may persist |
2 — component-scoped |
| (none) | — | Any persisted query may reference the field | 3 — stable |
The @component(name: String!) directive is the
query-side dual of @allowedFor — queries tag themselves
with a component name and the persist validator checks
membership.
The lifecycle is monotonic: "When we first extend the GraphQL schema, we start with the draft annotation. Then we promote new fields to a restricted usage in production using the allowedFor annotation. After we finally have stabilized the schema, we remove all of these annotations and have a non-breaking contract in form of persisted queries." The three-stage framing lives on patterns/directive-based-field-lifecycle; each individual directive has a concept page (concepts/draft-schema-field, concepts/component-scoped-field-access) (Source: ).
Why branch deployments are the rejected alternative¶
The 2022 post explicitly rejects feature-branch deployments as the way to stage schema work: "we have microservices and the GraphQL layer is an aggregator from multiple other services. So, maintaining multiple feature branches across 3-5 projects for 1 or 2 product features isn't going to help any developer or team move smoothly. The complexity increases non-linearly as we mix different features that must work together." Directive-based lifecycle stays on mainline across all the aggregated services (Source: ).
Sample validator implementation¶
Zalando ships the @draft persist-time validator in the
post as a standard GraphQL validation
rule:
export function draftRule(context) {
return {
Field(node) {
const parentType = context.getParentType();
const field = parentType.getFields()[node.name.value];
const isDraft = field.astNode.directives.some(
(directive) => directive.name.value === "draft"
);
if (isDraft) {
context.reportError(new GraphQLError(`Cannot persist draft field`));
}
},
};
}
The @allowedFor / @component validator is described by
analogy; its implementation is not shipped verbatim.
Error modeling discipline (2021-04 follow-up)¶
A companion post, Modeling Errors in GraphQL (2021-04-12), establishes the UBFF's discipline for error payloads. The core rule is a two-channel split by who can act on the failure — see concepts/error-action-taker-classification:
- Developer-actionable (front-end code acts on it — 404,
authZ, internal errors) → stay in
response.errorswith a parseableextensions.code. See patterns/error-extensions-code-for-developer-actionable-errors. - Customer-actionable (only the end user can fix it —
canonically mutation input validation) → modelled in the
schema as a Problem type on a
union ...Result = Success | Problem. Named after RFC 7807 to avoid colliding with GraphQL's reservederror. See patterns/problem-type-for-customer-actionable-errors and the Result union pattern.
The UBFF team is explicit that overusing schema Problem
types — making every field a Success | Error union —
destroys GraphQL's query-shape ergonomics, so Problem types
are restricted to mutation inputs (Source:
).
Full directive catalogue (2023-10 survey)¶
The 2023-10-18 follow-up post (sources/2023-10-18-zalando-understanding-graphql-directives-practical-use-cases-at-zalando) enumerates the production directive footprint beyond the three lifecycle directives, splitting by schema vs query directive class:
Schema directives (TypeSystem locations)¶
| Directive | Target | Effect | Pattern page |
|---|---|---|---|
@isAuthenticated(acrValue: ACRValue) |
FIELD_DEFINITION |
Require authentication and optional step-up ACR level | patterns/directive-based-field-authorization |
@sensitive(reason: String) |
ARGUMENT_DEFINITION |
Mark argument for observability redaction; paired with linter on 11 PII keywords | patterns/directive-based-pii-redaction |
@requireExplicitEndpoint(endpoints: [String!]!) |
FIELD_DEFINITION |
Gate field to specific HTTP pathnames (not the catch-all /graphql) |
patterns/directive-based-http-endpoint-partitioning |
@draft |
FIELD_DEFINITION |
Persist-time validator refuses any query touching the field | patterns/directive-based-field-lifecycle |
@allowedFor(componentNames: [String!]!) |
FIELD_DEFINITION |
Only queries tagged with @component(name:) in the allowlist may persist |
patterns/directive-based-field-lifecycle |
@final |
ENUM_VALUE |
Linter-only: blocks enum-value additions on marked enums (concepts/final-enum) | — |
@extensibleEnum(values: [String!]!) |
FIELD_DEFINITION |
Declares a String! field as open-to-extension with a current allowlist (concepts/extensible-enum) |
— |
@resolveEntityId(override: String) |
OBJECT |
Marks entity types for TypeScript codegen + runtime entity:<type>:<sku> ID wrapping |
patterns/directive-driven-entity-codegen |
Query directives (ExecutableDefinition locations)¶
| Directive | Target | Effect |
|---|---|---|
@component(name: String!) |
QUERY |
Client-provided component name; feeds @allowedFor gate at persist time + observability |
@tracingTag(value: String!) |
QUERY \| MUTATION \| SUBSCRIPTION |
Adds a client-defined tag to the OTel span |
@omitErrorTag |
FIELD |
Excludes specific field errors from contributing to the span's overall error tag (two-phase AST-walk implementation) |
@maxCountInBatch(value: Int!) |
QUERY |
Build-time-enforced cap on batch size; relies on persisted queries for trust |
Worked example — all query directives in one operation¶
query product_card($id: ID!)
@component(name: "web-product-card")
@tracingTag(value: "slo-1s")
@maxCountInBatch(value: 50) {
product(id: $id) {
id
name
brand {
id
name
}
inWishlist @omitErrorTag
}
}
The UBFF's directive-based-governance discipline is wider than the field lifecycle alone: authorization, PII redaction, HTTP routing, enum governance, entity-ID conventions, and per-operation observability metadata are all carried through the schema / query directive mechanism, enforced build-time via schema lints where possible and resolver-wrapped at runtime otherwise.
Seen in¶
- — first wiki source on Zalando's UBFF; Part 1 of a planned series on UBFF.
- — the error-modeling installment of the same series.
-
— the persisted-queries + schema-stability installment;
introduces the
@draft/@component/@allowedFordirective lifecycle. - sources/2023-10-18-zalando-understanding-graphql-directives-practical-use-cases-at-zalando
— the directive-taxonomy installment; catalogues the
full production directive footprint across schema
(
@isAuthenticated,@sensitive,@requireExplicitEndpoint,@final,@extensibleEnum,@resolveEntityId) and query (@tracingTag,@omitErrorTag,@maxCountInBatch) classes. - — the UBFF's production deployment (Fashion Store API (FSA)) canonicalises the discipline "the aggregation layer calls directly only Zalando-operated APIs" — external SaaS dependencies (Contentful for landing-page content) live behind an internal Contentful proxy that FSA consumes through a stable GraphQL type mapping.
- — the UBFF as the transport for request-scoped Experience context. Child- renderer GraphQL queries carry the resolved Experience name as an explicit parameter; the UBFF propagates it to the owning domain backend (Catalog / Product / Search), which applies the Experience's policies when shaping its response. Canonical wiki instance of patterns/request-state-propagated-experience and confirms the UBFF's no- business-logic discipline: Experience-awareness is a transport concern only, never a rules-evaluation concern in the aggregator itself.
Related¶
- systems/graphql — the query language substrate
- systems/graphql-jit — the JIT execution engine Zalando built for the UBFF
- systems/apollo-federation — the explicit not-chosen alternative
- systems/netflix-enterprise-graphql-gateway — peer one-graph architecture
- systems/rfc-7807-problem-details — the naming source for schema-level Problem types
- patterns/unified-graphql-backend-for-frontend — canonical instance
- patterns/business-logic-free-data-aggregation-layer
- patterns/per-platform-deployment-bulkhead
- patterns/result-union-type-for-mutation-outcome — mutation error shape
- patterns/problem-type-for-customer-actionable-errors
- patterns/error-extensions-code-for-developer-actionable-errors
- patterns/automatic-persisted-queries — mechanism class; UBFF runs the gate-mode instance
- patterns/disable-graphql-in-production — the load-bearing framing behind UBFF's persisted-queries discipline
- patterns/directive-based-field-lifecycle — the
@draft/@allowedForlifecycle umbrella - concepts/backend-for-frontend — the pattern this replaced
- concepts/unified-graph-principled-graphql
- concepts/problem-vs-error-distinction
- concepts/error-action-taker-classification
- concepts/graphql-persisted-queries — the mechanism
- concepts/draft-schema-field — stage 1 of the directive-based lifecycle
- concepts/component-scoped-field-access — stage 2
- concepts/graphql-schema-usage-observability — the capability that falls out of gate-mode persisted queries
- concepts/graphql-schema-directive — the TypeSystem-
directive primitive catalogued across
@isAuthenticated,@sensitive,@requireExplicitEndpoint,@draft,@allowedFor,@final,@extensibleEnum,@resolveEntityId - concepts/graphql-query-directive — the
ExecutableDefinition-directive primitive catalogued
across
@component,@tracingTag,@omitErrorTag,@maxCountInBatch - concepts/step-up-authentication — semantic behind the
@isAuthenticated(acrValue:)argument - concepts/sensitive-field-logging-redaction — the
observability-redaction discipline
@sensitiveencodes - concepts/schema-linter-enforcement — the enforcement
arm for
@sensitive(PII keyword detection),@final(enum-stability),@draft(persist validation) - concepts/extensible-enum · concepts/final-enum — the enum-governance directive pair
- concepts/entity-id-convention — the
entity:<type>:<sku>ID format@resolveEntityIdproduces - patterns/directive-based-field-authorization —
@isAuthenticatedcanonicalised - patterns/directive-based-pii-redaction —
@sensitive+ linter canonicalised - patterns/directive-based-http-endpoint-partitioning —
@requireExplicitEndpointcanonicalised - patterns/directive-driven-entity-codegen —
@resolveEntityIdcodegen + runtime wrap canonicalised - systems/opentelemetry — the tracing backend fed by
@tracingTagand filtered by@omitErrorTag - companies/zalando