Type-Safe GraphQL Resolvers for Federated CMS
This guide, part of Advanced GraphQL Federation Patterns, addresses a cost of federation: federating multiple headless CMS instances behind one GraphQL gateway breaks the type inference that monolithic resolvers rely on. When schemas span distinct subgraphs, you get runtime null returns, silent field collisions, and circular dependency loops on cross-platform queries. Three controls keep it type-safe: explicit schema contracts, generated resolver types, and runtime validation at every subgraph boundary.
Explicit schema contracts
Federation stitches distributed graphs via entity definitions and @key directives. Each platform exposes a subgraph with isolated namespaces, and the gateway merges them in a supergraph composition step — but without strict contracts, fields collide at query time. Subgraph ownership is decided as part of Headless CMS Architecture & Platform Selection, and that decision precedes the first resolver. Platform-native GraphQL layers need wrapping to expose federation-compatible types, and manual wrapping drifts from the supergraph fast.
The production standard decouples composition from execution: subgraphs publish schemas to a registry, and the router validates compatibility before deploy. Contract-first means content teams can iterate on editorial models without breaking frontend inference.
The three controls form a pipeline from compile-time contracts to runtime entity resolution:
Generated resolver types
Hand-written TypeScript interfaces for federated schemas don’t survive schema churn. Use @graphql-codegen/cli with typescript-resolvers to generate strict interfaces from the composed supergraph, so resolver signatures match the gateway contract exactly. This codegen.ts isolates domain models from GraphQL types via mappers and enforces strict context typing:
import type { CodegenConfig } from '@graphql-codegen/cli';
const config: CodegenConfig = {
schema: 'http://localhost:4000/supergraph',
documents: ['src/**/*.graphql'],
generates: {
'./src/generated/resolvers.ts': {
plugins: ['typescript', 'typescript-resolvers'],
config: {
strictScalars: true,
contextType: '../context#FederationContext',
mappers: {
Article: '../types#ArticleEntity',
Author: '../types#AuthorEntity',
MediaAsset: '../types#MediaEntity'
},
useIndexSignature: true
}
}
}
};
export default config;
The mappers config keeps CMS-specific metadata — Contentful sys fields, Sanity _type strings — out of the client by mapping GraphQL types to internal domain models, separating transport contracts from persistence. See the GraphQL Code Generator documentation for plugin and CI details.
Runtime validation and entity resolution
Generated types only cover compile time. At runtime, federated resolvers implement __resolveReference for entity stitching. When a query spans Contentful_Article and Sanity_Author, the gateway delegates across network boundaries, and the resolver chain runs sequentially unless you parallelize with Promise.all or DataLoader.
A missing __resolveReference returns a silent null at the gateway, and reference keys must match the @key directive exactly. This resolver shows strict type alignment, Zod payload validation, and cross-subgraph delegation:
import { Resolvers } from '../generated/resolvers';
import { z } from 'zod';
const ArticleRefSchema = z.object({
id: z.string().uuid(),
__typename: z.literal('Article')
});
export const resolvers: Resolvers = {
Article: {
__resolveReference: async (ref, { dataSources }) => {
const validated = ArticleRefSchema.parse(ref);
const article = await dataSources.contentful.getArticleById(validated.id);
return { ...validated, ...article };
},
author: async (parent, _, { dataSources }) => {
if (!parent.authorId) return null;
// Cross-subgraph delegation to Sanity author graph
return dataSources.sanity.getAuthorById(parent.authorId);
},
relatedContent: async (parent, _, { dataSources }) => {
// Parallelized fetch for performance optimization
const [relatedArticles, relatedMedia] = await Promise.all([
dataSources.contentful.getRelatedArticles(parent.id),
dataSources.assetStore.getMediaByArticle(parent.id)
]);
return { articles: relatedArticles, media: relatedMedia };
}
}
};
Zod validation is a defensive boundary before any external data source is called: it stops undefined propagation and emits structured errors that frontend error boundaries can surface. For routing and entity-resolution mechanics, see the Apollo Federation 2 documentation.
DX and multi-tenant safeguards
Schema drift hits DX directly — broken builds, delayed deploys, QA overhead. Run codegen in a pre-commit hook or CI so resolver signatures are validated before merge.
Multi-tenant environments need more: context injection should carry tenant IDs, cache-control headers, and rate-limit tokens, and resolvers must stay stateless and idempotent to scale horizontally. Use DataLoader for batched entity resolution to avoid N+1 when stitching across platforms. Per Content Modeling Best Practices, shared entities like Author, Category, and Media should be owned by a single authoritative subgraph and referenced by foreign key elsewhere — that cuts composition conflicts and simplifies cache invalidation.
Typing Data Sources and Context
Generated resolver types are only as good as the context and data sources they reference. Define a typed FederationContext with one data source per upstream system, each returning domain entities rather than raw CMS responses, and create loaders per request inside the context factory. The resolvers then never see CMS response shapes, and swapping a CMS affects only its data source.
// context.ts
import DataLoader from "dataloader";
import type { ArticleEntity, AuthorEntity } from "./types";
export interface ContentfulSource {
getArticleById(id: string): Promise<ArticleEntity | null>;
getRelatedArticles(id: string): Promise<ArticleEntity[]>;
}
export interface FederationContext {
tenantId: string;
locale: string;
dataSources: {
contentful: ContentfulSource;
authors: DataLoader<string, AuthorEntity | null>;
};
}
export function buildContext(tenantId: string, locale: string, contentful: ContentfulSource, fetchAuthors: (ids: readonly string[]) => Promise<(AuthorEntity | null)[]>): FederationContext {
return {
tenantId,
locale,
dataSources: {
contentful,
// One loader per request: batches author lookups across all articles in the operation.
authors: new DataLoader(fetchAuthors),
},
};
}
The contextType option in the codegen configuration points at this interface, so every resolver receives exactly these data sources with their types. A resolver that tries to call a method that does not exist, or treats a nullable entity as present, fails to compile rather than failing in production.
Configuration Reference
| Setting | Value | Why |
|---|---|---|
| Codegen schema | composed supergraph file, not a live URL | Reproducible builds without a running router. |
strictScalars |
true |
Forces explicit scalar mappings. |
mappers |
domain entity types | Keeps CMS shapes out of GraphQL types. |
contextType |
typed federation context | Data sources and tenant data are type-checked. |
| Runtime validation | Zod at reference and data-source boundaries | Catches data the types cannot. |
Gotchas & Edge Cases
- Codegen against a running server. Pointing codegen at
http://localhost:4000makes CI depend on a running router. Generate from the composed supergraph file checked into the repository or produced earlier in the pipeline. - UUID assumptions. The reference schema requires UUIDs, but Contentful entry ids are not UUIDs. Validate against the actual id format of each CMS, or the validation rejects every reference.
- Parsing throws.
parsethrows on invalid input, which becomes a generic resolver error. UsesafeParseand return a structured, typed error ornullfor nullable fields. - Per-field loaders missing. The
authorresolver calls the Sanity data source once per article. Wrap it in a per-request DataLoader so a list of articles loads authors in one call.
Worked Example
A publisher federated Contentful articles with Sanity-managed author profiles. Before generated types, a rename of authorRef to author in the Contentful model shipped to production and returned null authors on every article for a day, because the hand-written resolver interface still referenced the old field. After moving to generated resolver types with mappers and Zod validation of CMS payloads, the same kind of change failed CI with a type error in the mapper and a validation error in the contract test, and the fix went out with the model change instead of after it.
Rollout Checklist
- Generate resolver types from the composed supergraph in CI and fail the build on type errors.
- Map every GraphQL type to a domain entity with
mappers, so CMS shapes never leak into resolvers. - Type the context and data sources, and create DataLoaders per request inside the context factory.
- Validate entity references and CMS payloads at the boundary with
safeParse, using each CMS’s real id format. - Add contract tests that run recorded CMS responses through the data sources and mappers.
Treat the generated types as part of the schema review. When a pull request changes a subgraph schema, the diff in generated types shows exactly which resolvers must change, and reviewers can check that nullability decisions, especially for fields sourced from other subgraphs or less reliable CMS APIs, match what the frontend expects. Most federated type bugs come from nullability disagreements rather than wrong field names, and generated types make those disagreements visible before merge.
The same approach works for stitched schemas: generate types from the merged schema and keep vendor-specific shapes behind data sources, as in schema stitching for multi-vendor architectures.
Frequently Asked Questions
Do generated types slow down development?
The generation step takes seconds and runs automatically in watch mode or pre-commit. The time saved chasing runtime nulls outweighs it quickly.
Should resolvers return CMS objects directly?
No. Map CMS responses to domain entities in data sources, and let resolvers work with those. Mappers make that boundary explicit in the generated types.
How strict should runtime validation be?
Strict at trust boundaries, meaning references from the router and payloads from the CMS, and absent inside your own code, where generated types already guarantee shapes.
How do generated types handle federation directives?
The resolver types plugin understands federation when configured for it, including __resolveReference signatures typed with the key fields. Enable federation support in the codegen configuration so reference resolvers are type-checked too.