Skip to content
advanced Phase 8 · Schema Design Patterns and Type System Depth

Schema Composition and Domain Boundaries

Structuring a schema so teams own clear domains without duplicating shared types.

1h
1 problems
Topic Progress 0%

Owning a Slice of One Graph

Owning a Slice of One Graph

A single schema with forty contributing teams fails in a predictable way: two teams define search, three define Error, and every pull request touches files nobody owns. The fix is not splitting into separate services the client must stitch together — it is organizing one graph so each team owns a section and can change it without negotiating with everyone.

src/
  shared/
    scalars.graphql      # DateTime, JSON, Money
    page.graphql         # Page, PageInfo, cursor pagination
  domains/
    catalog/
      schema.graphql     # Product, Category, Query.product, Query.products
      resolvers.ts
    billing/
      schema.graphql     # Invoice, Transaction, Query.invoice
      resolvers.ts
    shipping/
      schema.graphql     # Shipment, Query.shipment
      resolvers.ts

Two rules make the layout work:

  • Root fields are namespaced by domain vocabulary. product(id:), invoice(id:), shipment(id:) — no bare search or get fields two teams can both claim. A collision is then a merge conflict in review instead of a gateway that refuses to start.
  • Ownership follows the directory. CODEOWNERS maps domains/billing/** to the billing team, so a schema change is reviewed by people who understand its consumers and the rest of the graph stays out of the diff.

Types get namespaced the same way when a word is ambiguous: ShippingAddress and BillingAddress beat two teams each defining Address with different fields. If two domains genuinely mean the same thing, that is a signal to move the type to shared/ rather than let each one keep a private copy.

The client still sees one endpoint with one introspection result. Composition happens at build or publish time on the server side; nobody hands a client three schemas and asks them to merge — see stitching versus federation for how that server-side assembly is structured.

Shared Types and Explicit Cross-Domain Fields

Shared Types and Explicit Cross-Domain Fields

Some types belong to nobody because everybody needs them: Money, PageInfo, DateTime, a shared Actor. Define them once in shared/ and import them everywhere. Two modules each defining their own Money is a composition failure waiting to happen — in federation it is an outright compose error, and in plain builds it is two types that look identical and normalize differently in a client cache.

Cross-domain references deserve the same discipline. The tempting shortcut:

# billing/schema.graphql
type Invoice {
  id: ID!
  total: Money!
  "Reaches into the catalog database. No field owner, no resolver boundary."
  productSku: String!
}

The explicit version:

type Invoice {
  id: ID!
  total: Money!
  lines: [InvoiceLine!]!   # billing owns these
}

type InvoiceLine {
  description: String!
  amount: Money!
  product: Product!        # cross-domain field with a named resolver
}

InvoiceLine.product is a real field whose resolver is written by billing and calls the catalog's public API or loader. The dependency is visible in code review, instrumentable with its own latency metrics, and removable when the integration goes away. A hidden join reaching across databases produces none of those: a catalog migration silently breaks billing queries, and nobody discovers the coupling until an incident has two teams in the same room.

The trade you are making

An explicit field can cost an extra hop in the query plan. That cost is a known, solvable problem — batching across the boundary keeps it to one request per parent set, as in manual batch loading — while invisible coupling has no ceiling and no owner. Prefer the explicit field, and let the resolver decide whether the data comes from a join, a cache, or a service call; the schema should describe the shape of the graph, not the topology of the databases behind it.

Circular references between domains are acceptable in GraphQL — depth and cost limits are what protect you — provided each crossing edge is a declared field with an owner rather than an accident of two teams pointing at each other's tables.

Composition Checks Before Merge

Composition Checks Before Merge

Composition is where naming collisions, redefinitions, and accidental breakage surface. Catch them in CI, where the failure is a red build instead of a gateway that will not start at 2 a.m.

# plain schema diff: fails on breaking changes
npx @graphql-inspector/diff current.graphql proposed.graphql

# federation: validate the subgraph against the registered supergraph
rover subgraph check my-graph@prod --name catalog --schema ./schema.graphql

What these catch that human review often misses:

Failure Why composition rejects it
Two modules declare type Money with different fields one name, two definitions
Two modules declare Query.search root fields must be unique
Two modules declare directive @auth with different arguments directive definitions must match exactly
A field removed or its type narrowed existing clients would break

The directive row surprises people: two teams writing the same directive definition with a different argument list is not merged, it is rejected — the composition engine has no basis for choosing a winner. Moving shared directives to shared/ alongside shared types removes the whole class, which is the theme of this topic: duplicates are cheaper to prevent than to resolve.

The review checklist

  1. Names are owned. Every type and root field maps to one directory and one team.
  2. Shared types are imported, never redefined. Money exists once, in shared/.
  3. Cross-domain fields name their resolver. No field that only works if someone else's table shape holds.
  4. Directive definitions appear once, in the shared module — see custom type system directives.
  5. Breaking changes have a runway. A diff that removes a field goes back for a plan — see deprecation strategy.

Why this is worth ceremony

Teams ship independently only while the graph enforces the boundaries for them. With owned modules, imported shared types, explicit crossing fields, and a mechanical check on every pull request, a schema grows the way a well-structured codebase does: bounded pieces, visible dependencies, and failures that happen where the fix is cheap.

Practice Problems

0 / 1 solved
Review a Schema Diff for Composition Breakage
Composition Review

Two teams ship in the same release. Catalog adds type Money { amount: Int! currency: String! } and Query.search; Billing independently adds type Money { minor: Int! currency: String! } and also declares Query.search for invoices. Review the diff, name every failure composition will raise, and restructure the schema so both domains compose.

Example:

Input: Catalog defines Money { amount, currency } and Query.search; Billing defines Money { minor, currency } and Query.search

Output: Composition fails twice: duplicate type Money with different fields, and duplicate root field Query.search

A name may have only one definition and a root field may have one owner, so the engine rejects the build rather than merging shapes or picking a winner.

Optimal Solution — O(n) over n types and fields during the diff time, O(n) for the loaded schema documents space

Move the shared type into the shared module, namespace each root field by domain vocabulary, then gate every pull request with a diff check and a composition check so the collision fails in CI.

// shared/money.graphql
type Money {
  minor: Int!
  currency: String!
}

// domains/catalog/schema.graphql
type Product { price: Money! }
type Query {
  product(id: ID!): Product
  products(filter: ProductFilter): [Product!]!
}

// domains/billing/schema.graphql
type Invoice { total: Money! }
type Query {
  invoice(id: ID!): Invoice
  invoices(filter: InvoiceFilter): [Invoice!]!
}

# CI gate
npx @graphql-inspector/diff current.graphql proposed.graphql
rover subgraph check my-graph@prod --name billing --schema ./schema.graphql

Edge Cases:

  • Two modules declaring the same directive with different argument types
  • A shared type defined once but extended privately by a domain
  • A rename that passes composition but breaks existing client queries

Quiz

1. Two composed modules both define type Money with different fields. What happens?

Question 1 options

2. Billing needs to display catalog data on an invoice line. What is the preferred schema design?

Question 2 options

3. Why should shared types live in one common module imported by every domain?

Question 3 options

Flashcards

Question

Rule for root field naming across teams

Answer

Namespace by domain vocabulary (product(id:), invoice(id:)); no bare search/get fields that two teams can both claim.

Question

Where shared types live

Answer

One shared module (Money, PageInfo, DateTime) imported by every domain — duplicate definitions of one name fail composition.

Question

Cross-domain reference rule

Answer

Read across boundaries through an explicit field with an owned resolver, never a hidden join into another team's tables.

Question

Composition failures to catch before merge

Answer

Duplicate type names, duplicate root fields, directive definitions that differ, and removals or type narrowing that break clients.

Question

CI gates for schema changes

Answer

Schema diff against the current published graph (breaking changes fail) plus a federation subgraph check against the registered supergraph.

Revision Notes

Key Takeaways

  • 1. One graph, many owners: directories and CODEOWNERS give each team a section it can evolve alone
  • 2. Shared types are defined once and imported; duplication is a composition error, not a convenience
  • 3. Cross-domain reads go through explicit fields with named resolvers so dependencies stay visible
  • 4. Diff and composition checks in CI move failures from the gateway to the pull request

Interview Tips

  • • Sketch the directory layout first, then explain how ownership and naming rules keep teams out of each other's diffs
  • • Name specific composition failures — duplicate type, duplicate root field, mismatched directive definition — and where each is caught
  • • Explain why an explicit cross-domain field beats a join, in terms of ownership and failure isolation rather than purity

Cheat Sheet

Schema Composition Guidelines

Layout

shared/     scalars, Money, Page, shared directives
domains/<name>/  schema.graphql + resolvers.ts, owned by one team

Rules

  • Root fields namespaced by domain vocabulary: product(id:), invoice(id:).
  • Shared types defined once and imported; duplicates fail composition.
  • Cross-domain reads via explicit fields with owned resolvers, not joins.
  • Directive definitions live in shared/ (two differing definitions are rejected).

CI gates

npx @graphql-inspector/diff current.graphql proposed.graphql
rover subgraph check my-graph@prod --name catalog --schema ./schema.graphql

Checklist: owned names | imported shared types | named cross-domain resolvers | single directive definitions | breaking changes routed through deprecation.

Continue Learning

Next Topics

Related Reading