Skip to content
GraphQL Schema-First Design: Defining Your API Contract Before Code 4 min

GraphQL Schema-First Design: Defining Your API Contract Before Code

SD
ScaleDojo
May 11, 2026
4 min read986 words
GraphQL Schema-First Design: Defining Your API Contract Before Code

How GitHub Built Their v4 API

When GitHub launched its v4 API in 2016, it picked GraphQL and committed to GraphQL schema-first design as its guiding philosophy. Instead of letting backend code generate the schema automatically (the code-first path), GitHub's team designed the schema as a contract first, sitting down with frontend engineers, mobile teams, and integration partners before writing resolvers. The result was an API that felt natural to the people consuming it rather than one that just mirrored GitHub's internal database tables.

You can see this in the details. The Repository type exposes stargazers, not stars_count. It has pullRequests with filter arguments instead of a REST endpoint like /repos/:id/pulls?state=open. And collaborators is a proper connection type, not a raw array someone has to paginate by hand. This schema-first philosophy is a big part of why GitHub's GraphQL API is still held up as one of the more developer-friendly ones in production, handling billions of queries a month.

If this sounds familiar, it's the same instinct behind designing your low-level building blocks before touching a database schema; see our piece on the building blocks of low-level design for the same idea applied one layer down.

Schema-First vs Code-First

Two Approaches to Building GraphQL APIs:

  CODE-FIRST (bottom-up):
  +-----------+     +-----------+     +-----------+
  | Database  | --> | Resolvers | --> | Schema    |
  | tables    |     | (code)    |     | (generated)|
  +-----------+     +-----------+     +-----------+

  Problem: Schema mirrors your DB, not client needs.
  Example: User.created_at_timestamp (DB column name leaks)
           User.user_status_id (foreign key leaks)

  SCHEMA-FIRST (top-down):
  +-----------+     +-----------+     +-----------+
  | Client    | --> | Schema    | --> | Resolvers |
  | needs     |     | (contract)|     | (implement)|
  +-----------+     +-----------+     +-----------+

  Result: Schema serves the consumer.
  Example: User.joinedAt (readable name)
           User.status (enum: ACTIVE | SUSPENDED | DELETED)

  Aspect           Code-First        Schema-First
  ---------------  ----------------  ----------------
  Who drives it?   Backend devs      Frontend + Backend
  API quality      DB-shaped         Consumer-shaped
  Parallel dev     Sequential        Frontend starts immediately
  Breaking changes Easy to introduce Schema review catches them
  Tooling          Introspection     Schema file is documentation

The table row that matters most in practice is "parallel dev." Code-first forces frontend teams to wait until resolvers exist before they can build against real shapes. Schema-first flips that, once the contract is agreed on, both sides start immediately, which is the whole point of GraphQL API design done well.

Designing Good Schemas

Schema Design Principles:

  1. USE DOMAIN LANGUAGE, NOT DB LANGUAGE
     BAD:  User.full_name_varchar  (DB column)
     GOOD: User.fullName           (domain term)

  2. DESIGN MUTATIONS AROUND BUSINESS OPERATIONS
     BAD:  updateOrder(id, status: 'shipped')    (CRUD)
     GOOD: shipOrder(id, trackingNumber: '...')   (business action)
     Why:  Business mutations encode rules (e.g., can't ship cancelled order)

  3. MAKE NULLABILITY INTENTIONAL
     BAD:  type User { name: String }    # nullable by default - WHY?
     GOOD: type User { name: String! }   # non-null - always has a name
           type User { bio: String }     # nullable - user may not set bio

  4. USE CUSTOM SCALARS FOR DOMAIN TYPES
     BAD:  createdAt: String    # '2024-01-15'? '1705276800'? ISO format?
     GOOD: createdAt: DateTime  # self-documenting, validated

  5. INPUT TYPES FOR MUTATIONS
     BAD:  createUser(name: String!, email: String!, role: String!)
     GOOD: createUser(input: CreateUserInput!): CreateUserPayload!

  Example Well-Designed Schema:

    type Query {
      viewer: User!                           # current authenticated user
      repository(owner: String!, name: String!): Repository
      search(query: String!, first: Int): SearchResultConnection!
    }

    type Mutation {
      createRepository(input: CreateRepoInput!): CreateRepoPayload!
      starRepository(id: ID!): StarRepoPayload!
      # NOT: updateRepository(id, starred: true)  <-- CRUD thinking
    }

Rule three trips people up the most. Nullability in GraphQL isn't a formality, it's a promise to every client that consumes the field. If name is nullable "just in case," every frontend engineer now has to write null-check code for a field that, in reality, always has a value. Be deliberate about it, and treat every ! or missing ! as a decision you're making on purpose, not a default you forgot to change.

Schema as a Development Contract

Parallel Development Workflow:

  Day 1: Frontend + Backend agree on schema
         |                                |
  Day 2: Frontend builds UI              Backend builds resolvers
         using mock resolvers             against real data sources
         (schema is the contract)         (schema is the target)
         |                                |
  Day 5: Integration                     |
         Mock resolvers replaced          |
         with real GraphQL endpoint       |
         |________________________________|
         Works immediately because both
         followed the same schema contract.

  Schema Review Checklist:
  [ ] Does every type use domain language?
  [ ] Are nullable fields intentionally nullable?
  [ ] Do mutations represent business operations?
  [ ] Are pagination fields using connections (not arrays)?
  [ ] Do error responses use union types?
  [ ] Is the schema backward-compatible with v(N-1)?

This is where schema-first design earns its keep on a real team. Once the contract is written down, the frontend can build against mock resolvers on day two instead of waiting around for the backend to finish its data layer. If you've worked with contract-first tooling on the async side, this will feel familiar, it's the same principle behind AsyncAPI, CloudEvents, and the transactional outbox pattern, just applied to request/response instead of events.

GraphQL is also only one of several ways to shape the contract between clients and services. If you're weighing it against REST gateways, BFFs, or tRPC, it's worth reading our rundown of API Gateway, BFF, tRPC, and the multi-protocol architecture before committing to one pattern across a whole org.

Interview Tip

When discussing GraphQL API design, say: "I prefer schema-first design because it forces the API to serve client needs rather than mirror backend structure. The schema becomes a contract that enables parallel development, frontend and backend teams work independently against the same contract. I design mutations as business operations (shipOrder, not updateOrderStatus) and use non-null types intentionally. Before merging schema changes, I run a backward-compatibility check to ensure existing clients are not broken." Mentioning GitHub's v4 API as a schema-first success story adds credibility.

GraphQL Schema-First Design: Defining Your API Contract Before Code - Architecture Diagram

Architecture overview

Key Takeaway

Schema-first design puts the API contract at the center of development. Design the schema collaboratively with frontend teams using domain language, business-operation mutations, and intentional nullability. The schema becomes documentation, contract, and parallel-development enabler all at once. Done well, this approach produces consumer-friendly APIs that outlast any backend refactor.

Enjoyed this article?

Share it with your network to help others level up their system design skills.

Discussion0

Join the Discussion

Sign in to leave comments, reply to others, or like insights.

Sign In to ScaleDojo

No comments yet. Be the first to start the thread!

Related Articles

Enjoyed this content?

Your support keeps us creating free resources

We put a lot of hours into researching and writing these guides. If it helped you, consider buying us a coffee. Every bit goes toward keeping ScaleDojo's content free and growing.

$

One-time payment via Stripe. ScaleDojo account required.

ScaleDojo Logo
Initializing ScaleDojo