Query Handlers
CQRS
Queries carry read intent, and query handlers process them. They receive a query, access the right projection through a ReadView, and return the result. Query handlers are the read-side mirror of command handlers, completing the CQRS pipeline.
For background on how query handlers complete the CQRS read pipeline, see Query Handlers concept.
Defining Queries
A query is an immutable DTO representing a read intent. Queries are defined
with the @domain.query decorator and must be associated with a projection
via part_of:
from protean.fields import Identifier, Integer, String
@domain.query(part_of=OrderSummary)
class GetOrdersByCustomer:
customer_id = Identifier(required=True)
status = String()
page = Integer(default=1)
page_size = Integer(default=20)
Queries are immutable once created, attempting to modify a field after
construction raises IncorrectUsageError. Fields support the same validation constraints as
other domain elements (required, max_length, min_value, max_value, choices). Invalid inputs raise ValidationError at
construction time, before the query reaches the handler.
Query Naming Conventions
Name queries with the intent they represent, typically starting with Get,
Find, List, or Search:
| Pattern | Example | Use when |
|---|---|---|
Get<Entity>By<Criteria> |
GetOrdersByCustomer |
Filtered lookup returning multiple results |
Get<Entity>ById |
GetOrderById |
Single-record lookup by identifier |
List<Entity> |
ListActiveProducts |
Listing with optional filters |
Search<Entity> |
SearchOrders |
Full-text or complex multi-field search |
Decorator Options
| Option | Default | Description |
|---|---|---|
part_of |
— | Required. The projection class this query targets |
abstract |
False |
When True, the query cannot be instantiated, use as a base class |
Defining a Query Handler
Query handlers are defined with the @domain.query_handler decorator:
from protean import current_domain, read
from protean.fields import Float, Identifier, Integer, String
@domain.projection
class OrderSummary:
order_id = Identifier(identifier=True)
customer_name = String(max_length=100)
status = String(max_length=20)
total_amount = Float()
@domain.query(part_of=OrderSummary)
class GetOrdersByCustomer:
customer_id = Identifier(required=True)
status = String()
page = Integer(default=1)
page_size = Integer(default=20)
@domain.query(part_of=OrderSummary)
class GetOrderById:
order_id = Identifier(required=True)
@domain.query_handler(part_of=OrderSummary)
class OrderSummaryQueryHandler:
@read(GetOrdersByCustomer)
def get_by_customer(self, query):
view = current_domain.view_for(OrderSummary)
results = view.query.filter(
customer_id=query.customer_id
)
if query.status:
results = results.filter(status=query.status)
return results.all()
@read(GetOrderById)
def get_by_id(self, query):
view = current_domain.view_for(OrderSummary)
return view.get(query.order_id)
The @read Decorator
The @read decorator marks methods as query handlers. It accepts a single
argument, the query class to handle:
@read(GetOrdersByCustomer)
def get_by_customer(self, query):
...
Unlike @handle, the @read decorator:
- Does not wrap execution in a UnitOfWork
- Does not accept
start,correlate, orendparameters - Is intended exclusively for query handlers
Dispatching Queries
Dispatch queries with domain.dispatch():
# From an API endpoint or application layer
result = domain.dispatch(
GetOrdersByCustomer(customer_id="cust-123", status="shipped")
)
domain.dispatch() resolves the registered query handler, invokes the
correct method, and returns the result directly.
Comparison with domain.process()
| Aspect | domain.process(command) |
domain.dispatch(query) |
|---|---|---|
| Side | Write | Read |
| UoW | Yes | No |
| Returns | Optional | Always |
| Async | Supported | No (synchronous only) |
| Idempotency | Supported | Not needed |
Workflow
sequenceDiagram
autonumber
App->>Domain: dispatch(query)
Domain->>Query Handler: Route to handler method
Query Handler->>ReadView: view_for(Projection)
ReadView->>Database: Read query
Database-->>ReadView: Results
ReadView-->>Query Handler: Results
Query Handler-->>App: Return value
- Application dispatches query: The API layer creates a query object and
calls
domain.dispatch(). - Domain routes to handler: The domain resolves the registered query
handler and calls the matching
@read-decorated method. - Handler accesses ReadView: The handler method uses
domain.view_for()to get a read-only facade over the projection. - Results returned: The handler returns results, which pass through
domain.dispatch()back to the caller.
Three Levels of Read Access
Protean provides three levels of read access to projections:
| Level | Entry point | When to use |
|---|---|---|
| Pipeline | domain.dispatch(query) |
Named queries with validation and structured read logic |
| Facade | domain.view_for(Projection) |
Read-only projection access without handler ceremony |
| Raw | domain.connection_for(Projection) |
Technology-specific queries (SQL, Elasticsearch DSL, Redis) |
Query handlers operate at Level 1, the most structured approach. Use
domain.view_for(Projection) (Level 2) for read-only lookups that don't need a
handler. It returns a ReadView, and view.query gives you a chainable
ReadOnlyQuerySet (a QuerySet subclass whose update()/delete() raise
NotSupportedError). Use domain.connection_for(Projection) (Level 3) when
you need the raw database or cache client for technology-specific queries.
To write to a projection (inside a projector), use
domain.repository_for(Projection). That is the write path, not a read level.
Error Handling
domain.dispatch() raises IncorrectUsageError when:
- The argument is not a query instance
- The query class is not registered in the domain
- No query handler is registered for the query
Within handler methods, common runtime errors include:
| Exception | When it occurs |
|---|---|
ValidationError |
Query field validation fails at construction (missing required field, invalid value) |
ObjectNotFoundError |
view.get(identifier) finds no matching record |
NotSupportedError |
view.query or view.find_by() called on a cache-backed projection |
from protean.exceptions import IncorrectUsageError, ObjectNotFoundError
try:
result = domain.dispatch(GetOrderById(order_id="nonexistent"))
except ObjectNotFoundError:
# No projection record with this identifier
...
except IncorrectUsageError as e:
# Handle missing handler or unregistered query
...
@handle vs. @read
Both decorators route messages to handler methods, but they serve different sides of CQRS:
| Aspect | @handle |
@read |
|---|---|---|
| Used in | Command handlers, event handlers | Query handlers only |
| UoW wrapping | Yes, rolls back on error | No, read-only, no transaction |
| Side effects | Expected (persist aggregates, raise events) | None, reads only |
| Parameters | start, correlate, end for lifecycle control |
None beyond the query class |
Using @handle on a query-handler method is not permitted, domain.init() raises IncorrectUsageError
(naming @read), because a query handler must be a stateless read with no
UnitOfWork. Use @read for every query-handler method.
See also
Concept overview: Query Handlers: How query handlers process read intents in CQRS.
Related guides:
- Projections: The read models that query handlers operate on.
- Event Handlers: The write-side consumer counterpart.