Project layout
Grow a single-file schema into a modular layout once it stops fitting in one file.
You can spread a Pothos schema across as many files as you like. The smallest project lives in one schema.ts; larger ones split across many. This page covers moving from one file to several.
const builder = new SchemaBuilder({});
const Race = builder.objectRef<IRace>('Race').implement({
fields: (t) => ({ id: t.exposeID('id'), name: t.exposeString('name') }),
});
const Character = builder.objectRef<ICharacter>('Character').implement({
fields: (t) => ({
id: t.exposeID('id'),
name: t.exposeString('name'),
race: t.field({ type: Race, resolve: (c) => Races.get(c.raceId)! }),
}),
});
builder.queryType({
fields: (t) => ({
characters: t.field({ type: [Character], resolve: () => Characters }),
races: t.field({ type: [Race], resolve: () => [...Races.values()] }),
}),
});
export const schema = builder.toSchema();A single file works well for a small schema. As it grows, splitting it across files keeps each type easy to find, rather than scrolling past a hundred-line Character definition to reach the Race type.
A modular layout
Most projects settle on the same shape: a shared builder module, one file per domain entity, and a schema.ts that imports each module before building the schema.
// builder.ts — the one place the SchemaBuilder is constructed.
export const builder = new SchemaBuilder({});
builder.queryType({});// race.ts — owns the Race type and the queries that return it.
import { builder } from './builder';
import { type IRace, Races } from './data';
export const Race = builder.objectRef<IRace>('Race').implement({
fields: (t) => ({
id: t.exposeID('id'),
name: t.exposeString('name'),
}),
});
builder.queryFields((t) => ({
races: t.field({
type: [Race],
resolve: () => [...Races.values()],
}),
}));// character.ts — owns Character + its queries. Cross-domain reference
// (race) lives here, not in race.ts.
import { builder } from './builder';
import { Characters, type ICharacter, Races } from './data';
import { Race } from './race';
export const Character = builder.objectRef<ICharacter>('Character').implement({
fields: (t) => ({
id: t.exposeID('id'),
name: t.exposeString('name'),
race: t.field({ type: Race, resolve: (c) => Races.get(c.raceId)! }),
}),
});
builder.queryFields((t) => ({
characters: t.field({
type: [Character],
resolve: () => Characters,
}),
}));// schema.ts — imports every domain module for its side effects,
// then builds.
import { builder } from './builder';
import './race';
import './character';
export const schema = builder.toSchema();The shape:
builder.tsis the one file that callsnew SchemaBuilder. Every other module importsbuilderfrom here. There's exactly one builder per schema.- One file per domain entity, holding the object type, its fields, and the query/mutation entrypoints that return it. Cross-domain references (a
Characterreturning aRace) live in the file that depends on the other type, not in the one being depended on. schema.tsis the entry point. It imports each domain module for its side effects, then callsbuilder.toSchema(). The whole graph is registered by the timetoSchema()runs.
Why side-effect imports
The domain modules call builder.objectRef(...).implement(...) and builder.queryFields(...) at the top level. These are side effects: they register the type and its fields on the shared builder. schema.ts only needs each module loaded before toSchema(); it doesn't use any of their exports.
If you'd rather avoid side-effect imports, every module can export its types and schema.ts can list them explicitly:
import { Race } from './race';
import { Character } from './character';
const _types = [Race, Character]; // ensures both modules are loaded
export const schema = builder.toSchema();Both produce the same schema; use whichever your team prefers.
When to split further
Once a single domain module crosses 200–300 lines, split inside it. builder.objectField adds a field to an existing type from anywhere, so a separate character/weapons.ts can attach a wieldedWeapons field to Character without crowding character.ts:
// character/weapons.ts
builder.objectField(Character, 'wieldedWeapons', (t) =>
t.field({
type: [Weapon],
resolve: (c) => loadWeaponsFor(c.id),
}),
);This is most useful for cross-domain attachments: a character × item field belongs naturally in neither the character module nor the item module.
Two reference layouts
For larger schemas, the repo's examples/ directory has two trees worth reading:
examples/lord-of-the-rings— core-only Pothos, no plugins. Demonstrates the layout above with rich interfaces, unions, and cross-domain references.examples/ultimate-league— Drizzle + scope-auth + validation. Shows how plugin configuration sits alongside the same layout.
Both are complete, runnable projects.