Aug 15, 2023 ·
Modular GraphQL Schema Stitching in Django
GraphQL starts clean when the project is small. Then every app wants fields, mutations, auth rules, and client types, and the schema file becomes the place where boundaries go to disappear.
GraphQL starts clean at small scale. Then every domain wants fields, mutations, auth rules, and client types, and the schema becomes the place where boundaries go to disappear.
This use case came from a Django product with a Graphene API and a separate mobile client. The backend needed one public GraphQL endpoint, but the code could not stay healthy if every domain pushed fields into one shared schema surface. The client also needed generated types, so the schema had to be more than a convenient backend object. It had to be a contract.
The approach was to let each Django app own a thin GraphQL slice, compose those slices once into the public schema, then feed the resulting schema into GraphQL Code Generator for the mobile app. That is schema composition, not federation, and the distinction matters.
Keep ownership inside the app
The app-level schema surfaces were intentionally thin. Each domain exposed local query and mutation wrappers, and each wrapper was a Graphene object type. Domain resolvers, auth mutations, and field decisions stayed near the Django app that owned the behavior.
That local ownership changes how a team navigates the API. If a developer needs to change behavior in one domain, they start in that domain. If another domain adds a mutation, it exposes the mutation through its own wrapper. The composition layer should not become the first place every domain decision is made.
Graphene-Django supports this shape because the final schema is a Graphene schema object with root query and mutation entry points. The app wrappers are Python classes that can be combined into one root schema. No gateway, registry, or build-time schema stitcher is required for that level of modularity.
The important discipline is naming. App-local ownership does not automatically prevent collisions. If two app slices expose the same root field name, class composition will not turn that into a product decision. A naming conflict is still a design problem, and the domain that owns the client-facing behavior needs to resolve it deliberately.
I would also keep app schema surfaces free of cross-app shortcuts. A domain query can return related user-facing data, but that does not mean the domain schema should import unrelated mutations to save a few lines. Once those shortcuts appear, the visible layout still looks modular while runtime ownership becomes unclear.
Compose once at the root
The root schema had one job: assemble the public contract. It imported the app-owned query and mutation wrappers, combined them into root query and mutation types, and created one graphene.Schema.
That composition point should stay boring. It should not contain business logic, permission rules, resolver bodies, or surprise imports that make application startup depend on unrelated domains. The root schema is the assembly point, not the application.
This is also where the caveat about federation belongs. GraphQL federation has a gateway and subgraphs that publish a federated contract. This pattern does not do that. It is one Django process composing Python classes into one Graphene schema. Calling it federation would overstate the architecture and set the wrong expectations for service ownership, conflict handling, deployment, and observability.
The pattern is still valuable. It gives a monolithic Django backend a modular GraphQL surface. That is often enough. A team can keep domain code near the Django app that owns it while presenting clients with a single endpoint.
The root composition should be reviewed whenever an app is added or removed. That is the moment the global API changes shape. If the team treats schema assembly as plumbing, it can miss the fact that a client-visible contract changed even when no resolver body moved.
Make tooling consume the same contract
GraphQL only pays off for clients when the schema becomes a usable contract. The backend settings pointed Graphene at the root schema and produced an introspection JSON artifact. Graphene's introspection support lets tooling inspect the server contract without reading Python code.
The mobile app added the next step. GraphQL Code Generator read the configured schema endpoint, scanned client operations, and emitted generated client artifacts with the client preset. The GraphQL Code Generator documentation describes codegen.ts as the standard configuration entry point, and the client preset is designed to generate typed operations for modern GraphQL clients.
That setup creates a feedback path from backend schema to mobile code. When a field changes, client generation has a chance to fail near the change instead of waiting for a runtime screen to break. It also makes the schema endpoint part of the delivery contract, not just a backend implementation detail.
There is an operational warning. Code generation against a deployed endpoint proves the generator can currently reach and interpret that endpoint. It does not prove that a local backend branch is compatible. Code generation against a local server proves a different thing: the branch's schema can satisfy the client operations under test.
For release work, make that target deliberate. Generate against the deployed endpoint when validating what the current mobile app can consume. Generate against a local backend when validating a backend change before release. Both checks are useful, but they answer different questions.
Keep auth and schema boundaries separate
The backend also configured GraphQL middleware and JWT behavior. That belongs near the schema contract, but it is not the same concern as schema composition.
A field appearing in the root schema does not mean every caller can use it. A mutation being app-owned does not mean it should bypass shared auth policy. The boundary between "what exists" and "who can call it" needs to remain visible.
This is where many GraphQL monoliths get messy. Developers put ownership, field naming, permissions, error conventions, and client generation into one mental bucket called "the schema." The system is easier to reason about when those concerns stay separated. App schema wrappers answer where fields come from. Root composition answers how the global contract is assembled. Auth settings answer which operations can be called without an existing authenticated user.
GraphQL's schema documentation frames the schema as the set of capabilities a client can query. That definition is useful because it leaves room for authorization to narrow access at execution time. The schema describes the shape. Policy decides access.
That distinction should show up in tests. Schema snapshot or codegen checks can prove the operation shape still exists. Auth tests should prove the right caller can execute it. A passing codegen run should never be used as evidence that permissions are correct.
Implementation path
Start by giving each Django app a local GraphQL schema surface that exports two wrappers: one for queries and one for mutations. Keep those wrappers thin. Import the app's actual query and mutation classes, inherit from them, and add graphene.ObjectType.
Next, create one root schema for the public contract. Import the app wrappers there and compose a single root query and mutation. Keep inheritance order reviewed because it can affect conflict behavior. If two apps want the same root field name, stop and choose a name rather than depending on method resolution order as product policy.
Then wire Graphene settings to the root schema. Add schema export configuration if client tooling or documentation needs introspection JSON. Treat the generated schema file as an artifact of the backend contract, not as hand-edited source.
After that, connect client code generation. Point GraphQL Code Generator at the endpoint that represents the contract you want the client to trust. For local development, that may be a local server. For release builds, it may be a deployed environment. Make the choice explicit.
Finally, add review rules. New GraphQL fields should name their owning app, auth expectations, client use case, and generated client artifacts that change. That small checklist prevents schema growth from becoming an unreviewed public API.
If the schema already exists, do not start with a rewrite. Pick one app, move only its wrapper into the local app schema, and leave root assembly as the composition point. Repeat once field ownership is clear.
When to use this pattern
Use this pattern when a Django monolith needs one GraphQL endpoint but multiple domains need to own their own fields and mutations. It is especially useful when a mobile or web client consumes generated GraphQL types from the same contract.
Do not use the language of federation unless you actually have federated services, a gateway, and subgraph ownership. Schema composition inside one Django process gives modularity, not distributed GraphQL architecture.
If your GraphQL schema has started to feel like shared mutable state, bring one domain and its client operations. I can help separate ownership, root composition, and code generation without pretending the monolith is something else.