Queries and Query Objects

Introduction

The Query Object pattern separates the parameters of a query from the execution of that query. In Darker, queries are simple objects that encapsulate the data needed to perform a query, while query handlers contain the logic to execute the query and return results.

This separation provides several benefits:

  • Clear separation between what you want to query (the query object) and how to query it (the query handler)

  • Easy testing - query objects are just data containers

  • Reusable query definitions across different parts of your application

  • Type-safe query parameters and results

For more information on how queries fit into CQRS architectures, see CQRS with Brighter and Darker.

The IQuery Interface

All queries in Darker implement the IQuery<TResult> interface, which is a marker interface that defines the result type of the query.

Interface Definition

public interface IQuery<out TResult>
{
}

The interface itself has no methods or properties - it simply marks a class as a query and specifies what type of result the query will return.

Type Parameter

The TResult type parameter specifies the type that will be returned when the query is executed:

  • IQuery<string> - Returns a string

  • IQuery<int> - Returns an integer

  • IQuery<OrderDetails> - Returns an OrderDetails object

  • IQuery<IReadOnlyList<Customer>> - Returns a read-only list of customers

  • IQuery<IReadOnlyDictionary<int, string>> - Returns a read-only dictionary

The query processor uses this type information to match queries to their handlers and ensure type safety throughout the pipeline.

Designing Query Objects

Query objects should be simple, immutable data containers that hold the parameters needed to execute a query. They should not contain any business logic or query execution code.

Simple Queries (No Parameters)

The simplest queries have no parameters - they simply indicate what data you want to retrieve:

This query returns all people as a dictionary mapping IDs to names. It has no properties because it doesn't need any parameters - the handler will return all available data.

When to use simple queries:

  • Retrieving all items in a collection (when the collection is reasonably sized)

  • Dashboard or summary queries that don't need filtering

  • Queries that return system-wide configuration or settings

Parameterized Queries

Most queries need parameters to specify what data to retrieve:

This query takes a personId parameter to specify which person's name to retrieve. The parameter is passed through the constructor and exposed as a read-only property.

When to use parameterized queries:

  • Single entity lookups by ID or unique key

  • Filtered collections (e.g., orders for a specific customer)

  • Searches with specific criteria

Complex Query Parameters

For queries with multiple parameters or complex filtering criteria, include all necessary parameters as properties:

This query supports multiple optional filter parameters, allowing flexible searching.

Optional Parameters:

Use nullable types for optional parameters:

Filter Objects:

For very complex queries, consider using a separate filter object:

This approach is useful when the same filter criteria are used across multiple queries.

Query Object Design Principles

Immutability

Query objects should be immutable - once created, their state cannot be changed. This makes queries predictable, thread-safe, and easy to reason about.

Use read-only properties:

Or use init-only setters (C# 9+):

Avoid mutable properties:

Value Object Pattern

Queries behave like value objects - their identity is based on their values, not on object reference. Two query objects with the same parameter values should be considered equal.

While not required, implementing equality can be useful for testing or caching scenarios.

Encapsulation

Keep query internals simple and focused on data. Any computed or derived values should be calculated in the handler, not the query.

Good encapsulation:

Avoid business logic in queries:

Business logic and calculations should live in handlers or domain objects, not in query objects.

Query Result Types

The result type specified in IQuery<TResult> can be any C# type. Choose the appropriate type based on what data the query needs to return.

Primitive Types

Use primitive types for simple single-value queries:

DTOs and Projections

For complex data, return Data Transfer Objects (DTOs) or projections:

DTOs are useful for projecting only the data needed by the UI or API, avoiding over-fetching.

Collections

Use collection types for queries that return multiple items:

Prefer IReadOnlyList<T> or IReadOnlyCollection<T> for query results to make it clear that the data should not be modified.

Dictionaries

Use dictionaries when returning key-value pairs:

Dictionaries are useful for lookup scenarios where you need fast access by key.

Nullable Results

Use nullable types when a query might not return a result:

Nullable types make it explicit that a query may return no result, forcing callers to handle the null case.

Complex Result Types

For advanced scenarios, you can return tuples, custom result wrappers, or domain objects:

Validation in Query Objects

Query objects should validate their parameters to ensure they receive valid data. Simple validation belongs in the constructor, while complex validation should be handled by the handler or a validation framework.

Constructor Validation

Use guard clauses in the constructor for simple validation:

Validation Attributes

For ASP.NET scenarios, you can use data annotations that are validated by the framework:

The ASP.NET model binder will validate these attributes before the query reaches your handler.

Where to Validate

Constructor validation (recommended for queries):

  • Parameter null checks

  • Range validation for numeric values

  • Format validation for strings

  • Basic business invariants

Handler validation (for complex rules):

  • Database existence checks

  • Authorization checks

  • Complex business rules

  • Cross-field validation

Framework validation (ASP.NET):

  • Model binding validation

  • Data annotations

  • Request validation

Query Naming Conventions

Consistent naming helps developers understand what a query does at a glance.

GetXQuery - Retrieve a single item (expected to exist):

GetXsQuery or GetXListQuery - Retrieve a collection:

FindXQuery - Retrieve a single item (may not exist, returns null):

SearchXQuery - Search with criteria:

ListXQuery - Retrieve a list (alternative to GetXsQuery):

Naming Examples

Use descriptive, specific names that clearly communicate the query's purpose.

Query Organization

File Structure

Organize query files in a way that makes them easy to find and maintain:

Option 1: Queries folder

Option 2: Feature folders

Option 3: Colocation with handlers

Choose the structure that best fits your application's organization and team preferences.

Colocation with Handlers

You can keep queries and their handlers in the same file for small projects:

This approach reduces file count and keeps related code together.

Shared Query Library

For microservices or modular monoliths, consider a shared query library:

This allows multiple services to reference the same query definitions without duplicating code.

Query Patterns

Pattern: Pagination Query

Queries for paginated data:

Pattern: Search Query

Queries with filter criteria:

Pattern: Projection Query

Queries that return specific fields:

Pattern: Aggregation Query

Queries that return calculated or aggregated data:

Best Practices

  • Make queries immutable - Use read-only properties or init-only setters

  • Use descriptive names - Queries should clearly indicate what data they retrieve

  • Keep queries simple - Queries should only hold parameters, not business logic

  • Validate in constructors - Use guard clauses for simple parameter validation

  • Use appropriate result types - Return read-only collections, DTOs, or domain objects as appropriate

  • Use nullable types - Make it explicit when a query may return no result

  • Seal query classes - Use sealed to prevent inheritance and maintain immutability

  • Prefer composition - Use filter objects for complex query parameters

Common Pitfalls

  • Mutable query objects - Avoid properties with public setters that can be changed after creation

  • Business logic in queries - Keep calculation and business rules in handlers, not queries

  • Complex validation in queries - Move complex validation to handlers or validation frameworks

  • Missing null handling - Always consider whether a query can return null and use nullable types accordingly

  • Inappropriate result types - Don't return mutable collections or over-fetch data

  • Vague naming - Avoid generic names like "Query1" or "GetDataQuery"

  • Over-validation - Don't perform expensive checks (like database lookups) in query constructors

  • Public mutable collections - If a query needs a collection parameter, make it read-only

Further Reading

Last updated

Was this helpful?