Type Ownership and Shared Schema Contracts
In a distributed GraphQL graph, every field must trace back to exactly one authoritative source, yet real systems constantly need the same type to surface in more than one service. Type ownership and shared schema contracts are the discipline that reconciles those two facts: they define who owns a type’s base fields, who is allowed to contribute additional fields, and how the composition engine verifies that those contributions never conflict. Get ownership wrong and you ship a graph that composes today and fails the next time two teams touch Product in the same sprint.
This guide is part of GraphQL Federation Architecture & Design, and it focuses on the contract layer that sits between subgraph design and runtime routing. We cover ownership models, the directives that encode them, a step-by-step implementation, how contracts plug into a composition pipeline, the performance characteristics of shared fields, and the composition errors you will actually see. The companion guide on using the @shareable directive for overlapping types drills into one specific mechanism; this page frames the broader contract that mechanism serves.
The Problem: Shared Types Without Shared Owners
The federation model is built on a single rule: every field is resolved by exactly one subgraph unless you explicitly say otherwise. The moment two teams independently define User.email or Product.price, the composition step has no way to decide which resolver is authoritative, and it refuses to build the supergraph. That refusal is a feature — it catches ambiguity at build time instead of letting clients receive non-deterministic data at runtime — but it means ownership cannot be implicit. It has to be a written, enforced contract.
A schema contract answers three questions for every entity:
- Who owns the base type and its key? Exactly one subgraph defines the canonical type with its
@keyand is responsible for the entity’s identity and lifecycle. - Who may contribute fields, and which ones? Contributing subgraphs extend the type, reference the key fields they do not own as
@external, and add only the fields their domain is responsible for. - Which fields are deliberately resolved by more than one service? Those — and only those — carry
@shareable, declaring that the duplication is intentional and the signatures must stay aligned.
When all three answers are encoded in SDL and validated in continuous integration, ownership stops being tribal knowledge and becomes a property the compiler enforces. The rest of this page shows how to build that.
Prerequisites
If you do not yet have a registry to check against, the section on schema registry and managed federation explains how to stand one up; without it, contract enforcement relies entirely on local composition.
Ownership Models in a Federated Graph
There are three roles a subgraph can play against any given type, and a healthy contract assigns every type to exactly one model.
Single-owner entities (the default)
One subgraph defines the type, its @key, and all of its fields. No other subgraph touches it except to reference it as a typed relationship. This is the cleanest model and should be your default. The owning service is the single source of truth for the entity’s identity and its core attributes.
# products subgraph — sole owner of Product
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.9",
import: ["@key"])
type Product @key(fields: "id") {
id: ID!
name: String!
price: Float!
}
Owner-plus-contributors (extension)
One subgraph owns the base type and key; other subgraphs extend it to attach domain-specific fields. Contributors declare the key fields as @external (they reference identity but do not resolve it) and resolve only the fields they add. This is how an inventory service adds inventoryCount to a Product it does not own.
# inventory subgraph — contributor to Product
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.9",
import: ["@key", "@external"])
type Product @key(fields: "id") {
id: ID! @external # identity referenced, not owned
inventoryCount: Int! # owned and resolved here
}
Intentionally shared fields
Some fields genuinely need to be resolvable by multiple subgraphs — typically read-mostly, low-volatility attributes that several domains already hold. These carry @shareable in every subgraph that resolves them, and their signatures must match exactly. This is the narrowest model and the easiest to abuse; reserve it for fields where duplication buys real routing flexibility rather than masking unclear ownership.
# auth subgraph and profile subgraph both resolve User.displayName
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.9",
import: ["@key", "@shareable"])
type User @key(fields: "id") {
id: ID!
displayName: String! @shareable # identical signature required in every owner
}
The deep mechanics of that last model — exact error payloads, query-plan routing, and @override interplay — live in using the @shareable directive for overlapping types.
Directive & Config Spec Table
The contract is expressed almost entirely through federation directives. The table below summarises what each one declares and when it is evaluated.
| Directive | Declares | Required imports | Evaluated |
|---|---|---|---|
@key(fields: "...") |
The type is an entity; these fields identify it | @key |
Composition + runtime (__resolveReference) |
@external |
This subgraph references but does not resolve the field | @external |
Composition |
@shareable |
Field may be resolved by more than one subgraph | @shareable |
Composition |
@override(from: "svc") |
Move resolution authority for the field to this subgraph | @override |
Composition (progressive at runtime with label) |
@inaccessible |
Field exists in subgraphs but is hidden from the public API schema | @inaccessible |
Composition |
@tag(name: "...") |
Attach metadata for contracts/filtering | @tag |
Composition + tooling |
@requires(fields: "...") |
Resolving this field needs @external fields from the owner |
@requires, @external |
Composition + runtime |
The key distinction for contract design is composition-time vs. runtime. Nullability alignment, directive consistency, and shareability are all checked when rover supergraph compose builds the supergraph — they fail fast and never reach a deployed router. Reference resolution and @requires data fetching happen at runtime, per query, and surface as resolver errors rather than composition errors.
Step-by-Step: Implementing a Shared Schema Contract
The following sequence takes a Product type owned by products and adds an inventory contribution plus one intentionally shared field.
1. Declare the canonical owner
The owning subgraph defines the entity, its key, and its base fields. Nothing here references @external or @shareable — it owns everything it declares.
# products subgraph
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.9",
import: ["@key", "@shareable"])
type Product @key(fields: "id") {
id: ID!
name: String!
price: Float!
sku: String! @shareable # intentionally shared with the search subgraph
}
2. Wire the owner’s reference resolver
Every entity needs a __resolveReference so the router can hydrate it from a key supplied by another subgraph. This is runtime behaviour, not part of the SDL contract, but it is what makes ownership real.
import { buildSubgraphSchema } from '@apollo/subgraph';
import { gql } from 'graphql-tag';
const resolvers = {
Product: {
// Router hands us the key fields; we return the full entity.
__resolveReference(ref: { id: string }, ctx: Context) {
return ctx.dataSources.products.byId(ref.id);
},
},
};
export const schema = buildSubgraphSchema({ typeDefs, resolvers });
3. Add a contributing subgraph
The inventory subgraph extends Product, references id as @external, and resolves only its own field. It must declare the same @key so the router knows how to fetch the entity.
# inventory subgraph
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.9",
import: ["@key", "@external"])
type Product @key(fields: "id") {
id: ID! @external
inventoryCount: Int!
}
4. Declare a deliberately shared field consistently
If search also resolves sku, it must carry @shareable with an identical signature — String!, no divergent arguments. A mismatch here is the single most common contract failure.
# search subgraph
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.9",
import: ["@key", "@external", "@shareable"])
type Product @key(fields: "id") {
id: ID! @external
sku: String! @shareable # MUST match products' String! exactly
}
5. Compose locally before publishing
Validate the merged supergraph on your machine first. A clean local compose is the gate before anything touches the registry.
rover supergraph compose --config ./supergraph.yaml --output supergraph.graphql
Inspect the generated SDL and confirm Product.sku shows as shareable and inventoryCount resolves only from inventory. When you intend to move ownership rather than share it — for example consolidating sku into a single service later — that is a versioning operation; see migrating and versioning federated schemas for the @override-based workflow that does it without breaking clients.
Composition Pipeline Integration
A contract is only worth as much as the gate that enforces it. The contract lives in SDL; the gate is rover subgraph check running on every pull request that changes a subgraph. The check composes the proposed subgraph against the currently published supergraph and reports composition and operation impacts before merge.
# .github/workflows/schema-check.yml
name: schema-check
on: [pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rover
run: |
curl -sSL https://rover.apollo.dev/nix/latest | sh
echo "$HOME/.rover/bin" >> "$GITHUB_PATH"
- name: Check subgraph against the published supergraph
env:
APOLLO_KEY: ${{ secrets.APOLLO_KEY }}
run: |
rover subgraph check my-graph@prod \
--name products \
--schema ./products/schema.graphql
On the main branch, a successful merge publishes the new subgraph SDL so the supergraph recomposes:
rover subgraph publish my-graph@prod \
--name products \
--schema ./products/schema.graphql \
--routing-url https://products.internal/graphql
This is the same pipeline described in depth under schema registry and managed federation; the contract directives are what give the check something meaningful to verify. Treat any FAILURE-severity composition result as a hard block, and run checks against a staging graph ref before promoting changes to the production supergraph.
Architecture: How Ownership Flows Through Composition
The diagram below traces a single Product entity from three subgraphs, through composition, to a router query plan that stitches the owned and contributed fields back together.
Reading left to right: each subgraph declares its slice of Product with explicit ownership directives. Composition merges those slices into one supergraph type, verifying that the @shareable sku has matching signatures and that exactly one subgraph owns name and price. The router then plans queries against the merged type, free to fetch sku from whichever subgraph it is already visiting. If inventory ever dropped its @external on id, or search defined sku as nullable, the green composition box would reject the build and the router would never see it.
Performance & Scale Considerations
Ownership decisions are also latency decisions, because they determine how many subgraph fetches a query plan needs.
- Field co-location reduces hops. Fields that are almost always requested together should live in (or be
@shareablefrom) the same subgraph so the router can satisfy them in one fetch. Scattering a screen’s worth of fields across four subgraphs forces four sequential or parallel_entitiesround-trips. @shareableadds routing flexibility, not overhead. A shared field does not cause an extra fetch; it lets the planner pick a subgraph it is already visiting. The cost is at composition time (more paths to validate), not per request. The detailed query-plan walkthrough is in using the @shareable directive for overlapping types.- Reference resolution is the N+1 hotspot. Each contributing subgraph receives a batch of keys through
_entities. Without batching, resolving 100 products’inventoryCountbecomes 100 datastore calls. Use DataLoader inside__resolveReferenceto collapse a key batch into a single query. - Cache owned fields, not the supergraph. Stable owned fields (a product’s
name) are good candidates for response caching at the router or subgraph; volatile contributed fields (inventoryCount) generally are not. Keep cache keys aligned to ownership so an inventory change never serves stale prices.
A useful rule: the number of subgraphs a single screen’s query touches is roughly the floor on its fan-out cost. Contracts that concentrate ownership by access pattern, not just by domain purity, keep that number low.
Here is the batched reference resolver pattern that turns a contributor’s _entities fan-out into a single datastore round-trip. The router hands __resolveReference one key at a time, but DataLoader coalesces the keys collected within a tick into one query, which is what keeps a contributed field from becoming an N+1 hotspot.
import DataLoader from 'dataloader';
// One loader per request; batches all inventory lookups in a tick.
function makeInventoryLoader(db: Db) {
return new DataLoader<string, InventoryRow>(async (ids) => {
const rows = await db.inventory.findMany({ where: { productId: { in: [...ids] } } });
const byId = new Map(rows.map((r) => [r.productId, r]));
// DataLoader requires results in the same order as the keys.
return ids.map((id) => byId.get(id) ?? { productId: id, count: 0 });
});
}
const resolvers = {
Product: {
// Router calls this once per key; the loader collapses them into one query.
inventoryCount(ref: { id: string }, _args: unknown, ctx: Context) {
return ctx.loaders.inventory.load(ref.id).then((row) => row.count);
},
},
};
The contract decides whether a field lives in a contributing subgraph; the loader decides whether that decision costs you one query or one hundred. Treat the two as a pair: any field you move out of the owning subgraph should ship with a batched resolver.
Failure Modes & Debugging
Contract violations almost always surface at composition. Here are the ones you will meet most often, with the messages they produce.
Non-shareable field resolved by multiple subgraphs. Two subgraphs define the same field and at least one lacks @shareable:
INVALID_FIELD_SHARING: Non-shareable field "Product.sku" is resolved
from multiple subgraphs: it is resolved from subgraphs "products" and
"search" and defined as non-shareable in at least one of them.
Resolution: either add @shareable to the field in every subgraph that resolves it, or remove it from all but the owner.
Field type mismatch. The same field has divergent nullability or scalar types across subgraphs:
FIELD_TYPE_MISMATCH: Type of field "Product.sku" is incompatible
across subgraphs: it has type "String!" in subgraph "products" but
type "String" in subgraph "search".
Resolution: align the signature exactly. @shareable never relaxes this — divergent signatures fail regardless. Field-type reconciliation is covered alongside other conflicts in Resolving Schema Conflicts in Apollo Federation.
Missing key on a contributor. A subgraph extends a type but omits the @key, so the router cannot fetch it:
KEY_FIELDS_MISSING: Field "Product.inventoryCount" cannot be resolved
because subgraph "inventory" does not declare a @key for type "Product".
Resolution: add the matching @key(fields: "id") and mark the key fields @external.
Decision Guide: Which Ownership Model Fits
When a field needs to appear in more than one subgraph, the choice is rarely between “share it” and “don’t” — it is between four distinct mechanisms, each with a different contract. Use this table to pick deliberately rather than reaching for @shareable by reflex.
| Situation | Mechanism | What it means | When to avoid |
|---|---|---|---|
| One service produces the value; others only need to reference identity | Single owner + @external key on contributors |
Canonical resolution stays in one place | When two services genuinely produce the value independently |
| Several services resolve the same value identically and you want hop flexibility | @shareable in every resolver |
Router may fetch from any participant | When the underlying data sources differ — they will drift |
| Ownership must move from service A to service B over time | @override(from: "A") on B |
Resolution migrates without breaking clients | As a permanent state — finish the migration |
| A field’s resolver needs sibling fields it does not own | @requires + @external |
Owner supplies inputs, contributor computes | When the required fields are expensive or volatile |
The failure pattern to watch for is @shareable used where @override belongs: two subgraphs share a field “for now” during a migration that never completes, and the values quietly diverge. If the long-term intent is a single owner, encode that intent with @override and a schema versioning plan, not with indefinite sharing.
A quick checklist before you commit any overlapping field to the contract:
Frequently Asked Questions
How do I decide which subgraph owns a shared type?
Assign ownership to the service that manages the entity’s data lifecycle — the one that creates, updates, and deletes it. That subgraph defines the base type and @key; everyone else either references the key as @external and adds their own fields, or carries @shareable for the narrow set of fields they legitimately resolve too. Ownership should follow the bounded context, which is why subgraph boundaries and ownership are designed together.
When should I use @shareable versus moving a field to a single owner?
Use @shareable only when more than one subgraph genuinely resolves the field with identical logic and the duplication buys routing flexibility for read-heavy access patterns. If only one service should ever produce the value, consolidate ownership instead — and if you are migrating ownership from one subgraph to another, use @override as part of a schema versioning workflow rather than leaving the field shared indefinitely.
Can I enforce shared schema contracts without a managed registry?
Partly. Local rover supergraph compose catches composition-time contract violations on every PR, which covers ownership conflicts, shareability, and type mismatches. What you lose without a registry is operation-impact analysis — knowing whether a change breaks live client queries — and historical schema checks. The schema registry and managed federation guide explains what that adds.
Why does composition fail even though both subgraphs declare the field identically?
Check for hidden signature drift: a directive present in one subgraph but not the other (@deprecated, @tag), an argument default that differs, or a nullability difference that is easy to miss ([String!]! vs [String!]). Composition compares the full field signature, including arguments and applied directives, not just the return type.
Related
- Using @shareable Directive for Overlapping Types — the directive that encodes intentional field sharing
- Resolving Schema Conflicts in Apollo Federation — reconciling type and directive mismatches at composition
- Defining Subgraph Boundaries for Microservices — drawing ownership lines along bounded contexts
- Schema Registry and Managed Federation — the registry that enforces contracts across teams
- Migrating and Versioning Federated Schemas — moving ownership with
@overridewithout breaking clients - GraphQL Federation Architecture & Design — parent guide