Schema Stitching for Multi-Vendor Headless Architectures
Schema stitching unifies disparate vendor GraphQL endpoints — CMS, commerce, DAM, search — into one frontend-facing schema without asking any vendor to adopt a shared SDK. The gateway introspects each endpoint, normalizes conflicting types, and resolves cross-service references at runtime. It’s the integration path when vendors export static schemas but block the runtime federation hooks that Advanced GraphQL Federation Patterns require.
The distinction matters: federation relies on explicit @key directives, subgraph ownership, and vendor-side federation SDKs. Stitching operates purely at the introspection level. That makes it the option for legacy CMS instances, third-party SaaS, and governed enterprise endpoints that prohibit federation hooks.
The gateway abstraction
The stitching gateway is a transparent proxy: it aggregates multiple schemas into one executable surface, intercepts client queries, delegates field resolution to the right downstream service, and merges results. No vendor API changes. This fits Headless CMS Architecture & Platform Selection decisions that prioritize composability over monolithic coupling.
The catch is normalization. Vendors expose conflicting type names, divergent scalars, and inconsistent pagination (offset vs. cursor). The gateway must apply deterministic transforms during schema construction or hit runtime collisions and unpredictable client typing.
The gateway introspects each vendor, normalizes types, and delegates cross-service fields at query time:
Introspection and type normalization
The flow starts with runtime introspection against each vendor. Disable introspection caching in development to catch schema drift; in production, cache snapshots to cut startup latency and avoid vendor rate limits during initialization.
import { stitchSchemas } from '@graphql-tools/stitch';
import { introspectSchema, RenameTypes, FilterRootFields } from '@graphql-tools/wrap';
import { delegateToSchema } from '@graphql-tools/delegate';
import { GraphQLSchema, OperationTypeNode, printSchema } from 'graphql';
import type { SubschemaConfig } from '@graphql-tools/delegate';
interface VendorConfig {
uri: string;
headers: Record<string, string>;
prefix: string;
excludeRootFields?: string[];
}
async function buildStitchedGateway(vendors: VendorConfig[]): Promise<GraphQLSchema> {
const subschemas: SubschemaConfig[] = [];
for (const vendor of vendors) {
const schema = await introspectSchema({
uri: vendor.uri,
headers: vendor.headers,
// Production optimization: cache introspection results
// to avoid repeated vendor rate-limit exhaustion
});
subschemas.push({
schema,
transforms: [
new RenameTypes((name) => `${vendor.prefix}${name}`),
...(vendor.excludeRootFields
? [new FilterRootFields((op, field) => !vendor.excludeRootFields!.includes(field))]
: []),
],
});
}
// Extend types to establish cross-service relationships
const mergedSchema = stitchSchemas({
subschemas,
typeDefs: `
extend type CmsProduct {
commerceInventory: CommerceInventory
damAssets: [DamAsset!]
}
`,
resolvers: {
CmsProduct: {
commerceInventory: {
selectionSet: `{ sku }`,
resolve: async (parent, _args, context, info) => {
return delegateToSchema({
schema: subschemas[1], // Commerce subschema config
operation: OperationTypeNode.QUERY,
fieldName: 'inventoryBySku',
args: { sku: parent.sku },
context,
info,
});
},
},
},
},
});
// Validate merged schema before exposing to clients
const schemaString = printSchema(mergedSchema);
if (schemaString.includes('TypeConflictError') || schemaString.includes('undefined')) {
throw new Error('Schema merge validation failed. Inspect printSchema output.');
}
return mergedSchema;
}
Three production requirements show up above:
- Deterministic prefixing:
RenameTypesprevents collisions when vendors share names likePage,User,Asset. - Root filtering:
FilterRootFieldsstrips vendor-specific mutations or queries that shouldn’t reach the unified client. - Explicit delegation:
selectionSetdeclares the exact payload the upstream needs, so downstream resolvers get only required context.
Type collisions and drift
Most stitching failures surface during type resolution. When two vendors define overlapping Page, Asset, or User types without transforms, schema construction throws TypeConflictError. Worse are silent overrides: two vendors with identical field names but mismatched scalar types produce unpredictable client behavior.
Guard against drift with CI schema validation — diff introspection snapshots against a baseline using graphql-diff or similar. When a vendor updates an API without versioning, the gateway must reject the incompatible merge or apply a fallback transform. The GraphQL specification requires strict type compatibility during merging; violating it produces runtime failures that slip past standard error boundaries.
Delegation latency
Misaligned selectionSet config drives latency. Over-fetching trips vendor rate limits and bloats payloads; under-fetching propagates null when a required field is missing from the parent. To tune delegation:
- Precise selection sets: request only the fields downstream resolution needs.
- Batched delegation: enable batching on
delegateToSchemawhen resolving multiple parents to cut round trips. - Response caching: add DataLoader or a Redis cache at the gateway for high-frequency queries like
inventoryBySkuorassetByHash. - Timeout enforcement: wrap delegation in
AbortControllerso a degraded vendor doesn’t cascade.
Instrument the gateway with OpenTelemetry spans to trace cross-service resolution and catch bottlenecks in TTFB, resolver execution time, and cache hit ratio before they reach the frontend.
Governance and header propagation
Multi-vendor stitching complicates authorization. Never hardcode vendor tokens. Extract auth headers from the incoming client request, validate scopes, and forward only the credentials each subschema needs.
const context = async ({ req }) => {
const cmsToken = extractScopedToken(req.headers.authorization, 'cms:read');
const commerceKey = extractScopedToken(req.headers.authorization, 'commerce:inventory');
return {
cmsHeaders: { Authorization: `Bearer ${cmsToken}` },
commerceHeaders: { 'X-Vendor-Auth': commerceKey },
// Propagate tenant ID for multi-tenant routing
tenantId: req.headers['x-tenant-id'],
};
};
Compliance requires audit trails for cross-service access. Log delegation paths, response sizes, and error classes at the gateway without exposing PII or tokens. Rotate vendor credentials and enforce least-privilege scopes.
When to stitch instead of federate
Stitching wins when vendor APIs lack federation support, enforce strict schema-export policies, or span organizational boundaries with limited API governance. It gives fast integration and minimal lock-in. But past roughly five stitched services, delegation complexity and latency usually justify migrating to federation with explicit subgraph ownership. Treat stitching as transitional or permanent based on vendor maturity, compliance needs, and DX targets — done right, it delivers one GraphQL surface without forcing vendor cooperation.
Configuration Reference
| Setting | Value | Why |
|---|---|---|
| Type prefix per vendor | Cms, Commerce, Dam |
Prevents collisions on common names. |
| Root field filter | only fields clients need | Keeps vendor admin mutations out of the public graph. |
| Introspection snapshot | cached, refreshed in CI | Fast startup and drift detection. |
| Delegation batching | enabled | Fewer round trips for lists. |
| Vendor timeout | per vendor, via AbortController | One slow vendor cannot stall the page. |
Gotchas & Edge Cases
- String checks for validation. The example scans
printSchemaoutput forTypeConflictErrororundefined, which catches nothing reliably, because conflicts throw during stitching rather than appearing in the printed schema. Validate by building the schema in CI and running a set of recorded client operations against it. - Subschema by index.
subschemas[1]couples delegation to array order; adding a vendor silently changes the target. Keep named references to each subschema config. - Introspection disabled by vendors. Some production APIs disable introspection. Load the vendor’s published SDL file instead, and update it deliberately when the vendor releases changes.
- Scalar mismatches. Two vendors’
DateTimescalars may serialize differently. Normalize to one scalar in the gateway with a transform, or clients receive inconsistent formats.
Worked Example
A retailer combined a SaaS CMS, a commerce platform and a digital asset manager, none of which offered federation support. Stitching with type prefixes and three delegated cross-vendor fields gave frontend teams one schema in two weeks. Two years later, with seven vendors and noticeable delegation latency, they moved the two services they owned to federation subgraphs and kept stitching only for the SaaS APIs, wrapped in a thin stitched subgraph of its own.
Rollout Checklist
- Snapshot each vendor’s schema and commit it; refresh and diff in CI.
- Prefix types per vendor and filter root fields to what clients need.
- Declare exact selection sets for every delegated field and batch delegation for lists.
- Put timeouts and caching around each vendor, sized to its reliability and volatility.
- Forward only scoped credentials per vendor, never the client’s token to every service.
- Plan the exit: services you own should become federation subgraphs as they mature.
The same governance and code review discipline applies here as it does in federation: type-safe resolvers, generated from the stitched schema, keep delegation code honest when vendors change their APIs, as described in type-safe resolvers for federated CMS.
Frequently Asked Questions
Can stitching and federation be combined?
Yes. A stitched gateway can itself be exposed as one subgraph of a federated supergraph, which lets teams federate the services they own while stitching third-party APIs behind a single wrapper.
How do I handle vendor schema changes?
Refresh introspection snapshots in CI, diff them against the committed baseline, and fail the build on breaking changes, so a vendor update never reaches production unnoticed.
Is schema stitching still maintained?
Yes, through the GraphQL Tools project, and it remains the practical way to merge schemas you do not control.
How do I cache stitched responses?
Cache at two levels: vendor responses inside the gateway, keyed by the delegated query and variables with vendor-appropriate TTLs, and whole responses at the CDN when operations are public and persisted. Purge both from vendor webhooks where vendors offer them.
How do I test delegated fields?
Mock each vendor with recorded responses and assert that a query spanning vendors returns merged data with the expected shape. Include a test where one vendor times out, to confirm nullable delegated fields degrade gracefully.