GraphQL schema design for web application

Our company is engaged in the development, support and maintenance of sites of any complexity. From simple one-page sites to large-scale cluster systems built on micro services. Experience of developers is confirmed by certificates from vendors.
Development and maintenance of all types of websites:
Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Our competencies:
Development stages
Latest works
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    822
  • image_crm_chasseurs_493_0.webp
    CRM development for Chasseurs
    847
  • image_website-sbh_0.png
    Website development for SBH Partners
    999
  • image_website-_0.png
    Website development for Red Pear
    451

Designing GraphQL Schema for Web Applications

A good GraphQL schema is a contract between frontend and backend that lasts for years. A poorly designed schema accumulates workarounds within three months: broken n+1 queries, fields with unclear semantics, type UserOrError instead of proper error handling. Design starts with usage questions, not by mapping database tables.

Design Principles

Schema is product-oriented, not storage-oriented. REST endpoints often mirror database structure. In GraphQL—the opposite: first determine what the UI needs, then design types.

Nodes and Edges via Relay specification. For medium-sized and larger projects, adopt Relay-compatible structure from the start—it standardizes pagination and global IDs.

Nullable by default. A nullable field is better than non-null that breaks the entire query on one null value. Use non-null (!) only where you guarantee a value at the business logic level.

Base Types

type Query {
  node(id: ID!): Node
  product(id: ID!): Product
  products(filter: ProductFilter, page: PaginationInput): ProductConnection!
  order(id: ID!): Order
  viewer: User                    # current authenticated user
}

type Mutation {
  createOrder(input: CreateOrderInput!): CreateOrderPayload!
  updateOrderStatus(input: UpdateOrderStatusInput!): UpdateOrderStatusPayload!
  addToCart(input: AddToCartInput!): AddToCartPayload!
}

type Subscription {
  orderStatusUpdated(orderId: ID!): OrderStatusEvent!
}

interface Node {
  id: ID!
}

Payload Pattern for Mutations

Never return a bare object type from a mutation. Use a payload wrapper:

type CreateOrderPayload {
  order: Order              # null on error
  userErrors: [UserError!]! # always an array, empty on success
}

type UserError {
  field: [String!]          # path to error field, e.g. ["items", "0", "quantity"]
  message: String!
  code: OrderErrorCode
}

enum OrderErrorCode {
  INSUFFICIENT_STOCK
  INVALID_ADDRESS
  PAYMENT_DECLINED
  PRODUCT_UNAVAILABLE
}

Difference between userErrors and GraphQL errors (errors): userErrors are predictable business errors the client should handle. GraphQL errors are unforeseen situations (exceptions, network errors).

Pagination: Cursor-Based

type ProductConnection {
  edges: [ProductEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type ProductEdge {
  node: Product!
  cursor: String!           # opaque base64 cursor
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

input PaginationInput {
  first: Int
  after: String
  last: Int
  before: String
}

Filtering and Sorting

input ProductFilter {
  categoryIds: [ID!]
  priceMin: Decimal
  priceMax: Decimal
  inStock: Boolean
  search: String
  tags: [String!]
}

enum ProductSortField {
  PRICE
  CREATED_AT
  POPULARITY
  NAME
}

enum SortDirection {
  ASC
  DESC
}

input ProductSort {
  field: ProductSortField!
  direction: SortDirection!
}

Versioning and Deprecation

GraphQL isn't versioned via URL (/v2/graphql). Instead—continuous evolution:

type Product {
  id: ID!
  name: String!

  # Deprecated field—kept for compatibility
  price: Decimal @deprecated(reason: "Use `pricing.basePrice` instead")

  pricing: ProductPricing!
  variants: [ProductVariant!]!
  categories: [Category!]!

  # Debug metadata
  _debug: ProductDebugInfo @deprecated(reason: "Debug only, remove before production")
}

type ProductPricing {
  basePrice: Decimal!
  salePrice: Decimal
  currency: CurrencyCode!
  isOnSale: Boolean!
  discountPercent: Int
}

Directives

Custom directives for cross-cutting concerns:

directive @auth(requires: Role = USER) on FIELD_DEFINITION
directive @rateLimit(max: Int!, window: String!) on FIELD_DEFINITION
directive @cacheControl(maxAge: Int, scope: CacheControlScope) on FIELD_DEFINITION | OBJECT

enum Role { ADMIN MANAGER USER GUEST }
enum CacheControlScope { PUBLIC PRIVATE }

type Order {
  id: ID!
  status: OrderStatus!
  items: [OrderItem!]!
  customer: User! @auth(requires: MANAGER)
  internalNotes: String @auth(requires: ADMIN)
}

type Query {
  products: ProductConnection! @cacheControl(maxAge: 300, scope: PUBLIC)
  dashboard: DashboardStats! @auth(requires: MANAGER) @rateLimit(max: 60, window: "1m")
}

Complete Entity Schema Example

type Order implements Node {
  id: ID!
  number: String!                   # human-readable number, e.g. "ORD-2024-001234"
  status: OrderStatus!
  createdAt: DateTime!
  updatedAt: DateTime!
  completedAt: DateTime

  customer: User!
  shippingAddress: Address!
  billingAddress: Address

  items: [OrderItem!]!
  itemsCount: Int!

  subtotal: Money!
  shippingCost: Money!
  discount: Money!
  total: Money!

  payment: Payment
  shipment: Shipment
  timeline: [OrderTimelineEvent!]!  # status change history
}

scalar DateTime
scalar Decimal

type Money {
  amount: Decimal!
  currency: CurrencyCode!
  formatted: String!               # "1,234.56 USD"—for direct display
}

Schema Splitting by Domain

For large projects, split schema into files by domain and merge on load:

schema/
  base.graphql         # Query, Mutation, Subscription, Node
  products.graphql
  orders.graphql
  users.graphql
  payments.graphql
  scalars.graphql      # DateTime, Decimal, Money, CurrencyCode
  directives.graphql

Timelines

Schema design for mid-scale application (5–10 entities): 3–5 days. With review, field documentation, and coordination with frontend team: 1 week.