How to Implement a Query Handler
Introduction
Query handlers are the entry point to your query execution logic in Darker. A query handler receives a query object, executes the necessary logic to retrieve or compute the requested data, and returns the result. Query handlers are always part of an internal bus and form part of a middleware pipeline, similar to how request handlers work in Brighter.
Each query handler is responsible for:
Receiving a specific query type
Executing the logic to retrieve the data
Returning a strongly-typed result
Participating in the query pipeline (decorators can be applied)
Query handlers are analogous to methods on an ASP.NET Controller, but with the benefits of separation of concerns, testability, and the ability to apply cross-cutting concerns through decorators.
For more information on the Query Processor and how it dispatches queries to handlers, see Basic Concepts.
Query Objects
Before implementing a query handler, you need a query object that defines what data to retrieve. Query objects implement the IQuery<TResult> interface.
Defining a Query
Here are the two queries we'll use in examples throughout this guide:
Simple query (no parameters):
using Paramore.Darker;
using System.Collections.Generic;
public sealed class GetPeopleQuery : IQuery<IReadOnlyDictionary<int, string>>
{
}Parameterized query:
For detailed information on designing query objects, see Queries and Query Objects.
Query Design Guidelines
When designing queries that will be handled by your handlers:
Keep queries immutable (read-only properties)
Include all parameters needed to execute the query
Use descriptive names (GetX, FindX, SearchX patterns)
Validate parameters in the constructor
Use appropriate result types in
IQuery<TResult>
Handler Implementation Patterns
Darker provides three ways to implement query handlers, each suited to different scenarios. The asynchronous handler pattern is recommended for most applications.
Pattern 1: Asynchronous Handler (Recommended)
The QueryHandlerAsync<TQuery, TResult> base class is the recommended approach for implementing query handlers. It supports asynchronous operations, which is essential for I/O-bound operations like database queries.
QueryHandlerAsync<TQuery, TResult>
To create an asynchronous query handler, inherit from QueryHandlerAsync<TQuery, TResult> and override the ExecuteAsync method:
ExecuteAsync Method Signature
The ExecuteAsync method has the following signature:
Parameters:
query- The query object containing the parameters for this querycancellationToken- Token to cancel the async operation (optional, defaults todefault)
Returns: A Task<TResult> containing the query result
CancellationToken Support
Always accept and use the CancellationToken parameter:
Passing the cancellation token allows the operation to be cancelled if the request is aborted, improving application responsiveness and resource usage.
When to Use Async Handlers
Use QueryHandlerAsync<TQuery, TResult> when your handler performs:
Database queries - Entity Framework, Dapper, ADO.NET
HTTP requests - Calling external APIs or services
File I/O - Reading from files or streams
Any I/O-bound operation - Operations that wait on external resources
Modern .NET applications should default to async handlers unless there's a specific reason to use synchronous handlers.
Complete Example with Decorators
Here's a complete example showing a handler with decorators applied:
The decorators are applied using attributes with step numbers that control their execution order. For more information on decorators and the query pipeline, see Query Pipeline.
Working example: Darker/samples/SampleMinimalApi/QueryHandlers/GetPeopleQueryHandler.cs
Pattern 2: Synchronous Handler
The QueryHandler<TQuery, TResult> base class is used for synchronous query handlers. Use this pattern only when you have a specific need for synchronous execution.
QueryHandler<TQuery, TResult>
To create a synchronous query handler, inherit from QueryHandler<TQuery, TResult> and override the Execute method:
Execute Method Signature
The Execute method has the following signature:
Parameters:
query- The query object containing the parameters for this query
Returns: The query result of type TResult
Note that synchronous handlers do not receive a CancellationToken.
When to Use Synchronous Handlers
Use QueryHandler<TQuery, TResult> only when:
Your handler performs purely in-memory operations (cache lookups, calculations)
You're working in a synchronous context that cannot be made async
You have a specific performance requirement for synchronous execution
You're integrating with legacy synchronous code
Important: For database queries, HTTP calls, file I/O, or any operation that waits on external resources, use QueryHandlerAsync<TQuery, TResult> instead.
Complete Example
Converting from Synchronous to Asynchronous
If you later need to add async operations, you can convert a synchronous handler to async:
Pattern 3: Direct IQueryHandler Implementation
For maximum control, you can implement the IQueryHandler<TQuery, TResult> interface directly. This gives you full control over the handler implementation but requires more boilerplate code.
IQueryHandler<TQuery, TResult> Interface
The interface defines both synchronous and asynchronous execution methods:
When to Use Direct Implementation
Use IQueryHandler<TQuery, TResult> directly when you need:
Maximum control over both sync and async implementations
Custom lifetime management beyond what the base classes provide
Advanced scenarios not covered by the base classes
Conditional sync/async execution based on runtime conditions
Complete Example
Note: Most applications should use QueryHandlerAsync<TQuery, TResult> or QueryHandler<TQuery, TResult> instead of implementing the interface directly. Direct implementation adds complexity and is rarely needed.
Query Handler Registration
Query handlers must be registered with the Darker query processor before they can be used. Darker provides two approaches: automatic assembly scanning (recommended) and manual registration.
Automatic Registration (Recommended)
Use AddHandlersFromAssemblies to automatically discover and register all query handlers in one or more assemblies:
This scans the assembly and registers all classes that inherit from QueryHandler<,>, QueryHandlerAsync<,>, or implement IQueryHandler<,>.
Multiple assemblies:
Convention-based discovery:
The assembly scanner looks for:
Public classes (not abstract)
That implement query handler interfaces
With parameterless constructors or constructors that can be resolved from DI
Manual Registration
For fine-grained control, register handlers explicitly using QueryHandlerRegistry:
Manual registration is useful when:
You need precise control over which handlers are registered
You're not using ASP.NET Core's dependency injection
You want to register handlers conditionally
You're working in a non-web application
Working with Dependencies
Query handlers typically need dependencies like repositories, database contexts, or services to execute queries. Darker supports dependency injection for handler dependencies.
Constructor Injection
Inject dependencies through the handler's constructor:
Dependencies are resolved automatically by the DI container when the handler is instantiated.
Scoped Dependencies (EF Core DbContext)
When using Entity Framework Core, inject the DbContext as a scoped dependency. Remember to configure Darker with scoped lifetime:
Important: Ensure you've configured Darker with scoped lifetime in your Program.cs:
Multiple Dependencies
Handlers can have multiple dependencies injected:
Query Results and Error Handling
Returning Results
Query handlers should return the type specified in IQuery<TResult>. The result can be any C# type.
Simple results:
Complex results:
Collection results:
Null Handling
Use nullable reference types when a query might not return a result:
Throwing Exceptions
Throw exceptions for exceptional situations:
When to throw exceptions:
Entity not found (when the query expects it to exist)
Authorization failures
Data integrity issues
Unrecoverable errors
When not to throw exceptions:
Normal flow control (use nullable types instead)
Expected "not found" scenarios (use
Findpattern with nullable return)
Domain Exceptions
Create custom exception types for domain-specific errors:
Validation
Validate complex business rules in the handler:
Testing Query Handlers
Query handlers are easy to test because they have clear inputs (queries) and outputs (results), with dependencies that can be mocked or replaced.
Test-Driven Development
Use Test-Driven Development (TDD) to design query handlers:
Replacing Dependencies with In-Memory Solutions
For integration tests, replace real dependencies with in-memory alternatives:
Acceptance Tests
For acceptance tests, use a real database to verify the entire query flow:
Best Practices
Keep handlers focused - Each handler should have a single responsibility
Use async for I/O operations - Always prefer
QueryHandlerAsyncfor database, HTTP, or file operationsValidate query parameters - Perform simple validation in the query constructor, complex validation in the handler
Return appropriate result types - Use nullable types when results may not exist, read-only collections for lists
Handle nulls explicitly - Use nullable reference types and be clear about when nulls can occur
Use CancellationToken - Always accept and pass through cancellation tokens to allow request cancellation
Inject dependencies - Use constructor injection for all handler dependencies
Project only what you need - Use DTOs to return only the data required by consumers
Use AsNoTracking with EF Core - Optimize read-only queries with
AsNoTracking()Keep logic in handlers, not queries - Query objects should be simple data containers
Common Pitfalls
Forgetting CancellationToken parameter - Always include the cancellation token in async methods
Using wrong handler base class - Use async handlers for I/O operations
Lifetime scope mismatches - Configure scoped lifetime when using EF Core DbContext
Not registering handlers - Ensure handlers are registered via assembly scanning or manual registration
Business logic in query objects - Keep queries as simple parameter containers
Over-fetching data - Project only the fields needed instead of returning entire entities
N+1 query problems - Use
Includeor projection to avoid multiple database roundtripsNot handling nulls - Be explicit about nullable results and handle them appropriately
Mixing queries and commands - Queries should never modify state
Complex validation in constructors - Move complex validation logic to handlers
Further Reading
Queries and Query Objects - Designing query objects
Query Pipeline - Understanding decorators and the query pipeline
Query Patterns - Advanced patterns for real-world scenarios
Basic Configuration - Setting up Darker
CQRS with Brighter and Darker - Architectural patterns
Last updated
Was this helpful?
