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.
Table of Contents
- 1. Schema-First as a Development Strategy
- 2. Nullability: the Often Underestimated Design Decision
- 3. Custom Scalars Instead of Generic String Types
- 4. Interfaces: Typing Shared Fields
- 5. Unions: Heterogeneous Result Sets
- 6. Interface vs. Union: Which One When?
- 7. Schema Design in Magento: graphqls Files and Extensibility
- 8. Schema Design Decisions Compared
- 9. Summary
- 10. The Essentials at a Glance
- 11. FAQ
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.