Instead of Business Logic Monsters
A GraphQL resolver that combines database queries, validation, permission checks and transformation logic in a single class is no longer a resolver, it is an unbounded mini-service. This article shows how to keep resolvers thin, introduce clear layers, and move business logic to where it actually belongs.
Table of Contents
- 1. What a resolver should actually do
- 2. How resolvers turn into monsters
- 3. The delegation principle in practice
- 4. Using the service layer and repositories correctly
- 5. Resolver architecture in Magento, concretely
- 6. Wrong versus right, side by side
- 7. Typical failure patterns and their causes
- 8. Testability as an architecture indicator
- 9. Resolver types compared
- 10. Summary
- 11. FAQ
1. What a resolver should actually do
A GraphQL resolver has exactly one responsibility: it receives the context of the request, delegates the actual work to the correct layer, and returns the result in a format the schema expects. That sounds trivial, but in practice it is implemented incorrectly surprisingly often. Resolvers are the entry point into the data layer, they are the bridge between the GraphQL schema and the application logic. That bridge should be thin, not loaded down with logic.
In a well-structured system, you can read a resolver and understand within ten lines what it does: it checks whether the caller is authorized, extracts the relevant arguments, delegates to a service or repository, and returns the result. A resolver needs nothing more than that. What it should never contain: database queries, complex validation rules, calculations, or transformations that would also make sense to test in unit tests outside the GraphQL context.
2. How resolvers turn into monsters
The emergence of a business logic monster inside a resolver is rarely a deliberate decision. It usually starts with one small exception: a validation that is "only needed here," a transformation that gets added "quickly for now," a database query that "actually belongs in the repository, but stays here for the time being." Each of these exceptions is harmless on its own, but together they create a resolver with dozens of dependencies, hundreds of lines of code, and no way to test it without full knowledge of all internal state.
Another driver is missing layer separation in the architecture. If the project has no service layer, if repository classes are injected directly into resolvers and execute complex query logic there, if permission checks are duplicated manually in every resolver, then there is structurally no way to keep the resolver thin. The solution is not inside the resolver itself but in the architecture around it. A thin resolver is an indicator that the rest of the system is well structured.
3. The delegation principle in practice
The delegation principle for resolvers can be summarized in one sentence: the resolver decides who does the work, it does not do the work itself. In practice that means: extract arguments from $args, pass them to a service or repository, return the result. Optional addition: a context check at the beginning. That is it. Anything beyond these three steps belongs in a different class.
This approach has measurable advantages: the resolver can be tested without any GraphQL infrastructure, because it contains no logic of its own. The service underneath it can be developed further independently of the GraphQL layer. Changes to business logic never touch the resolver class. And when the schema is extended, no resolvers need to be refactored, only the mapping layer between schema fields and service return values. Delegation creates maintainability.
# Clean resolver schema: the resolver only maps, not decides
type Query {
customerOrders(pageSize: Int = 10, currentPage: Int = 1): OrderList!
}
type OrderList {
total_count: Int!
items: [Order!]!
}
type Order {
order_number: String!
status: String!
grand_total: Float!
created_at: String!
}
4. Using the service layer and repositories correctly
The cleanest architecture for GraphQL resolvers in PHP follows a simple pattern: the resolver knows a service, the service knows a repository, the repository knows the database. Each layer has exactly one responsibility. The service contains the business logic, meaning validation, calculations, access rules. The repository translates data requests into concrete database operations. The resolver connects the GraphQL world to the service.
In Magento, that means: resolvers implement ResolverInterface, inject a service interface from the module's Api/ directory, and call exactly one method on it. Argument validation lives in the service class. The authentication check lives in a middleware or a plugin on the resolver. Mapping internal model objects onto the GraphQL output format lives in a separate data provider class. That way each class has at most five to ten dependencies, and none of them becomes a god class.
# Resolver delegates to service, no business logic here
# PHP equivalent: $this->orderService->getCustomerOrders($customerId, $args)
query CustomerOrders {
customerOrders(pageSize: 5, currentPage: 1) {
total_count
items {
order_number
status
grand_total
}
}
}
5. Resolver architecture in Magento, concretely
In Magento 2, the interface for GraphQL resolvers is Magento\Framework\GraphQl\Query\ResolverInterface. Every resolver implements a single method: resolve(). This method receives the field, the context, ResolveInfo and the arguments. The most common mistake in Magento resolvers: the entire lookup code, the EAV attribute transformation, and the permission check all end up inside this one method. Six months later, no team member understands anymore why which line does what.
The alternative: resolve() has at most 15 lines. Argument extraction, an authentication check via the context ($context->getUserId()), a call to an injected service interface, and returning the mapped result. The service handles everything else. For the output transformation, there is a separate DataProvider class that maps an internal model onto an associative array for the GraphQL response. This separation makes Magento resolvers testable, maintainable and extensible, without having to open a class that has grown out of control with every new feature.
# Anti-pattern: resolver does too much (conceptual illustration)
# The resolver fetches, transforms, validates and decides, all in one place
# Good pattern: resolver only coordinates
# 1. Extract args
# 2. Check context (authenticated?)
# 3. Delegate to service interface
# 4. Return mapped result
type Mutation {
submitContactForm(input: ContactFormInput!): ContactFormResult!
}
input ContactFormInput {
name: String!
email: String!
message: String!
}
type ContactFormResult {
success: Boolean!
reference_id: String
}
6. Wrong versus right, side by side
The most direct way to illustrate the problem is a concrete comparison. The bad resolver opens a database connection, checks multiple conditions with nested if blocks, transforms result sets with complex array_map constructs, and throws GraphQL exceptions with internal error messages directly on failure. The good resolver has a try-catch structure around a single service call, maps the result with a dedicated class, and logs unexpected errors before returning a generic error message to the outside.
| Aspect | Wrong resolver | Right resolver | Consequence |
|---|---|---|---|
| Data access | Direct repository calls in the resolver | Delegation to a service interface | Testable without DB setup |
| Validation | if blocks in the resolver | Exception from the service layer | Reusable validation |
| Output mapping | array_map in the resolver | DataProvider class | Simple schema extension |
| Permissions | Manual context checks | Middleware / plugin | No duplicated auth logic |
| Error messages | Internal details exposed externally | Generic, plus internal logging | No information leaks |
The table shows: the goal is not to turn the resolver into an empty pass-through class. The goal is to move every kind of logic to the right place. A resolver is allowed to extract arguments, check the context, and return the result. Everything else is a responsibility that is better placed in a different class, one that is also independently testable there.
7. Typical failure patterns and their causes
The most common failure pattern in bloated resolvers is the god class: a resolver with 300 lines, ten dependencies injected through the constructor, running through several if-else branches in the resolve() method before returning a result. That is no longer a resolver, it is a controller without a framework. The cause almost always lies in missing interfaces: if there is no service interface for the resolver to delegate to, the logic inevitably ends up inside the resolver.
A second failure pattern: resolvers that work directly with ObjectManager because proper dependency injection seems too much effort. In Magento this is particularly critical because the ObjectManager makes testing harder and hides dependencies. Whoever finds ObjectManager::getInstance() in a resolver has a strong signal that the class has grown historically and urgently needs refactoring. The third failure pattern: missing error handling strategy, so that database exceptions with stack traces are passed straight through to the client as GraphQL errors.
8. Testability as an architecture indicator
Testability is the most reliable indicator of whether a resolver is well architected. A thin resolver can be tested with a single mock: you mock the service interface, define the expected return object, call resolve() with the corresponding arguments, and check whether the mapped result is correct. No database setup, no Magento bootstrap, no complex fixture management.
If a resolver test needs ten mocks, or you cannot write a test without a database connection, that is a clear sign the resolver does too much. In Magento projects this is not a theoretical concern: a bloated resolver that loads EAV data directly, performs price calculations, and includes visibility checks can only be tested with a fully installed Magento instance. A thin resolver that delegates all of that to a ProductDataServiceInterface can be verified in milliseconds inside a unit test.
# Testing a lean resolver: only one mock needed
# The service interface is mocked, no DB, no Magento bootstrap required
# Resolver under test:
# resolve() -> $this->productService->getProductBySku($args['sku'])
# -> $this->dataProvider->map($product)
# -> return $mapped
query ProductBySku {
productBySku(sku: "MH01-XS-Black") {
sku
name
price
stock_status
}
}
9. Resolver types compared
Not all resolver types in GraphQL have the same requirements. A query resolver that reads data has different requirements than a mutation resolver that writes data, or a field resolver that resolves a single value of a parent type. A good understanding of these differences helps you choose the right architecture for each type. Mutation resolvers are especially prone to bloat because they often have to trigger complex side effects.
Resolver Architecture, the Essentials at a Glance
Core principle
Resolvers delegate, they decide who does the work but never do it themselves. resolve() has at most 15 lines.
Service layer
Business logic belongs in the service interface. Resolvers inject services, never repositories or models directly.
Warning sign
More than 3 injected dependencies, ObjectManager calls, or if-else cascades in resolve(), refactor immediately.
Testability
A thin resolver needs exactly one mock. If you need more, too much logic sits in the wrong layer.
10. Summary
The most important insight about thin GraphQL resolvers is that a bloated resolver is always a symptom of a structural problem, be it a missing service layer, missing interfaces, or missing conventions within the team. The resolver itself is not the problem, it is the most visible consequence of it. Anyone refactoring resolvers must simultaneously build up the layer underneath: service interfaces, repositories with clearly defined responsibility, and data providers for output transformation.
In Magento this is especially relevant, because the framework delivers all the necessary building blocks with service contracts, repository patterns, and the DI container. You only have to use them consistently. A resolver that implements ResolverInterface, injects a service interface, and makes exactly one call inside resolve() is maintainable, testable, and extensible, regardless of how complex the schema above it or the database layer beneath it may be.