VERSION: 1.1.0 · FEB 2025 · COMPLEXITY: HIGH

Designing GraphQL Schemas for Real Business Logic

Most GraphQL tutorials model a blog or a todo list. Real systems are messier — pricing rules, role-based visibility, nested relationships, and business constraints that don't map cleanly to simple types. Here's how I approach schema design when the domain actually has complexity.

Designing GraphQL Schemas for Real Business Logic
Schema as Contract

Your GraphQL schema isn't just a data layer — it's a contract between your frontend, backend, and business rules. Design it to reflect the domain, not the database.

Constraints at the Type Level

The goal is to make invalid states unrepresentable in the schema itself — so bad data never reaches your resolvers.

This article covers the technical decisions behind building this system. Each section explores a different layer of the architecture.

Start With the Domain

The most common mistake in GraphQL schema design is starting with the database. You end up with schemas that are just a thin wrapper over your tables — technically correct, but wrong for the domain.

For Paws Paradise, I started by listing every business concept that actually mattered:

A Pet belongs to a Customer and has a size, breed, and service history
A Booking has a service type, a pet, a unit, a date range, and a status
A Unit is a room or slot with a type, capacity, and availability state
Pricing is computed — not stored — from a rule graph at booking time
A User has a role that determines what data they can see and mutate

Notice that Pricing isn't a table — it's a computation. Modeling it as a stored value would create sync issues every time a rule changed. The schema should reflect that.

Modeling Relationships

Relationships in GraphQL should reflect how the client actually needs the data — not how the database joins tables. This means thinking about query shapes before you write a single type.

For the booking flow, the client needs: the booking, the pet attached to it, the unit assigned, and the pricing breakdown — all in one query. So the types need to compose naturally:

SCHEMA.GRAPHQL
type Booking {
  id: ID!
  service: ServiceType!
  status: BookingStatus!
  checkIn: DateTime!
  checkOut: DateTime
  pet: Pet!
  unit: Unit
  pricing: PricingSummary!
  customer: User!
  addons: [Addon!]!
  createdAt: DateTime!
}

type Pet {
  id: ID!
  name: String!
  breed: String!
  size: PetSize!
  owner: User!
  bookingHistory: [Booking!]!
}

type Unit {
  id: ID!
  number: Int!
  type: UnitType!
  status: UnitStatus!
  currentOccupant: Pet
}

The Unit type exposes currentOccupant — not a foreign key, but the resolved pet. This is intentional: the frontend never has to make a second query to find out who's in a room.

Enums as Business Rules

Enums are underused in most GraphQL schemas. They're not just convenience — they're business rules encoded at the type level. If a field can only have specific values, make that constraint explicit.

SCHEMA.GRAPHQL
enum BookingStatus {
  PENDING
  CONFIRMED
  CHECKED_IN
  CHECKED_OUT
  CANCELLED
}

enum ServiceType {
  LODGING
  DAYCARE
  GROOMING
}

enum PetSize {
  SMALL   # under 15 lbs
  MEDIUM  # 15-40 lbs
  LARGE   # 40-80 lbs
  XLARGE  # 80 lbs+
}

enum UnitStatus {
  VACANT
  OCCUPIED
  RESERVED
  MAINTENANCE
}

PetSize directly drives pricing — a LARGE dog in a suite costs more than a SMALL dog in the same suite. By encoding this in the schema, the pricing engine can use it as a typed input instead of a loose string.

Input Validation Patterns

GraphQL inputs are where most schemas get lazy. A common pattern is accepting nullable fields everywhere and validating in the resolver. This pushes errors downstream and makes the contract unclear.

Instead, use non-nullable inputs for required fields, and split mutations by intent:

SCHEMA.GRAPHQL
input CreateBookingInput {
  serviceType: ServiceType!
  petId: ID!
  checkIn: DateTime!
  checkOut: DateTime       # nullable — daycare has no checkout
  selectedAddons: [ID!]
  discountCode: String
}

input ConfirmBookingInput {
  bookingId: ID!
  unitId: ID!            # required at confirmation step
}

input CancelBookingInput {
  bookingId: ID!
  reason: String
}

Three separate inputs for three distinct business operations. CreateBookingInput doesn't ask for a unit — that's assigned at confirmation. This mirrors the actual 6-step booking flow and prevents invalid state combinations at the schema level.

Role-Based Field Visibility

Not every field should be visible to every role. A customer shouldn't see staff notes. A staff member shouldn't see admin revenue reports. This logic belongs in the resolvers — not in separate schemas.

RESOLVER.TS
const BookingResolver = {
  Booking: {
    // Only admin and staff can see internal notes
    staffNotes: (parent, _, context) => {
      if (!['ADMIN', 'STAFF'].includes(context.user.role)) {
        return null
      }
      return parent.staffNotes
    },

    // Customers only see their own pricing
    pricing: (parent, _, context) => {
      if (
        context.user.role === 'CUSTOMER' &&
        context.user.id !== parent.customerId
      ) {
        throw new ForbiddenError('Access denied')
      }
      return computePricing(parent)
    }
  }
}

Field-level resolvers let you apply permissions granularly — a customer can query a booking's status without seeing the internal notes or other customers' data. The schema stays unified; the access control lives in the resolution layer.

Schema Evolution

A schema in production is a contract. Breaking it means breaking clients. Here's how I approach changes without breaking existing queries:

Add fields freely — new fields don't break existing queries
Never remove or rename fields — deprecate them first with @deprecated
Never change argument types — add new arguments as optional instead
Use versioned input types for major flow changes (CreateBookingV2Input)
Test schema changes against existing query documents before deploying
SCHEMA.GRAPHQL
type Booking {
  id: ID!
  status: BookingStatus!

  # Deprecated: use pricing.total instead
  totalPrice: Float @deprecated(reason: "Use pricing.total")

  pricing: PricingSummary!
}

The @deprecated directive signals to clients and tooling that a field is going away — without removing it immediately. This gives frontend teams time to migrate.

Key Takeaways

MODEL THE DOMAIN NOT THE DATABASE

Your schema should reflect business concepts — bookings, pets, units, roles — not tables and foreign keys. The database is an implementation detail.

ENUMS ENCODE BUSINESS RULES

If a field can only hold specific values, make it an enum. It documents intent, enables tooling, and prevents invalid states from entering the system.

SPLIT MUTATIONS BY INTENT

One mutation per business operation — not one generic update mutation. This makes the API self-documenting and prevents invalid state transitions.

PERMISSIONS LIVE IN RESOLVERS

Field-level access control in resolvers is more precise and more secure than route-level guards or separate schemas per role.

#graphql#schema_design#typescript#api_design#architecture
PREV_LOGNEXT_LOG