Modeled Cleanly
Validation in GraphQL is not a single mechanism but an interplay of schema types, custom scalars, resolver logic and explicit error types. Anyone who relies on just one layer ends up with incomplete validation. This article shows how all the layers work together and which mistakes typically occur.
Table of Contents
- 1. The Three Validation Layers in GraphQL
- 2. Input Types: The Foundation of Clean Validation
- 3. Non-Null and Required: Mandatory Fields in the Schema
- 4. Custom Scalars: Domain-Specific Types
- 5. Error Types: UserErrors vs. Top-Level Errors
- 6. Resolver-Side Validation: What the Schema Cannot Do
- 7. Comparison: Poor vs. Good Validation Models
- 8. Input Validation in the Magento Context
- 9. Testing Validation Logic
- 10. Summary
- 11. FAQ
1. The Three Validation Layers in GraphQL
GraphQL validation happens on three independent layers. The first layer is schema validation: before every execution, the GraphQL server checks whether the query is syntactically correct and whether all requested fields and types exist in the schema. This layer automatically rejects requests with the wrong type or a missing mandatory field, before a resolver is even called. The second layer is custom scalars: specialized scalar types can bring their own serialization and validation logic. An Email scalar only accepts strings in email format, a PositiveInt scalar only accepts positive integers.
The third layer is resolver-side validation: business rules that the schema alone cannot express are validated in the service layer and returned as explicit error types in the response. This includes checking whether an email address is already taken, whether a quantity exceeds available stock, or whether a discount code is valid and not expired. These three layers complement each other: only when all three are deliberately planned does a complete validation strategy emerge.
2. Input Types: The Foundation of Clean Validation
In GraphQL, input data for mutations is passed via input types. These are separate from normal object types and must not contain fields that reference other output types. This enforces a clean separation between input data and output data. An input type explicitly defines which fields a mutation expects, what types they have, and whether they are mandatory. The schema itself thereby takes on the first validation layer.
A common mistake: input types that are too broad and get shared across multiple mutations. An input type like AddressInput that is used for both shipping and billing addresses during checkout as well as for address management in the customer profile grows with divergent requirements and becomes hard to maintain. It is better to define a dedicated, narrow input type for each context: PlaceOrderAddressInput with exactly the fields needed at checkout. Refactoring an input type that is only used internally by one mutation is not a breaking change. Refactoring a shared input type, however, very much is.
# Well-modeled input types: specific per use-case, not generic
# Each input type has exactly the fields needed for its context
input RegisterCustomerInput {
firstname: String!
lastname: String!
email: String! # validated as Email scalar server-side
password: String! # validated for strength in resolver
dateOfBirth: String # optional, ISO date string
acceptsMarketing: Boolean! = false
}
input UpdateCustomerProfileInput {
firstname: String
lastname: String
dateOfBirth: String
# email change is a separate mutation with re-verification
# password change is a separate mutation with current password check
}
input ChangeCustomerEmailInput {
newEmail: String!
currentPassword: String! # re-authentication required
}
input ChangeCustomerPasswordInput {
currentPassword: String!
newPassword: String!
}
# Wrong pattern to avoid, one generic input for everything
# input CustomerInput {
# firstname: String
# lastname: String
# email: String
# password: String
# currentPassword: String # context-dependent, confusing
# }
3. Non-Null and Required: Mandatory Fields in the Schema
The exclamation mark ! in GraphQL marks a field or type as Non-Null: no null may be returned or passed. For input fields, ! means the field must be present in the client request. If a Non-Null field is missing, schema validation fails before a resolver is even executed. This is the most effective form of mandatory-field validation: it is automatic, requires no resolver code, and delivers a clear error message.
A common schema design mistake: leaving everything nullable because GraphQL is nullable by default. This shifts validation entirely into the resolver and makes the schema less informative. The client does not know which fields are actually required. A good approach: mark fields that are always needed for an operation as Non-Null. Optional fields that can be omitted stay nullable. The schema thereby explicitly communicates which data the server guarantees to expect.
4. Custom Scalars: Domain-Specific Types
GraphQL provides five built-in scalars: String, Int, Float, Boolean and ID. These are often not enough for domain-specific constraints. Custom Scalars allow you to define your own types with their own serialization and validation logic. An Email scalar checks the email format, a URL scalar checks URL syntax, a PositiveInt scalar rejects negative numbers and null. This validation happens before the resolver runs and automatically delivers type-safe error messages.
Libraries such as graphql-scalars (Node.js) or webonyx/graphql-php provide ready-made custom scalar implementations. In Magento, custom scalars can be registered in your own GraphQL module. Important: custom scalars improve schema typing but do not replace business-level resolver validation. An Email scalar checks the format, not whether the address actually exists. A PositiveInt scalar checks the value, not whether the quantity fits available stock. Both layers are necessary.
# Custom scalars for domain-specific validation, validated before resolver execution
scalar Email # valid email format: user@domain.tld
scalar URL # valid URL with scheme
scalar PositiveInt # integer > 0
scalar ISODate # ISO 8601 date string: 2026-05-09
# Using custom scalars in input types
input CreateProductReviewInput {
sku: String!
nickname: String! # 2-50 characters (validated in resolver)
summary: String! # 5-255 characters (validated in resolver)
text: String! # 20-5000 characters (validated in resolver)
ratings: [RatingInput!]!
}
input RatingInput {
id: String!
value_id: String!
}
# Result type with UserErrors for domain validation failures
type CreateProductReviewResult {
review: ProductReview
userErrors: [ValidationError!]!
}
type ValidationError {
field: String!
code: ValidationErrorCode!
message: String!
}
enum ValidationErrorCode {
REQUIRED
TOO_SHORT
TOO_LONG
INVALID_FORMAT
ALREADY_REVIEWED
PRODUCT_NOT_FOUND
}
5. Error Types: UserErrors vs. Top-Level Errors
GraphQL errors can be returned in two ways. Top-level errors appear in the errors array alongside data and are intended for transport and system errors: network errors, authentication errors, invalid queries. They have no stable code that the client can handle programmatically. UserErrors are business validation errors modeled as an explicit type in a mutation's return value. They appear in the data part of the response and have a structured code and a message.
The most important design principle: never return business errors as top-level errors. A password that fails to meet the requirements is not a system error, it is a user error. It belongs in a userErrors array in the result type. Top-level errors are automatically converted into an error state by the Apollo Client and often end up only in the error log. UserErrors in the result type are read out by the component and shown to the user. That makes the difference between a mutation that responds sensibly to validation errors and one that fails without explanation.
6. Resolver-Side Validation: What the Schema Cannot Do
Schema validation and custom scalars check type and format. Business rules (whether a username is already taken, whether a quantity exceeds available stock, whether a coupon code applies to the current cart) must be validated in the service layer. The resolver delegates these checks to a validation service that knows all the relevant business rules and returns structured errors. The resolver maps these to the UserErrors format and returns them in the result type.
An anti-pattern: implementing validation logic directly in the resolver. That makes resolvers hard to test, hard to reuse, and leads to similar validations being redundantly implemented across different mutations. A validation service that is testable in isolation, has a clear interface, and can be reused across different mutations is the more maintainable alternative. In PHP-based systems like Magento, validator classes that are wired into services and resolvers via dependency injection are well suited for this.
7. Comparison: Poor vs. Good Validation Models
The quality of a validation model shows most clearly in how errors are returned and how much the client can actually do with them. A poor validation model throws top-level exceptions, returns only a boolean, or forces the client to infer the error from the HTTP status code. A good model returns structured, coded UserErrors that the client can map directly to a form field.
| Aspect | Problematic | Recommended | Why It's Better |
|---|---|---|---|
| Mandatory fields | All nullable, checked in resolver | Non-Null ! in the schema |
Automatic, no resolver code needed |
| Email format | email: String! + regex in resolver |
email: Email! (custom scalar) |
Validation before the resolver is called |
| Business errors | Exception → top-level error | UserErrors in the result type | Client can map errors to fields |
| Return type | mutation: Boolean! |
mutation: OperationResult! |
Returns data and errors together |
| Input types | Generic shared input | Context-specific input | No breaking-change risk |
A complete validation model combines all five recommendations: Non-Null for mandatory fields, Custom Scalars for format checks, context-specific input types for mutations, structured UserErrors in the result type, and resolvers that delegate to validation services testable in isolation. No single one of these points is sufficient on its own: together, they form a robust validation strategy.
8. Input Validation in the Magento Context
Magento implements validation in its GraphQL layer across multiple levels. Input types are defined in the schema for all mutations, for example SetShippingAddressesOnCartInput or AddProductsToCartInput. Non-Null fields mark mandatory fields. The actual business validation (whether an address is complete enough for delivery, whether a product is available in the requested quantity) happens in the corresponding service classes and is returned as an error in the UserErrors format.
Anyone building their own mutations in Magento modules should follow this pattern. Define input types in schema.graphqls, resolver classes that only delegate, and separate validator classes that implement the business rules. Magento provides the Validator pattern from the service contract area for exactly this purpose. Errors are thrown as GraphQlInputException or GraphQlAuthorizationException, depending on the error type, and automatically translated into the correct format by the GraphQL layer.
# Complete mutation with multi-level validation
mutation PlaceOrder($input: PlaceOrderInput!) {
placeOrder(input: $input) {
order {
order_number
status
}
userErrors {
code
message
field
}
}
}
# Example response with UserErrors (HTTP 200, not an exception)
# {
# "data": {
# "placeOrder": {
# "order": null,
# "userErrors": [
# {
# "code": "OUT_OF_STOCK",
# "message": "Product 'DEMO-001' is no longer available in the requested quantity.",
# "field": "cartItems[0].quantity"
# },
# {
# "code": "INVALID_ADDRESS",
# "message": "Postal code invalid for the selected country.",
# "field": "shippingAddress.postcode"
# }
# ]
# }
# }
# }
9. Testing Validation Logic
Validation logic is easy to test when it lives in isolated service classes. For the validation service, you can write unit tests that cover different input combinations: valid data, missing mandatory fields, wrong formats, violated business rules. These tests run without an HTTP layer and without GraphQL execution: they are fast and diagnose problems precisely. Integration tests at the resolver level can then ensure that validation errors are correctly translated into the UserErrors format and returned in the GraphQL response.
For custom scalars, a dedicated test suite that checks the boundaries of the scalar is recommended: valid values, edge values, and invalid values. A PositiveInt scalar should reject 0, negative numbers, and non-integer values. Schema-level tests with tools like graphql-tester or plain HTTP requests can verify that the schema correctly reports mandatory-field violations. A complete test strategy for validation covers all three layers (schema, scalar and service layer) and ensures that no validation gaps remain.
10. Summary
GraphQL Input Validation is not a single feature but a multi-layered strategy. Non-Null fields in the schema automatically validate mandatory fields. Custom Scalars validate formats before resolver execution. Isolated validation services implement business rules and are invoked by resolvers. Structured UserErrors in the result type return business errors that the client can meaningfully process. Top-level errors remain reserved for system and transport errors.
In the Magento context, that means: carefully design input types in schema.graphqls, create validator classes for business rules, throw the correct exception for each error type, and adopt the UserErrors pattern for all custom mutations. Anyone who cleanly separates these layers builds GraphQL APIs that validate more robustly, are easier to test, and give the client enough information to handle errors meaningfully.
GraphQL Input Validation: The Key Points at a Glance
Schema Layer
Non-Null for mandatory fields. Context-specific input types instead of generic ones. Non-Null fails before a resolver is called.
Custom Scalars
Email, URL, PositiveInt for domain-specific format checks. Validation before the resolver is called, with no resolver code.
UserErrors
Business errors in the result type as a structured array with code, message and field path. Never as top-level errors.
Service Layer
Validation services for business rules: testable in isolation, reusable, independent of the GraphQL layer.