โœฆ StealThis .dev

GraphQL Schema Architecture

GraphQL API structure comparing schema-first vs code-first approaches with domain-based module organization and resolver patterns.

Open in Lab
graphql nodejs typescript
Targets: HTML

Code

GraphQL Schema Architecture

A reference architecture for organizing GraphQL APIs, comparing schema-first and code-first approaches and illustrating two folder structures โ€” domain-based modules (recommended for scale) and role-based separation (simpler for small projects).

Schema-first vs Code-first

AspectSchema-firstCode-first
DefinitionWrite .graphql SDL files; generate typesDefine schema in code (TypeScript decorators / builders)
ToolsApollo Server, GraphQL Yoga, graphql-toolsTypeGraphQL, Nexus, Pothos
ProsClear contract, readable, great for collaborationSingle source of truth, full IDE support, no codegen step
ConsRequires codegen to keep types in syncSchema is scattered across code files
Best forTeams that want a spec-first workflowSolo/small teams who want maximum type safety

Group everything related to a domain concept in one folder. Each module owns its schema, resolvers, data sources, and types. This mirrors how the team thinks about the product and scales naturally:

src/modules/users/
โ”œโ”€โ”€ schema.graphql     # Type definitions for User
โ”œโ”€โ”€ resolvers.ts       # Query/Mutation/Field resolvers
โ”œโ”€โ”€ datasource.ts      # Data access (DB, REST, etc.)
โ””โ”€โ”€ types.ts           # TypeScript interfaces

Modules are merged at the root level using mergeTypeDefs / mergeResolvers from @graphql-tools/merge, or with a tool like GraphQL Modules.

Role-based Separation (Simpler)

For smaller projects, grouping by role (all type definitions together, all resolvers together) is easier to start with but can become unwieldy as the schema grows:

src/
โ”œโ”€โ”€ typeDefs/          # All .graphql files
โ”œโ”€โ”€ resolvers/         # All resolver files
โ”œโ”€โ”€ dataSources/       # All data source classes
โ””โ”€โ”€ server.ts

Resolver Patterns

Resolvers are the heart of a GraphQL server. Best practices:

  • Thin resolvers โ€” Resolvers should be one-liners that delegate to data sources or services. No business logic in resolvers.
  • Context injection โ€” Pass auth info, DB connections, and DataLoader instances through the context object.
  • Field resolvers โ€” Use them for computed fields, relationships, and authorization checks at the field level.
const resolvers = {
  Query: {
    user: (_, { id }, { dataSources }) =>
      dataSources.usersAPI.getUser(id),
  },
  User: {
    posts: (parent, _, { dataSources }) =>
      dataSources.postsAPI.getByAuthor(parent.id),
  },
};

The N+1 Problem & DataLoader

GraphQLโ€™s nested nature makes it prone to N+1 queries. If a query fetches 50 users and each user has posts, that is 1 query for users + 50 queries for posts = 51 total.

DataLoader batches and caches within a single request:

// context.ts
import DataLoader from 'dataloader';

export const createContext = () => ({
  loaders: {
    postsByAuthor: new DataLoader(async (authorIds) => {
      const posts = await db.posts.findMany({
        where: { authorId: { in: authorIds } },
      });
      return authorIds.map(id => posts.filter(p => p.authorId === id));
    }),
  },
});

Request Flow

Client Query โ†’ Parse & Validate โ†’ Execute Resolvers โ†’ DataSource / DataLoader โ†’ Format Response
  1. Parse โ€” The server parses the incoming query string into an AST.
  2. Validate โ€” The AST is validated against the schema (types, fields, arguments).
  3. Execute โ€” Resolvers are called top-down, field by field.
  4. DataSource โ€” Each resolver delegates to a data source or DataLoader.
  5. Response โ€” Results are assembled into the shape requested by the client.

References