GraphQL Schema Design: Fields, Types, Interfaces and Unions Done Right
AI generated
{ }
type
GraphQL · Schema Design · Interfaces · Unions · Magento
GraphQL Schema Design:
Fields, Types, Interfaces and Unions Done Right

A poorly designed schema is hard to extend, forces breaking changes, and makes resolvers complex. The right nullability decisions, custom scalars, and correct use of interfaces and unions determine how long a GraphQL API stays viable.

20 min read Interfaces · Unions · Custom Scalars · Nullability · Schema-First SDL · Magento GraphQL · graphqls files

1. Schema-First as a Development Strategy

With schema-first, the GraphQL schema is defined in SDL (Schema Definition Language) as a contract artifact before frontend or backend implementation begins. The schema is the shared document between frontend developers, who need to know which data is available, and backend developers, who need to know what has to be implemented. Unlike code-first, where the schema is generated from the code, schema-first forces everyone involved to explicitly discuss data models and interfaces before code gets written.

The practical result: misunderstandings between teams surface early. A frontend developer expecting a field discountPercent: Float while the backend developer implements discount_percent: Int notices this conflict immediately if both are working from the schema as the source of truth. In Magento, schema-first manifests in the .graphqls files that every module defines. The Magento schema itself is a good example of schema-first design: all GraphQL types are defined in SDL files and merged together through Magento's schema stitching mechanism.

2. Nullability: the Often Underestimated Design Decision

Nullability in GraphQL is a deliberate design decision with far-reaching consequences. In GraphQL, every field is nullable by default, meaning a resolver is allowed to return null without triggering an error. Non-nullable fields are marked with ! (String!). The difference is fundamental: when a non-nullable field returns null, GraphQL propagates the error upward and nulls out the nearest valid nullable field. In deeply nested type structures, this can cause a single failed resolver to null out an entire subtree of the response.

The rule of thumb for nullability decisions: fields that must always be present for the API to be usefully consumed are defined as non-nullable (id: ID!, sku: String!). Fields that are conceptually optional or come from a secondary data source call remain nullable. Too many non-nullable fields make the schema rigid and increase failure exposure during partial database outages. Too many nullable fields force the frontend into constant null checks. A balanced design treats required fields as non-nullable and optional contextual data as nullable.


# Nullability design: deliberate decisions for every field type

type Product {
  # Non-nullable: without these fields the product can't be rendered
  id: ID!
  sku: String!
  name: String!
  price_range: PriceRange!
  url_key: String!

  # Nullable: optional metadata, no error if empty
  description: String
  meta_title: String
  short_description: String

  # Nullable with reason: comes from a separate service call
  # Can be null on service failure without breaking the product resolver
  review_summary: ReviewSummary

  # Non-nullable list, but nullable list elements
  # List is always present (at least empty), elements could be null
  media_gallery: [MediaGalleryInterface]!

  # Non-nullable list with non-nullable elements
  # Guaranteed: list present and all elements present
  categories: [CategoryInterface!]!
}

# Wrong: making everything non-nullable makes the resolver fragile
type ProductFragile {
  id: ID!
  sku: String!
  review_summary: ReviewSummary!  # Breaks if review service is down
  description: String!            # Breaks if there is no description value
}

3. Custom Scalars Instead of Generic String Types

Custom scalars solve a common schema problem: semantically meaningful values modeled as primitive types. A date as String, a URL as String, a JSON blob as String, all of that works technically, but gives the client no type information about how to handle the value. Custom scalars add semantic meaning: DateTime instead of String signals that an ISO 8601 date is expected. URL instead of String communicates that the value is a valid URL. This not only improves documentation, it also enables client-side validation through code generation tools.

Magento uses custom scalars in several places: Money for monetary amounts (with value and currency), though there it is solved as an object type rather than a scalar. For your own modules, a custom scalar DateTime is recommended for every timestamp instead of String, PhoneNumber for phone numbers with implicit format validation, and JSON for structured data that should be stored in a single field without defining a full type for it. The latter should be used sparingly, an explicit type is almost always better than a JSON blob.

4. Interfaces: Typing Shared Fields

GraphQL interfaces define a set of fields that every implementing type must have. They solve the problem where multiple types are conceptually related and share common fields, that commonality is explicitly expressed in the schema instead of being defined separately in each type. Magento uses interfaces extensively: ProductInterface defines all fields that every product must have, while SimpleProduct, ConfigurableProduct, and BundleProduct implement this interface and add their own additional fields.

The key principle for interface design: the interface only contains fields that make sense for all implementing types. A field that only exists for configurable products (configurable_options) does not belong in ProductInterface, it belongs in the concrete type ConfigurableProduct. Overloaded interfaces that contain fields from every possible implementation become heavy and make new implementations harder, because every new implementation has to serve all fields, even when semantically meaningless.


# Interface design: clearly defining shared fields

interface ProductInterface {
  # Fields required by ALL product types
  id: ID!
  sku: String!
  name: String!
  url_key: String!
  price_range: PriceRange!
  stock_status: ProductStockStatus!
  categories: [CategoryInterface]
  media_gallery: [MediaGalleryInterface]
}

# Each concrete type implements the interface and adds its own fields
type SimpleProduct implements ProductInterface {
  id: ID!
  sku: String!
  name: String!
  url_key: String!
  price_range: PriceRange!
  stock_status: ProductStockStatus!
  categories: [CategoryInterface]
  media_gallery: [MediaGalleryInterface]
  # SimpleProduct-specific:
  weight: Float
  only_x_left_in_stock: Float
}

type ConfigurableProduct implements ProductInterface {
  id: ID!
  sku: String!
  name: String!
  url_key: String!
  price_range: PriceRange!
  stock_status: ProductStockStatus!
  categories: [CategoryInterface]
  media_gallery: [MediaGalleryInterface]
  # ConfigurableProduct-specific:
  configurable_options: [ConfigurableProductOption]
  variants: [ConfigurableVariant]
}

# Client query using inline fragments on the interface
query GetProducts($search: String!) {
  products(search: $search) {
    items {
      __typename
      sku
      name
      price_range { minimum_price { final_price { value currency } } }
      ... on ConfigurableProduct {
        configurable_options { attribute_code label }
      }
      ... on SimpleProduct {
        only_x_left_in_stock
      }
    }
  }
}

5. Unions: Heterogeneous Result Sets

GraphQL unions model result sets that can contain values of different types with no shared fields. The typical use case is a search that can return different entity types: products, categories, CMS pages. These types share no meaningful common fields, a CMS page has no SKU, a product has no page content. Yet the client wants to receive them in a single response. Unions enable exactly that: union SearchResult = Product | Category | CmsPage.

Unions cannot declare shared fields, the client must always access the concrete types via __typename and inline fragments. This is a crucial difference from interfaces. If two types actually share common fields, an interface is the better choice, unions are for genuinely heterogeneous sets with no semantic overlap. Magento uses unions, for instance, for checkout payment method configurations, where the PayPal configuration and the credit card configuration have completely different fields.

6. Interface vs. Union: Which One When?

The choice between interface and union is one of the most common design questions when building a GraphQL schema. The guiding question: are there common fields that all types share? Yes, use an interface. No, use a union. If different entities have common base fields that the client can use without inline fragmentation, an interface enables smoother queries. If the types are fundamentally different and no meaningful shared field exists, a union is clearer.

An edge case: sometimes types have a single shared field (say id: ID!) but are otherwise entirely different. In that case, check whether the shared field is actually semantically identical: the ID of a product and the ID of a CMS page are technically both IDs, but conceptually from different namespaces. Here a union is often the more honest modeling choice, an interface for just one technical field would feel artificial. If more shared fields could plausibly be added in the future, an interface is the more future-proof choice.

7. Schema Design in Magento: graphqls Files and Extensibility

Magento implements GraphQL schema design through .graphqls files that live in each module's etc/schema.graphqls directory. The notable part: Magento merges these schemas at runtime and allows existing types to be extended (type Query gets extended in every module). This is powerful extensibility, but it also has pitfalls: if one module extends an existing type with a field that shares its name with a field from another module, a conflict arises that is hard to debug.

The best schema design pattern for Magento modules: use your own type namespaces. Instead of adding a generic field extra_info to ProductInterface, add a module-specific field mironsoft_product_meta: MironsoftProductMeta. The type MironsoftProductMeta belongs entirely to the module and can evolve without conflicting with other modules. It's also worth using @resolver directives in the graphqls file that point directly to the responsible PHP resolver class, that way, the link between schema and implementation is immediately visible.


# Magento module schema: etc/schema.graphqls
# Extending ProductInterface with a module-specific namespace

# Extending an existing type without conflicts:
type Query {
  # Module-specific query, no naming conflict risk
  mironsoftProductRecommendations(
    sku: String!
    limit: Int = 5
  ): MironsoftRecommendationResult! @resolver(class: "Mironsoft\\Catalog\\Model\\Resolver\\ProductRecommendations")
}

# Module-owned type: can be evolved independently
type MironsoftRecommendationResult {
  items: [MironsoftRecommendedProduct!]!
  algorithm: String!
  generated_at: String!
}

type MironsoftRecommendedProduct {
  sku: String!
  score: Float!
  reason: MironsoftRecommendationReason!
  product: ProductInterface! @resolver(class: "Mironsoft\\Catalog\\Model\\Resolver\\RecommendedProductLoader")
}

enum MironsoftRecommendationReason {
  FREQUENTLY_BOUGHT_TOGETHER
  SIMILAR_ATTRIBUTES
  VIEWED_TOGETHER
  PRICE_RANGE_MATCH
}

# Extending ProductInterface, using module-namespaced field
type ProductInterface {
  mironsoft_sustainability_score: Float @resolver(class: "Mironsoft\\Catalog\\Model\\Resolver\\SustainabilityScore")
  mironsoft_lead_time_days: Int @resolver(class: "Mironsoft\\Catalog\\Model\\Resolver\\LeadTime")
}

8. Schema Design Decisions Compared

The most important schema design decisions have a direct impact on the maintainability, extensibility, and performance of a GraphQL schema. This comparison shows common mistakes and their better alternative.

Design Decision Problematic Pattern Recommended Pattern Reasoning
Type safety date: String date: DateTime Custom scalar signals format, enables code-gen validation
Nullability Everything non-nullable (!) Required fields non-nullable, optional ones nullable Too many ! marks make the schema rigid and break on partial failures
Polymorphism Everything in one type with many nullable fields Interface or union depending on shared fields Clear type structure improves tooling and documentation
Magento extension Generic field names without a namespace Module-specific prefix (mironsoft_*) Prevents naming conflicts with other modules
Development strategy Code-first: schema generated from code Schema-first: SDL as contract artifact Early alignment between teams, mock servers possible right away

The most common anti-pattern in Magento GraphQL schema design is extending ProductInterface indiscriminately with fields that have no namespace. After several years and several modules, this creates a bloated interface with fields of various origins and no clear ownership. The namespacing pattern, module-specific fields with a module prefix, solves this problem permanently without sacrificing schema flexibility.

9. Summary

Good GraphQL schema design makes deliberate decisions on nullability, types, interfaces, and unions. Nullability determines how tolerant the schema is toward partial failures, too many non-nullable fields make the schema fragile. Custom scalars give primitive values semantic meaning and improve tooling and documentation. Interfaces model shared fields of related types and enable type-safe queries without full inline fragmentation. Unions model heterogeneous result sets without forcing shared fields.

In Magento, schema design is implemented through .graphqls files that are merged at deployment. Module-specific namespaces prevent conflicts between modules and make the schema origin of every field traceable. Schema-first as a development strategy ensures the schema stands as a contract artifact between frontend and backend teams before implementation begins.

GraphQL Schema Design, the Essentials at a Glance

Choose nullability deliberately

Required fields non-nullable (id!, sku!), optional contextual data nullable. Too many ! marks make the schema fragile during partial database outages.

Custom scalars instead of String

DateTime, URL, PhoneNumber instead of generic strings, improves documentation, code generation, and client-side validation.

Interface vs. union

Shared fields, use an interface. Fundamentally different types with no shared fields, use a union. Both solve polymorphism problems, but for different cases.

Magento namespace pattern

Module fields with a prefix (mironsoft_*), prevents naming conflicts between modules and makes field origin immediately recognizable in the schema.

11. FAQ: GraphQL Schema Design, Fields, Types, Interfaces and Unions

1Interface vs. union: what's the most important difference?
Interfaces have shared fields that all implementing types have. Unions have no shared fields. Rule of thumb: shared fields = interface, fundamentally different types = union.
2When nullable, when non-nullable?
Required fields without which the object can't be rendered (id!, sku!) = non-nullable. Optional contextual data, secondary data sources = nullable. Too many ! marks make the schema fragile.
3When custom scalars instead of String?
When a value has semantic meaning beyond the primitive type: DateTime, URL, PhoneNumber, Email. Improves documentation, code generation, and client-side validation.
4Extend a Magento module without naming conflicts?
Use a module-specific prefix: mironsoft_field_name. That way fields are clearly attributed to a module and conflicts between modules become impossible.
5What is schema-first development?
Defining SDL as a contract artifact before implementation begins. Enables mock servers right away, early team alignment, and clear ownership of fields.
6How does Magento manage multiple schema files?
Every module defines etc/schema.graphqls. Magento merges these at runtime. Naming conflicts occur when the same field name comes from different modules.
7Interface for just one shared field?
Almost never useful. An interface for just one technical field like id usually feels artificial. A union is the more honest modeling choice, unless future shared fields are foreseeable.
8Nullability and error handling?
A non-nullable field returning null propagates the error upward to the nearest nullable parent field. Deep non-nullable hierarchies can null out large parts of the response.
9Convert a union into an interface later?
A breaking change. @deprecated on the old union, offer the new interface in parallel. Only remove after the deprecation period ends, once all clients have migrated.
10Which fields go into the interface, which into the type?
Interface: only fields that make semantic sense for ALL implementations. Concrete type: type-specific fields. A field that only applies to one of five types does not belong in the interface.