in GraphQL APIs
How to model filter and sort arguments as clean input types, implement operator patterns for different data types, use Magento's filter conventions, and choose the right pagination strategy for each use case.
Table of Contents
- 1. Why filter design is underestimated
- 2. Input types as the foundation for filter logic
- 3. Operator patterns: eq, in, range, like
- 4. Magento's FilterEqualTypeInput: using the conventions
- 5. Modeling sorting: enum fields and direction
- 6. Pagination: offset vs. cursor based
- 7. Wiring filters to the resolver and repository
- 8. Security: what must not be filterable
- 9. Summary
- 10. Comparison: filter modeling approaches
- 11. FAQ
1. Why filter design is underestimated
Filter and sort logic in GraphQL APIs looks trivial at first glance: a few arguments, a bit of SQL in the resolver, done. In practice, however, poor filter design is one of the most common reasons APIs become hard to extend, create performance problems, and frustrate frontend teams. A query that passes filter conditions as flat strings is hard to validate, hard to document, and impossible to process programmatically.
The counterpart to this is typed filter design with structured input types and operator patterns. This approach is explicit, machine readable, validatable, and extensible. Magento leads the way here: FilterEqualTypeInput, FilterRangeTypeInput, and FilterMatchTypeInput are typed filter input types used consistently across all product list queries. Anyone building their own APIs benefits from adopting this pattern, adapted to their own data context.
2. Input types as the foundation for filter logic
The first step toward clean filter design is choosing input types over scalar arguments. Instead of query products(priceMin: Float, priceMax: Float), you model query products(filter: ProductFilterInput), where ProductFilterInput is an input type with fields for various filter criteria. This approach is more flexible (new filter fields can be added without changing the query signature) and clearer to document, because every filter field has an explicit name and type.
Input types in GraphQL follow the same type rules as output types, but they are only allowed as input. They can contain other input types as fields, but no output types. This allows filter composition: a ProductFilterInput can contain a field price: PriceRangeFilterInput, which in turn defines from: Float and to: Float. This nesting is clear, type safe, and semantically unambiguous.
# Typed filter input types, composable and self-documenting
input PriceRangeFilterInput {
from: Float
to: Float
}
input StringFilterInput {
eq: String # exact match
in: [String!] # match any in list
like: String # pattern match (% wildcard)
neq: String # not equal
}
input IntFilterInput {
eq: Int
gt: Int # greater than
lt: Int # less than
gte: Int # greater than or equal
lte: Int # less than or equal
in: [Int!]
}
input ProductFilterInput {
sku: StringFilterInput
name: StringFilterInput
price: PriceRangeFilterInput
category_id: IntFilterInput
status: IntFilterInput
}
extend type Query {
filteredProducts(
filter: ProductFilterInput
sort: ProductSortInput
pageSize: Int = 20
currentPage: Int = 1
): ProductSearchResult
@resolver(class: "Vendor\\Catalog\\Model\\Resolver\\FilteredProducts")
}
3. Operator patterns: eq, in, range, like
Operator patterns structure filter conditions according to their semantics. The simplest operator is eq (exact match): a field must match a value exactly. For list-based filtering, in is the right operator: the field must match one of the values in the list. For numeric ranges such as prices or dates, from/to or gte/lte pairs are suitable. For text search, like with wildcard support is the standard.
An important aspect of operator patterns is how null values are handled. In Magento, filter fields that are not supplied are simply ignored: no filter means no restriction. That makes sense for optional filter fields, but it is a security gap for mandatory filters, such as a tenant filter for multi-store setups. Mandatory filters should be declared as non-nullable arguments rather than as optional fields inside an input type, or the resolver must explicitly check that the mandatory filter is set.
4. Magento's FilterEqualTypeInput: using the conventions
Magento defines several reusable filter input types in Magento_GraphQl/etc/schema.graphqls: FilterEqualTypeInput (with eq and in), FilterRangeTypeInput (with from and to), and FilterMatchTypeInput (with match for full-text search). These types are used in Magento's product filters and are the standard for custom filters in Magento modules that should stay consistent with the core schema.
# Using Magento's built-in filter types for consistency
# These types are defined in Magento_GraphQl/etc/schema.graphqls
input CustomProductFilterInput {
# Exact match or list: use Magento's FilterEqualTypeInput
category_uid: FilterEqualTypeInput
color: FilterEqualTypeInput
size: FilterEqualTypeInput
# Numeric range: use Magento's FilterRangeTypeInput
price: FilterRangeTypeInput
stock_quantity: FilterRangeTypeInput
# Full-text match: use Magento's FilterMatchTypeInput
name: FilterMatchTypeInput
description: FilterMatchTypeInput
}
# Example query using the filter
query FilteredProductList {
filteredProducts(
filter: {
category_uid: { eq: "MTI=" }
price: { from: "10.00", to: "100.00" }
color: { in: ["red", "blue"] }
name: { match: "running" }
}
sort: { name: ASC }
pageSize: 20
currentPage: 1
) {
total_count
items {
sku
name
price_range { minimum_price { final_price { value currency } } }
}
}
}
5. Modeling sorting: enum fields and direction
Sort logic in GraphQL is modeled most cleanly with a dedicated sort input type. The sort fields are enums, which prevents invalid field names and makes the allowed sort options visible to the client. Sort direction is expressed through a separate enum type, SortEnum, with the values ASC and DESC. Magento uses this pattern in its product API, and it transfers well to other resource types.
A common design question is whether multi-column sorting (sorting by several fields at once) should be supported. Magento supports it: the sort input type can have multiple fields set, and the resolver interprets them as a prioritized sort order. If you do not need multi-column sorting, you should still design the type so it can be extended later, meaning you use an input type rather than a scalar argument for sorting.
6. Pagination: offset vs. cursor based
Pagination is its own design topic in GraphQL. The simplest form is offset pagination with pageSize and currentPage: understandable, widely used in Magento, but with a well-known problem for live data. If a record is added or removed between two page requests, the offset shifts and the client ends up seeing either duplicate or skipped entries. For product lists that change rarely, that is acceptable; for activity feeds or real-time lists, it is problematic.
Cursor-based pagination is the more robust pattern, defined in the Relay specification: the server attaches an opaque cursor to each record, and the client can pass this cursor as the starting point for the next page. This is stable against insertions and deletions during pagination. The implementation is more involved (the cursor has to encode the sort position and be decoded on the next request), but the result is consistent pagination regardless of data changes.
# Offset-based pagination (simple, Magento-native)
type ProductSearchResult {
items: [Product!]!
total_count: Int!
page_info: SearchResultPageInfo!
}
type SearchResultPageInfo {
page_size: Int!
current_page: Int!
total_pages: Int!
}
# Cursor-based pagination (Relay spec, more robust for live data)
type ProductConnection {
edges: [ProductEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type ProductEdge {
node: Product!
cursor: String! # opaque cursor, do not parse client-side
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
# Query using cursor pagination
extend type Query {
productsFeed(
first: Int = 20
after: String # cursor from previous page's endCursor
filter: ProductFilterInput
): ProductConnection
}
7. Wiring filters to the resolver and repository
The resolver takes the structured filter input from $args['filter'] and has to translate it into database queries. In Magento, that is the SearchCriteriaBuilder: each filter field is translated into a FilterGroup entry. Mapping GraphQL filter fields to Magento attribute codes is its own concern; it belongs in a dedicated mapper class, not directly in the resolver. This mapper is also the right place to explicitly exclude filter fields that are not allowed.
Performance is critical for filtered lists: a filter on a non-indexed attribute leads to a full table scan. The resolver cannot prevent that, but it can refuse it by rejecting non-indexed filter fields with a GraphQlInputException. A whitelist of allowed filter fields, analogous to a whitelist of allowed sort fields, is the right pattern here. Anything not explicitly allowed gets rejected.
8. Security: what must not be filterable
Filters are an attack surface for data exposure: a client could try to filter by internal fields that exist in the database schema but are not exposed in the GraphQL schema. This is especially relevant in SQL-based backends where the resolver translates filters directly into SQL conditions. A whitelist of allowed filter fields prevents a client from filtering by fields such as password_hash, fraud_score, or other internal attributes.
A second security aspect is the complexity of filter expressions. A client could pass a filter with hundreds of in values, generating an expensive SQL IN clause. A maximum number of values in the in operator, for example 100, prevents intentional or unintentional query overload. This limit should be checked explicitly in the resolver or mapper and rejected with a GraphQlInputException when exceeded.
9. Summary
Modeling filtering and sorting cleanly in GraphQL APIs means: typed input types instead of flat scalar arguments, operator patterns instead of implicit filter logic, whitelisting instead of free field selection, and the right pagination strategy for the use case. Magento's filter input types are a good reference model for custom filters in Magento modules and for external APIs that need similar filtering concepts.
The effort spent on clean filter design pays off twice over: the frontend team gets a clear, documented API that does not need to learn the backend's implementation details. The backend team can perform performance optimizations at the repository layer without changing the API surface. And security properties, such as whitelisting and complexity limits, are explicit and testable instead of implicit and accidental.
Filtering and Sorting in GraphQL APIs: The Essentials at a Glance
Input Types
Typed filter input types instead of scalar arguments. Composition through nested input types. Use Magento's FilterEqualTypeInput as a template.
Operators
eq, in, range, like as dedicated fields in the filter input type. Fields not supplied are ignored. Mandatory filters as non-nullable arguments.
Pagination
Offset (pageSize/currentPage) for simple lists. Cursor-based (Relay spec) for live data and consistent navigation without duplicates.
Security
Whitelist of allowed filter fields in the mapper. Cap the maximum in-operator size. Explicitly reject non-indexed fields.
10. Comparison: filter modeling approaches
| Approach | Example | Advantage | Disadvantage |
|---|---|---|---|
| Scalar arguments | products(sku: "ABC") |
Simple | Not extensible, no operators |
| Input type with operator | filter: { sku: { eq: "ABC" } } |
Type safe, extensible | More schema code |
| Magento FilterEqualTypeInput | { eq: "x", in: ["x","y"] } |
Core compatible, reusable | Magento specific |
| Offset pagination | pageSize: 20, currentPage: 2 |
Simple, understandable | Inconsistent with live data |
| Cursor pagination | first: 20, after: "cursor" |
Stable, Relay compatible | More complex implementation |