sensible structure and file layout
Making a custom Magento module GraphQL-ready sounds simple, yet it often fails because of missing structure: misplaced schema files, resolvers without clear delegation, di.xml entries that never take effect. This article shows what the file layout should look like and why convention matters more here than creativity.
Table of Contents
- 1. Why structure matters so much for GraphQL modules
- 2. Which files a GraphQL-ready module needs
- 3. The schema: building etc/schema.graphqls correctly
- 4. Resolvers: classes, interfaces and delegation
- 5. di.xml: registering resolvers and connecting types
- 6. Don't mix models and services into the resolver
- 7. File structure compared: wrong versus right
- 8. Typical mistakes with your first GraphQL module
- 9. Checklist before the first query test
- 10. Summary
- 11. FAQ
1. Why structure matters so much for GraphQL modules
Magento loads GraphQL schemas from all active modules and merges them into a single shared schema. That means errors in your own schema.graphqls file can bring down the entire GraphQL endpoint, not just your own module. At the same time, poorly placed resolver classes are hard to test, hard to debug and tend to accumulate business logic that doesn't belong there. A clear file layout is therefore not a formality, it's a direct investment in maintainability and error avoidance.
In practice, developers often treat the GraphQL schema as an afterthought and drop resolver classes somewhere in the module without a clear namespace. That works at first, but turns into a black box as soon as you have several queries and mutations. Anyone who uses a conventional directory structure from the start, the same one Magento itself demonstrates in its core modules, has a noticeably easier path into debugging, testing and schema extension.
2. Which files a GraphQL-ready module needs
A minimal GraphQL-ready Magento module consists of three files in addition to the normal module structure: the schema file under etc/schema.graphqls, at least one resolver class under Model/Resolver/, and an entry in etc/di.xml that links the resolver to the schema type. Optionally a DataProvider or a service interface is added when the resolver needs more complex data access. This separation of schema definition, resolver logic and data access is the decisive difference between a maintainable module and one nobody wants to touch after three months.
The directory structure follows the convention of Magento's core modules: schema files in etc/, resolvers in Model/Resolver/, interfaces in Api/, and data providers in Model/ or dedicated to Model/DataProvider/. Whoever sticks to this separation from the start can test resolvers individually, replace data providers with mock implementations, and safely make schema changes without touching resolver logic.
3. The schema: building etc/schema.graphqls correctly
The file etc/schema.graphqls is the only place where new types, queries and mutations for your own module are defined. Magento merges all schema files from all active modules at startup. Strict rules apply here: fields cannot simply be overwritten, types must have unique names, and interfaces must be fully implemented. A common mistake is defining a type with the same name as a core type, which leads to merge conflicts that only become visible at the next cache flush.
Well structured schemas use descriptive type names with a vendor prefix to avoid collisions. Instead of extending type Product, you should use Magento's @doc annotation system and add new fields through the interface extension mechanism. For custom queries, a clear namespace is recommended: mironCustomerBadge instead of a generic customerData, to avoid conflicts with third-party modules.
# etc/schema.graphqls, define a custom query with proper namespacing
type Query {
mironCustomerBadge(customer_id: Int @doc(description: "Customer entity ID")): MironCustomerBadgeOutput
@resolver(class: "Mironsoft\\CustomerBadge\\Model\\Resolver\\CustomerBadge")
@doc(description: "Returns badge information for a specific customer")
@cache(cacheIdentity: "Mironsoft\\CustomerBadge\\Model\\Resolver\\Identity\\CustomerBadgeIdentity")
}
type MironCustomerBadgeOutput @doc(description: "Output type for customer badge data") {
badge_level: String @doc(description: "Current badge level, e.g. silver, gold, platinum")
points: Int @doc(description: "Total accumulated reward points")
next_level_threshold: Int @doc(description: "Points required to reach next badge level")
expires_at: String @doc(description: "Expiration date in ISO 8601 format")
}
4. Resolvers: classes, interfaces and delegation
Every resolver class implements Magento\Framework\GraphQl\Query\ResolverInterface with its single method resolve(). This method receives arguments from the query, the current context (store, customer group, authorization) and the ResolveInfo instance, which describes which fields were requested. The most common beginner mistake is doing database queries, transforming data and implementing error handling directly inside this method, all in one place. Instead, the resolver should exclusively delegate: validate input, call a service or repository, and convert the result into the expected array format.
The resolver class should use constructor property promotion to inject only the services it actually needs, not the whole object manager or generic repositories. A resolver class with more than 50 lines is usually a sign that logic is misplaced. Good resolvers are short, testable and carry no business logic of their own. The actual work, permission checking, data retrieval, transformation, belongs in a service that can be tested independently of GraphQL.
# Query to test the custom resolver, send this via Altair or curl
query GetCustomerBadge {
mironCustomerBadge(customer_id: 42) {
badge_level
points
next_level_threshold
expires_at
}
}
# Introspection query to verify the type was registered correctly
query InspectCustomType {
__type(name: "MironCustomerBadgeOutput") {
name
fields {
name
type {
name
kind
}
}
}
}
5. di.xml: registering resolvers and connecting types
In Magento 2, the resolver isn't only registered through the @resolver annotation in the schema. For more complex setups, for instance when a resolver needs to return an abstract class or an interface that must be resolved at runtime, etc/di.xml comes into play. This is also where union type resolvers are registered, which are needed for interfaces like ProductInterface: Magento has to decide at runtime which concrete type is returned.
Another important use of di.xml is defining virtualType instances for data provider classes that need slightly different configurations for different resolvers. Instead of writing a separate class for every variant, you can create multiple instances with different constructor arguments via virtualType. This technique is widely used in Magento core modules and should be applied consistently in your own GraphQL modules too.
6. Don't mix models and services into the resolver
The cleanest architecture for Magento GraphQL modules separates four layers: the schema defines the structure, the resolver receives the request and delegates, a service implements the business logic, and a repository or data provider accesses the database. This separation isn't academic, it has direct practical consequences: when business logic lives in the service instead of the resolver, it can be tested in unit tests without a GraphQL context. When data access lives in the repository, it can be cached and optimized without touching the resolver.
In practice, this separation often fails under time pressure: a resolver quickly gets wired up with a repository dependency and that's it. That works for one query. But if performance problems appear three months later, or a second entry point, say a REST API, needs the same logic, you're stuck extracting logic out of the resolver without disrupting live operation. Whoever enforces this separation consistently from the start avoids exactly that refactoring situation.
7. File structure compared: wrong versus right
Choosing a directory structure isn't purely a matter of taste, it has direct consequences for maintainability, testability and the extensibility of the module. The following table shows the most common deviations from Magento convention and what consequences they have.
| Aspect | Wrong / problematic | Right / recommended | Consequence |
|---|---|---|---|
| Schema path | Model/schema.graphqls |
etc/schema.graphqls |
Magento only loads schema from etc/ |
| Resolver location | Block/GraphQl/Resolver.php |
Model/Resolver/QueryName.php |
Convention, discoverability, testability |
| Logic in the resolver | DB queries directly in resolve() |
Delegation to service/repository | Unit testability, reuse |
| Type name | type Product (conflict) |
type MironBadgeOutput |
No schema merge conflict |
| Error handling | Exception directly in resolve() |
GraphQlInputException / GraphQlNoSuchEntityException |
Correct HTTP status codes, structured errors |
8. Typical mistakes with your first GraphQL module
The most common mistake when getting started with Magento GraphQL modules is forgetting to run bin/magento setup:upgrade after adding the schema file. Magento only registers new schema files after a full setup upgrade and cache flush. Without this step, the new schema simply isn't present, which leads to cryptic error messages that look like schema syntax errors but are actually registration problems.
Another widespread mistake concerns the resolver namespace: if the FQCN given in @resolver(class: "...") doesn't exactly match the actual class name and file location, Magento throws an exception at runtime that doesn't always clearly point to the problem. Particularly tricky: Magento loads resolvers lazily, so the error only surfaces on the first query execution, not at server startup. That makes debugging time-consuming if you don't know where to look.
# Minimal smoke test, paste into Altair or GraphiQL after setup:upgrade
# Verifies the endpoint responds and the custom type is registered
query SmokeTest {
__schema {
queryType {
fields {
name
description
}
}
}
}
# Check if custom query field appears in schema
query VerifyCustomField {
__type(name: "Query") {
fields {
name
}
}
}
9. Checklist before the first query test
Before a new GraphQL module is tested for the first time, a fixed checklist should be worked through. First: is the schema file placed under etc/schema.graphqls and syntactically correct? A quick syntax check is possible with bin/magento graphql:schema or through introspection. Second: are all referenced resolver classes present, do they have the correct FQCN and do they implement ResolverInterface? Third: was bin/magento setup:upgrade executed and the cache cleared with bin/magento cache:flush?
Fourth: are there conflicts with existing type names? A look at the core schemas and the schemas of other installed modules helps spot merge conflicts early. Fifth: is error handling implemented cleanly in the resolver? Uncontrolled PHP exceptions get converted by Magento GraphQL into generic error messages that provide little debugging information. Deliberately used GraphQL exceptions like GraphQlInputException or GraphQlAuthorizationException give the client structured, actionable error information.
# Integration test query, verify resolver returns expected structure
query CustomerBadgeIntegrationTest {
mironCustomerBadge(customer_id: 1) {
badge_level
points
next_level_threshold
expires_at
}
}
# Expected response shape:
# {
# "data": {
# "mironCustomerBadge": {
# "badge_level": "gold",
# "points": 1250,
# "next_level_threshold": 2000,
# "expires_at": "2027-01-01T00:00:00Z"
# }
# }
# }
10. Summary
Making a Magento module GraphQL-ready doesn't require a complex architecture, but it does require consistently following Magento conventions. The schema belongs in etc/schema.graphqls, resolvers in Model/Resolver/, business logic in a separate service. Type names with a vendor prefix avoid merge conflicts, and resolvers should be kept as short as possible: delegate instead of implement is the single most important design rule.
The most common problems when getting started, a forgotten setup:upgrade, incorrect FQCN references, logic placed directly in the resolver, can all be avoided with a clear checklist and a consistent separation of responsibilities. Anyone who sticks to this basic structure from the start has a noticeably easier starting point for later extensions and performance optimizations.
Magento GraphQL module structure, the essentials at a glance
Schema file
Always under etc/schema.graphqls, Magento only looks for schema files there. Use type names with a vendor prefix to avoid conflicts.
Resolver classes
Place them in Model/Resolver/, implement ResolverInterface, delegate exclusively to services or repositories, no database logic of their own.
Deploy order
setup:upgrade then cache:flush after every schema change. Without these steps the new schema isn't visible, not even for introspection.
Error handling
Targeted GraphQL exceptions (GraphQlInputException, GraphQlAuthorizationException) instead of generic PHP exceptions, giving the client actionable error messages.
11. FAQ: Preparing Magento modules for GraphQL
1Where does the schema.graphqls file need to live?
etc/schema.graphqls. Magento only loads schema files from this path, files placed elsewhere are ignored.2Is setup:upgrade needed after every schema change?
setup:upgrade and cache:flush, without these steps the new schema is invisible to Magento.3How do I avoid conflicts with core type names?
MironBadgeOutput instead of Product. Magento merges all schemas and throws an error on naming conflicts.4Is a resolver allowed to access the database directly?
5Difference between @resolver and di.xml registration?
@resolver in the schema is enough for simple cases. di.xml is additionally needed for union types, interface implementations and virtualType configurations.6How do I check whether the resolver is registered?
__type(name: "Query") { fields { name } } lists all registered query fields. If your own field is missing, the FQCN or setup status is wrong.7Which exception types should a resolver use?
GraphQlInputException, GraphQlNoSuchEntityException, GraphQlAuthorizationException, depending on the error type. Generic PHP exceptions give the client almost no actionable info.8Can the same service be used from a resolver and a REST API?
9What does @cache mean in the schema file?
10How do I debug a resolver that shows no error message?
var/log/debug.log and exception.log are the first place to look. Testing the resolver in isolation in an integration test also helps rule out the HTTP layer as the source of the error.