Query Patterns

Introduction

This guide presents common query patterns you'll encounter when building real-world applications with Darker. While Queries and Query Objects covers the fundamentals of query design, and Implementing a Query Handler covers basic handler implementation, this document focuses on practical patterns for complex scenarios including pagination, projections, aggregations, and Entity Framework Core integration.

These patterns address real challenges like handling large data sets, optimizing query performance, working with related data, and implementing caching strategies. Each pattern includes complete, working examples that you can adapt to your specific needs.

Parameterized Query Patterns

Pattern: Single Entity Lookup

Use Case: Retrieve a single entity by its unique identifier

This is the most common query pattern - retrieving one entity when you have its ID or another unique key.

using Paramore.Darker;

// Query by primary key
public sealed class GetPersonNameQuery : IQuery<string>
{
    public GetPersonNameQuery(int personId)
    {
        PersonId = personId;
    }

    public int PersonId { get; }
}

// Query by unique alternate key
public sealed class GetCustomerByEmailQuery : IQuery<CustomerDto?>
{
    public GetCustomerByEmailQuery(string email)
    {
        if (string.IsNullOrWhiteSpace(email))
            throw new ArgumentException("Email is required", nameof(email));

        Email = email;
    }

    public string Email { get; }
}

// Query by composite key
public sealed class GetOrderLineQuery : IQuery<OrderLineDto?>
{
    public GetOrderLineQuery(int orderId, int lineNumber)
    {
        OrderId = orderId;
        LineNumber = lineNumber;
    }

    public int OrderId { get; }
    public int LineNumber { get; }
}

Handler Example:

When to use: Direct entity lookups by ID, email, username, or other unique keys.

Pattern: Filtered List

Use Case: Retrieve a list of entities matching specific criteria

Handler with optional filters:

When to use: Filtered lists where you know all results will fit in memory (typically < 1000 items). For larger result sets, use pagination.

Pattern: Search with Multiple Criteria

Use Case: Complex search with multiple optional filters

Handler with multiple optional criteria:

When to use: Search interfaces with multiple optional filter parameters. Consider adding pagination for production use.

Pagination Patterns

Pattern: Offset-Based Pagination

Use Case: Standard pagination for most applications

Offset-based pagination is the most common pattern, using page number and page size.

Handler with pagination:

When to use: Most pagination scenarios, especially when total count is needed for UI.

Trade-offs:

  • ✅ Simple to implement

  • ✅ Supports jumping to specific pages

  • ✅ Total count available for UI

  • ❌ Performance degrades with large offsets (page 1000 is slow)

  • ❌ Can show duplicates/skips if data changes between page requests

Pattern: Cursor-Based Pagination

Use Case: Efficient pagination for large datasets or real-time data

Cursor-based pagination uses a unique identifier to mark position in the result set.

Handler with cursor pagination:

When to use: Real-time feeds, infinite scroll, large datasets

Benefits:

  • ✅ Consistent performance regardless of depth

  • ✅ No duplicates/skips when data changes

  • ✅ Efficient for large datasets

Trade-offs:

  • ❌ Cannot jump to arbitrary pages

  • ❌ No total count

  • ❌ More complex to implement

Projection Patterns

Pattern: Simple Projection

Use Case: Return only a subset of entity properties

When to use: Optimize queries by selecting only needed fields, hide sensitive data.

Pattern: Complex Projection with Joins

Use Case: Aggregate data from multiple related entities

When to use: Denormalized views that combine data from multiple entities.

Pattern: Calculated Fields

Use Case: Include computed values in query results

Calculated fields can be computed in the query projection (database) or in the handler code (application).

Database-computed fields (preferred for performance):

Application-computed fields:

Collection and Aggregation Patterns

Pattern: Small Collection (Get All)

Use Case: Retrieve entire small collection that can be cached

When to use: Small, relatively static lookup tables (< 100 items), often cached.

Pattern: Count Query

Use Case: Get count of items matching criteria

When to use: Dashboard metrics, badge counts, pagination totals.

Pattern: Summary/Statistics

Use Case: Aggregate calculations (sum, average, min, max)

When to use: Reports, dashboards, analytics.

Entity Framework Core Integration

Pattern: AsNoTracking for Read-Only Queries

Always use AsNoTracking() for query handlers. Since queries don't modify data, change tracking is unnecessary overhead.

Performance benefits:

  • Reduced memory usage

  • Faster query execution

  • No overhead for tracking entity state

Use Include() and ThenInclude() to load related entities in a single query, avoiding N+1 query problems.

When to use: When you need related data and want to avoid multiple database round trips.

Alternative: Projection without Include:

Pattern: Scoped Lifetime for EF Core

Critical: When using Entity Framework Core, configure the Query Processor with scoped lifetime to match the DbContext lifetime:

For more details, see Darker Basic Configuration.

Pattern: Compiled Queries

Use compiled queries for frequently executed queries to improve performance by caching the query translation.

When to use: Hot-path queries executed frequently (thousands of times per second).

Trade-offs:

  • ✅ Faster query execution (cached translation)

  • ❌ More complex code

  • ❌ Only beneficial for high-frequency queries

Performance Best Practices

Pattern: Select Only What You Need

Always project to DTOs rather than loading full entities:

Pattern: Avoid N+1 Queries

N+1 problem: Loading a collection, then querying related data for each item.

Pattern: Use Async All the Way

Always use async methods for I/O operations:

Real-World Example: Product Catalog Query

Here's a complete, production-ready example combining multiple patterns:

Usage in controller:

Best Practices Summary

  1. Use pagination for any query that could return more than 100 items

  2. Project to DTOs using Select() - don't return domain entities

  3. Always use AsNoTracking() for read-only queries

  4. Use Include() wisely to avoid N+1 queries, but prefer projection when possible

  5. Cache appropriately - small, static lookup data is a good candidate

  6. Handle nulls explicitly - use nullable reference types (CustomerDto?)

  7. Use CancellationToken - pass it through to all async operations

  8. Validate query parameters in the query constructor

  9. Use compiled queries for hot-path queries

  10. Consider read replicas for scaling read-heavy workloads

Common Pitfalls

  1. Loading entire collections without pagination - Always paginate large result sets

  2. Forgetting AsNoTracking() - Wastes memory and CPU for read-only queries

  3. N+1 query problems - Use Include() or projections to avoid multiple round trips

  4. Over-fetching data - Select only the fields you need

  5. Under-fetching (multiple queries) - Use joins/includes to get related data in one query

  6. Not using CancellationToken - Prevents graceful cancellation of long-running queries

  7. Returning domain entities - Always project to DTOs for the query side

  8. Caching too aggressively - Consider staleness tolerance and cache invalidation

  9. Not optimizing database indexes - Ensure indexes exist for filter/sort columns

  10. Ignoring query performance - Monitor slow queries and optimize hot paths

Further Reading

Last updated

Was this helpful?