Wiring Filters Cleanly into Resolvers
Anyone who resolves GraphQL filters in Magento naively through raw SQL or direct model queries builds up technical debt. The correct path runs through the SearchCriteriaBuilder, with type-safe arguments in the schema, clean delegation in the resolver and testable filter logic in the service contract.
Table of Contents
- 1. Why SearchCriteria in GraphQL Resolvers Deserves Its Own Chapter
- 2. The GraphQL Schema: Modeling Filter Arguments Correctly
- 3. SearchCriteriaBuilder: Translating Arguments Cleanly into Filter Groups
- 4. The Resolver: Delegation Instead of Data Access Logic
- 5. Complete Example: Product List with Price Filter
- 6. Wrong vs. Right: Filter Logic in the Resolver vs. Service Contract
- 7. Typical Failure Patterns When Using SearchCriteria
- 8. Resolver Tests with SearchCriteria Mocking
- 9. Performance: Which Filters Get Expensive and Why
- 10. Summary
- 11. FAQ
1. Why SearchCriteria in GraphQL Resolvers Deserves Its Own Chapter
In Magento 2, the SearchCriteria mechanism is the standardized interface for filtered data access through service contracts. Every repository that accepts SearchCriteriaInterface thereby promises consistent, extensible filter logic, regardless of whether the data comes from the database, an index or an external system. When GraphQL resolvers do not use this interface correctly, the result is a system in which filter logic is scattered, implemented twice and untestable.
The problem shows up especially in growing projects: a resolver first filters directly on the model collection object because that is quick. Later, another resolver arrives for the same data type and implements the same filter differently. Eventually a third one builds its own SQL extension. GraphQL filters should therefore run through the SearchCriteriaBuilder from day one: the resolver translates GraphQL arguments into filter groups and delegates the rest to the service contract.
This article walks through the full path: from schema design through the resolver implementation to the test, with real code examples from Magento modules and a comparison table of the most common failure patterns.
2. The GraphQL Schema: Modeling Filter Arguments Correctly
The first step starts in the module's schema.graphqls file. Filter arguments are modeled as input types, not as flat scalar arguments. The reason: Magento's own schema uses this pattern throughout, and custom types should fit in seamlessly. A ProductAttributeFilterInput-like type allows the same input type to be reused across multiple queries and evaluated consistently inside the resolver.
An important aspect of schema modeling is separating filter inputs from the actual result type. Filter inputs are input types used only for arguments. The result type is a normal object type. This separation prevents filter fields from accidentally ending up as output in subscriptions or other contexts. It also makes it possible to deprecate breaking changes in the filter model in a targeted way without touching the result type.
# Schema definition for a filterable product list query
# File: Vendor/Module/etc/schema.graphqls
type Query {
customProducts(
filter: CustomProductFilterInput
pageSize: Int = 20
currentPage: Int = 1
sort: CustomProductSortInput
): CustomProductsOutput @resolver(class: "Vendor\\Module\\Model\\Resolver\\CustomProducts")
}
input CustomProductFilterInput {
sku: FilterEqualTypeInput
price: FilterRangeTypeInput
category_id: FilterEqualTypeInput
status: FilterEqualTypeInput
}
input CustomProductSortInput {
price: SortEnum
name: SortEnum
created_at: SortEnum
}
type CustomProductsOutput {
items: [CustomProductItem]
total_count: Int
page_info: SearchResultPageInfo
}
type CustomProductItem {
id: Int
sku: String
name: String
price: Float
}
3. SearchCriteriaBuilder: Translating Arguments Cleanly into Filter Groups
The SearchCriteriaBuilder is the bridge between the GraphQL arguments and the repository call. Its job inside the resolver is clearly defined: it takes the raw arguments from the $args array, builds typed filter objects from them and hands the finished SearchCriteriaInterface object to the service contract. Implementing this logic directly in the resolver mixes the presentation layer with data access logic, a classic anti-pattern.
A clean solution separates the translation into a dedicated FilterBuilder with a clear input/output interface: GraphQL arguments in, SearchCriteria out. This FilterBuilder is testable independently of the resolver, can be used across multiple resolvers and keeps the actual resolver noticeably slimmer. Magento's filter group logic, where multiple filters in one group act as OR and multiple groups act as AND, must be explicitly accounted for here.
4. The Resolver: Delegation Instead of Data Access Logic
A cleanly implemented GraphQL resolver in Magento has exactly one job: it takes the validated arguments from the request, delegates the data retrieval to the appropriate service contract and transforms the result into the data structure expected by the schema. Everything else, filter logic, caching, authorization, error handling, belongs in specialized classes, not in the resolver itself.
This principle matters especially because Magento's resolver infrastructure has no automatic error forwarding. Exceptions that are not caught in the resolver end up as generic GraphQL errors on the client, without details, without context. A resolver that delegates cleanly can catch exceptions from the service contract in a targeted way and translate them into structured GraphQL errors that the frontend can evaluate.
# Example query using the custom filter schema
query FilteredProducts {
customProducts(
filter: {
price: { from: "10.00", to: "150.00" }
category_id: { eq: "5" }
status: { eq: "1" }
}
pageSize: 12
currentPage: 1
sort: { price: ASC }
) {
total_count
page_info {
current_page
page_size
total_pages
}
items {
id
sku
name
price
}
}
}
5. Complete Example: Product List with Price Filter
The following example shows how a resolver can translate GraphQL filter arguments into a correct database query through a dedicated SearchCriteriaBuilder call. The key property: the resolver contains no filter logic. It reads arguments, builds criteria through the builder, calls the service and maps the result. The SearchCriteriaBuilder is provided through dependency injection, and the actual FilterBuilder is a separate object.
Handling pagination correctly matters here: Magento's SearchResultsInterface already contains all the information needed for the page_info field in the schema. The total_count value comes directly from the repository result and returns the total number of unfiltered records, so the frontend can compute the page count from it without issuing a separate count query.
# Introspection check: verify custom filter input types are registered
query IntrospectFilterInput {
__type(name: "CustomProductFilterInput") {
name
kind
inputFields {
name
type {
name
kind
ofType {
name
kind
}
}
}
}
}
# Expected result: inputFields should contain
# sku (FilterEqualTypeInput), price (FilterRangeTypeInput),
# category_id (FilterEqualTypeInput), status (FilterEqualTypeInput)
6. Wrong vs. Right: Filter Logic in the Resolver vs. Service Contract
The most common wrong variant: the resolver instantiates a model collection directly, calls addFieldToFilter() and iterates over the result right in the resolver. That works, but it is not testable, not extensible and completely ignores Magento's caching and indexing infrastructure. The SearchCriteria path is the prerequisite for the result being cached correctly and invalidated automatically when the index changes.
| Aspect | Wrong: collection directly | Right: SearchCriteria | Benefit |
|---|---|---|---|
| Testability | Hard to mock, needs a DB | Repository mockable | Unit tests without a database connection |
| Caching | No automatic caching | Cache infrastructure applies | Results are invalidated automatically |
| Extensibility | Plugin on collection is hard | Plugin on repository possible | Filters can be added later |
| Type safety | Strings as field names | Typed FilterGroup objects | Errors detectable at compile time |
| Pagination | Implement manually | Included in SearchCriteria | pageSize and currentPage standardized |
Another argument for the SearchCriteria path: OpenSearch integration. When Magento switches to OpenSearch as the search backend, repositories built on SearchCriteriaInterface automatically route requests to the correct adapter. Filter logic that operates directly on collections knows nothing about that switch and keeps issuing database queries, a serious performance problem on large product catalogs.
7. Typical Failure Patterns When Using SearchCriteria
The most common failure pattern: OR combinations get implemented incorrectly. Multiple calls to addFilter() on the SearchCriteriaBuilder create AND combinations by default. To get OR, all OR-combined filters must be gathered into a single FilterGroup before that group is handed to the builder. Anyone who does not know this ends up implementing OR logic and getting AND results, a behavior that is hard to debug because the query does not return an error, just fewer results.
A second failure pattern concerns undeclared filters: when a GraphQL argument is defined in the schema but not processed in the resolver, Magento silently ignores the filter. The frontend passes a filter, gets unfiltered results back and interprets that as a backend bug. That is why every filter field defined in the schema should be handled explicitly in the FilterBuilder, with an explicit if (isset($args['filter']['feldname'])) guard.
8. Resolver Tests with SearchCriteria Mocking
A big advantage of the SearchCriteria architecture is testability. Because the resolver only calls the repository, and not the database layer directly, the repository can be fully mocked in unit tests. The mock object checks whether the resolver passes the correct filters: whether the price filter carries the right from and to value, whether the status filter selects only active products, and whether the pagination parameters are passed correctly into the SearchCriteria object.
A different approach is recommended for integration tests: here the real resolver is called against a test database, and the result is checked against a known set of products. Magento's own integration test infrastructure provides fixtures for this that create test products with defined attributes. The GraphQL endpoint can be addressed directly through GraphQlQueryTest, which is the safest way to make sure schema, resolver and filter logic work correctly together.
# Integration test query: verify filter returns exactly matching products
# Run against test database with known fixture data
query TestPriceFilter {
customProducts(
filter: {
price: { from: "50.00", to: "100.00" }
status: { eq: "1" }
}
pageSize: 100
currentPage: 1
) {
total_count
items {
sku
price
}
}
}
# Expected: all items have price between 50 and 100
# Expected: total_count matches fixture data count in that range
# Assertion: no item.price outside [50, 100] range
9. Performance: Which Filters Get Expensive and Why
Not all filters are equally expensive. EAV attribute filters, meaning filters on product attributes such as color, material or manufacturer, produce JOIN queries across the EAV tables that can create significant runtimes on large product catalogs. SearchCriteria filters on EAV attributes go through the eav_attribute_value join, which scales linearly without an index on the attribute column and the entity ID field. This is a well-known Magento performance problem that can be avoided through OpenSearch indexing.
Another performance problem: nested filter groups with IN conditions over large ID lists. A filter such as category_id IN (1, 2, 3, ..., 500) internally produces a catalog_category_product join query that, with a deep category hierarchy, includes every subcategory. For cases like this, OpenSearch is the correct solution: the GraphQL filter is then evaluated not against the database but against the search index, which already holds this data denormalized.
10. Summary
Wiring SearchCriteria filters cleanly into Magento GraphQL resolvers means: model filter arguments in the schema as input types, translate them in the resolver into typed filter groups through the SearchCriteriaBuilder, and delegate the actual data retrieval entirely to the service contract. The resolver itself contains no filter logic; it is the translator between the GraphQL world and the Magento service contract world.
The separation pays off immediately: tests become simpler because the repository can be mocked. Extensions become safer because plugins can target repositories. Performance optimizations through OpenSearch work transparently because the filter logic is not tied to a specific persistence layer. This is not an academic architecture principle but a practical prerequisite for maintainable GraphQL code in Magento.
SearchCriteria and Magento GraphQL, the Essentials at a Glance
Schema Design
Model filter arguments as input types, not as flat scalar arguments. Reusable and schema-conform.
Resolver Principle
The resolver translates GraphQL arguments into SearchCriteria and delegates to the service contract. No filter logic in the resolver.
OR vs. AND
Multiple addFilter() calls equal AND. OR combinations need a shared FilterGroup. This difference causes the most common bugs.
Performance
EAV filters and large IN lists over the DB are expensive. OpenSearch integration solves that, but only if SearchCriteria is used correctly.