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 stringIQuery<int>- Returns an integerIQuery<OrderDetails>- Returns an OrderDetails objectIQuery<IReadOnlyList<Customer>>- Returns a read-only list of customersIQuery<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.
Recommended Patterns
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
sealedto prevent inheritance and maintain immutabilityPrefer 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
Implementing a Query Handler - Learn how to create handlers for your queries
Query Pipeline - Understanding decorators and middleware for queries
Query Patterns - Advanced patterns for real-world query scenarios
Basic Configuration - Setting up Darker in your application
CQRS with Brighter and Darker - Architectural patterns for CQRS
Last updated
Was this helpful?
