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:

From schema contract to validated runtime resolutionSubgraph schemas with keys are composed into a supergraph, GraphQL Code Generator produces resolver types with mappers, resolvers are implemented against those types, and at runtime entity references are validated with Zod before cross-subgraph fetches.Subgraph schemas+ @keySupergraphcompositionCodegenresolver types + mappersResolverimplementationZod validationof referencesTyped response
Compile-time types and runtime validation cover different failures; you need both.

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:

TypeScript
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.

What each safety layer catchesFailure types in federated CMS resolvers and whether composition, generated types or runtime validation catches each.FailureCompositionGenerated typesRuntime validationMissing @key on shared entityyesnonoResolver returns wrong shapenoyespartiallyCMS returns unexpected payloadnonoyesReference with malformed idnonoyesField collision across subgraphsyesvia typesno
Generated types catch mistakes in your code; runtime validation catches surprises in other systems' data.

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:

TypeScript
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.

Resolving an Article reference with an author from another subgraphThe router sends an Article representation; the resolver validates it with Zod, loads the article from Contentful, and resolves the author field through a batched loader against the Sanity data source.RouterArticle resolverContentfulSanity_entities: Article {id}Zod: uuid + __typenamegetArticleById (batched)article + authorIdgetAuthorById (DataLoader)author
Validation happens before any external call, so malformed references never reach the CMS.

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.

TypeScript
// 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:4000 makes 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. parse throws on invalid input, which becomes a generic resolver error. Use safeParse and return a structured, typed error or null for nullable fields.
  • Per-field loaders missing. The author resolver 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.