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.

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 query

  • cancellationToken - Token to cancel the async operation (optional, defaults to default)

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.

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 Find pattern 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 QueryHandlerAsync for database, HTTP, or file operations

  • Validate 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 Include or projection to avoid multiple database roundtrips

  • Not 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

Last updated

Was this helpful?