Input, Service Contract, Response
A GraphQL mutation is more than a resolver with side effects. Anyone who doesn't model input types in the schema, writes business logic directly into the resolver, or fails to return errors as a structured response ends up with an API that is hard to test, document, and extend.
Table of Contents
- 1. What Distinguishes a GraphQL Mutation in Magento from a Query
- 2. The Mutation Schema: Defining Input Type and Response Type Correctly
- 3. The Mutation Resolver: Delegate Only, Never Persist Directly
- 4. The Service Contract as the Mutation Backend
- 5. Complete Example: Creating a Customer Wishlist Entry via Mutation
- 6. Wrong vs. Right: Logic in the Resolver vs. the Service Contract
- 7. Typical Error Patterns in Magento Mutations
- 8. Input Validation: Where It Belongs
- 9. Testing Mutations: PHPUnit and GraphQL Integration Tests
- 10. Summary
- 11. FAQ
1. What Distinguishes a GraphQL Mutation in Magento from a Query
Technically, mutations and queries in GraphQL are almost identical: both define fields in the schema, both are processed by resolvers, and both return a typed response. The fundamental difference lies in the semantics and the execution order: mutations are executed sequentially, so if multiple mutations arrive in one request, they are processed one after another. Queries, by contrast, can be resolved in parallel. A mutation also signals to the client and to every caching layer that the system state has changed.
In Magento, this difference has concrete consequences. A mutation that changes a cart must make sure the session ID is processed correctly and that the result does not come from a stale cache. Magento's GraphQL infrastructure automatically treats mutations as non-cacheable, but that only protects at the HTTP level. Resolvers that internally rely on cached objects can still return outdated data. Understanding these boundaries is a prerequisite for correct mutation implementations.
For custom Magento modules, that means a new mutation needs a dedicated input type in the schema, a lean resolver that does nothing but delegate, and a service contract that encapsulates the actual business logic. These three layers are not negotiable: merging them produces a mutation that is neither testable nor extensible.
2. The Mutation Schema: Defining Input Type and Response Type Correctly
The schema is the contract between frontend and backend. For mutations, that means the input type defines exactly which data the client must submit and which fields are optional. Mandatory fields are marked with an exclamation mark (String!), optional fields are left without one. The response type defines what the client gets back after a successful mutation: it should always be a specific output type, never a generic boolean. Structured responses make it possible to encode both success fields and error information in a single type.
A common mistake: the mutation schema returns only a boolean. That may be enough for simple confirmations, but it prevents the frontend from receiving additional context, such as a generated ID, a status, or a message. A well-modeled response type can be extended without breaking the schema for existing clients. Fields can be added without triggering a breaking change.
# Mutation schema definition
# File: Vendor/WishlistModule/etc/schema.graphqls
type Mutation {
createWishlistEntry(
input: CreateWishlistEntryInput!
): CreateWishlistEntryOutput
@resolver(class: "Vendor\\WishlistModule\\Model\\Resolver\\CreateWishlistEntry")
}
input CreateWishlistEntryInput {
customer_id: Int!
product_sku: String!
note: String
quantity: Float = 1.0
}
type CreateWishlistEntryOutput {
entry_id: Int
customer_id: Int
product_sku: String
quantity: Float
created_at: String
user_errors: [WishlistUserError]
}
type WishlistUserError {
code: WishlistErrorCode!
message: String!
path: [String]
}
enum WishlistErrorCode {
PRODUCT_NOT_FOUND
CUSTOMER_NOT_FOUND
DUPLICATE_ENTRY
PERMISSION_DENIED
}
3. The Mutation Resolver: Delegate Only, Never Persist Directly
The mutation resolver has exactly one job: it receives the validated GraphQL arguments, passes them to the service contract, and returns the result in the structure the schema expects. Persistence, validation, authorization and error handling do not belong in the resolver. This separation is not merely stylistic: it is the prerequisite for the service contract to also be usable in other contexts, such as a REST API, a CLI command, or other GraphQL endpoints.
Magento's GraphQL infrastructure provides resolvers with the ResolverInterface, which defines a single method: resolve(). Within this method, the authorization check should come first; Magento provides the $context parameter for this, through which the current customer token can be evaluated. Next comes passing the input data to the service and transforming the result into the schema format. Exception handling in the resolver should specifically catch Magento-specific exceptions and translate them into user_errors fields in the response type, instead of being returned as generic GraphQL errors.
4. The Service Contract as the Mutation Backend
The service contract is the heart of every Magento mutation. It defines the interface (WishlistManagementInterface), the concrete implementation (WishlistManagement), and the DI configuration (di.xml) that connects the two. The implementation contains the complete business logic: existence checks, duplicate detection, persistence via the repository, and dispatching events for plugins and observers.
Anyone who implements the business logic directly in the resolver loses this flexibility. A plugin on the service contract can intercept and extend mutations, for example to add logging, notifications, or A/B tests. A plugin on the resolver would technically be possible, but it would also change the entire GraphQL infrastructure of the resolver. Separating resolver and service contract keeps extension points clean and independent of the API layer.
# Client-side mutation call with variables
mutation CreateEntry($input: CreateWishlistEntryInput!) {
createWishlistEntry(input: $input) {
entry_id
customer_id
product_sku
quantity
created_at
user_errors {
code
message
path
}
}
}
# Variables:
# {
# "input": {
# "customer_id": 42,
# "product_sku": "MH12-XS-Black",
# "note": "Birthday gift",
# "quantity": 2.0
# }
# }
5. Complete Example: Creating a Customer Wishlist Entry via Mutation
The example combines all three layers: schema, resolver and service contract. The resolver first checks whether an authenticated customer is present in the context; if not, a structured user_errors entry is returned instead of a generic GraphQL error. It then passes the input data to the WishlistManagementInterface service and maps the result onto the CreateWishlistEntryOutput type.
An important design decision: errors are returned in the user_errors array, not as HTTP-level errors or GraphQL-level errors. This pattern, also known as "errors as data", is widely used in Magento's own GraphQL schema (checkout, cart) and lets the frontend react specifically to different error states without treating the entire response as a failure. That significantly simplifies error handling on the frontend.
# Possible response structures for the wishlist mutation
# Success case
{
"data": {
"createWishlistEntry": {
"entry_id": 157,
"customer_id": 42,
"product_sku": "MH12-XS-Black",
"quantity": 2.0,
"created_at": "2026-05-09T10:30:00+00:00",
"user_errors": []
}
}
}
# Error case: product not found
{
"data": {
"createWishlistEntry": {
"entry_id": null,
"customer_id": null,
"product_sku": null,
"quantity": null,
"created_at": null,
"user_errors": [
{
"code": "PRODUCT_NOT_FOUND",
"message": "Product with SKU MH12-XS-Black not found",
"path": ["input", "product_sku"]
}
]
}
}
}
6. Wrong vs. Right: Logic in the Resolver vs. the Service Contract
Drawing the line between resolver and service contract comes easily to experienced developers. For everyone else, it is one of the most common sources of poorly maintainable Magento GraphQL code. A resolver that calls a repository directly, checks for duplicates via SQL query, and fires events itself is no longer a resolver, it is a service disguised as a resolver.
| Aspect | Wrong: Logic in the Resolver | Right: Service Contract | Benefit |
|---|---|---|---|
| Reuse | Usable only via GraphQL | REST, CLI, other resolvers | Business logic is channel-agnostic |
| Testability | Requires GraphQL context | Pure PHP unit test | Faster, isolated tests |
| Extensibility | No plugin point | Plugin on the interface | Later extensions without changing the resolver |
| Error Handling | GraphQL error format mandatory | Exception types freely chosen | Resolver translates into user_errors |
| Events | Events in the resolver are unusual | Events in the service are standard | Observer pattern stays consistent |
7. Typical Error Patterns in Magento Mutations
The most common error pattern: the resolver returns a generic GraphQlInputException without categorizing the cause of the error. The frontend gets an error message but no code, no path, and no way to evaluate the error programmatically. The correct approach is to wrap errors as user_errors entries in the response type, with an enum code that can be evaluated programmatically.
A second error pattern: the input type in the schema has mandatory fields, but validation in the service contract still checks them manually all over again. That is redundant when the schema is configured correctly, since Magento's GraphQL infrastructure already enforces mandatory fields at the schema level and returns a validation error before the resolver is even called. Additional null checks in the resolver or service are still worthwhile for fields whose validation depends on database state, such as whether a product SKU actually exists.
8. Input Validation: Where It Belongs
The answer depends on the kind of validation. Structural validation, such as whether a mandatory field is present, whether an enum value is valid, or whether a number is positive, belongs in the schema. GraphQL handles this check automatically and returns a validation error before the resolver is called. Semantic validation, such as whether the product with this SKU exists, whether the customer has the required permission, or whether the quantity falls within the allowed range, belongs in the service contract.
What does not belong in the resolver: database queries for validation. A resolver that first checks whether a product exists before calling the service does the work twice. The service has to check it anyway, because it is the only place that has access to all the necessary information. Duplicate database queries across the resolver-service tandem are one of the most common performance problems in poorly structured Magento modules.
9. Testing Mutations: PHPUnit and GraphQL Integration Tests
Mutations can be tested on two levels. At the unit test level, the service contract is called directly, with mocked repositories and a controlled input data set. This test checks whether the business logic is implemented correctly: are duplicates detected? Are events fired? Are exceptions raised correctly? At the integration test level, the real GraphQL endpoint is called with a complete request. Magento provides GraphQlMutationTest for this, a base class for HTTP-based mutation tests.
An often overlooked test case: what happens when the same mutation is called twice with the same input data? Is idempotency desired, or should a duplicate error be produced? This case must be tested explicitly, because Magento's GraphQL layer has no built-in idempotency mechanism. The outcome of a repeated call depends entirely on the implementation in the service contract.
# Schema introspection: verify mutation is correctly registered
query VerifyMutationSchema {
__schema {
mutationType {
fields {
name
args {
name
type {
name
kind
ofType {
name
kind
}
}
}
type {
name
kind
}
}
}
}
}
# Filter results for "createWishlistEntry" to verify:
# - arg "input" of type "CreateWishlistEntryInput!" (NON_NULL INPUT_OBJECT)
# - return type "CreateWishlistEntryOutput" (OBJECT)
10. Summary
Building a mutation in Magento correctly means cleanly modeling input types and response types in the schema, limiting the resolver to pure delegation, and encapsulating the business logic entirely in the service contract. These three layers form the foundation for testable, extensible and maintainable mutation implementations. Errors are returned as user_errors in structured response types, not as generic GraphQL errors.
The biggest lever is consistently separating the resolver from the service contract. Anyone who encapsulates business logic in the service contract automatically gains a plugin point for extensions, a REST-API-compatible service, and a unit-testable code path without a GraphQL context. That is not extra effort, it is the foundation for keeping Magento modules maintainable in the long run.
Building Mutations in Magento: The Key Points at a Glance
Schema Design
Define an input type with mandatory fields and a response type with user_errors. Never a boolean as the return value.
Resolver Principle
Delegate only. No SQL, no events, no duplicate checks in the resolver. Everything belongs in the service contract.
Errors as Data
user_errors in the response type instead of GraphQL-level errors. Enum code for machine-readable error evaluation on the frontend.
Testability
Test the service contract directly with PHPUnit. GraphQL integration test for endpoint verification. Test duplicate behavior explicitly.