Skip to content

Generic Query Pattern

See also: Generic Queries (Developer Guide) for hands-on usage examples and feature reference.

Overview

The generic query pattern provides abstract base classes for implementing common query operations (GetAll, GetById) while allowing derived classes to: 1. Specify the contract type they work with 2. Transform results to filter sensitive data 3. Customize caching behavior

This pattern significantly reduces boilerplate code and provides a consistent way to handle sensitive data filtering.

Architecture

Base Classes (in LBS.Augment.Query.GenericQueries)

GetAllQuery - Abstract record for queries that retrieve all records - Implements ISecureCacheableQuery and IRequireUserContext - Derived records override ContractTypeName to specify which contract to query

GetAllQueryHandler - Abstract handler for GetAll queries - Handles runtime type resolution and reflection-based query execution - Derived classes override TransformResults() to filter sensitive data

GetByIdQuery - Abstract record for queries that retrieve a single record by ID - Implements ISecureCacheableQuery and IRequireUserContext - Derived records override ContractTypeName to specify which contract to query

GetByIdQueryHandler - Abstract handler for GetById queries - Handles runtime type resolution and reflection-based query execution - Derived classes override TransformResult() to filter sensitive data

Usage Examples

Simple Example (No Data Filtering)

For contracts without sensitive data, creating queries is extremely simple:

// Query
[DataContract(Name = "GetAllTeams")]
public record GetAllTeamsQuery : GetAllQuery
{
    public override string ContractTypeName => "Team";
}

// Handler
[RequiresModule(ModuleDefinition.Sport)]
public class GetAllTeamsQueryHandler : GetAllQueryHandler<GetAllTeamsQuery, TeamContract>
{
    public GetAllTeamsQueryHandler(IDocumentStore store, ILogger<GetAllTeamsQueryHandler> logger)
        : base(store, logger)
    {
    }

    // No TransformResults override needed - returns data as-is
}

Advanced Example (With Data Filtering)

For contracts with sensitive data (like User with password hash), override the transform method:

// Query
[DataContract(Name = "GetAllUsers")]
public record GetAllUsersQuery : GetAllQuery
{
    public override string ContractTypeName => "User";
}

// Handler
[RequiresModule(ModuleDefinition.Core)]
public class GetAllUsersQueryHandler : GetAllQueryHandler<GetAllUsersQuery, UserContract>
{
    public GetAllUsersQueryHandler(IDocumentStore store, ILogger<GetAllUsersQueryHandler> logger)
        : base(store, logger)
    {
    }

    protected override Task<IEnumerable<UserContract>> TransformResults(
        IEnumerable<UserContract> results,
        GetAllUsersQuery query,
        ClaimsPrincipal? currentUser)
    {
        // Remove sensitive data like password hashes
        var sanitized = results.Select(user => user with
        {
            PasswordHash = null!  // Never send to client
        });

        return Task.FromResult(sanitized);
    }
}

GetById Example

// Query
[DataContract(Name = "GetUserById")]
public record GetUserByIdQuery : GetByIdQuery
{
    public override string ContractTypeName => "User";
}

// Handler
[RequiresModule(ModuleDefinition.Core)]
public class GetUserByIdQueryHandler : GetByIdQueryHandler<GetUserByIdQuery, UserContract>
{
    public GetUserByIdQueryHandler(IDocumentStore store, ILogger<GetUserByIdQueryHandler> logger)
        : base(store, logger)
    {
    }

    protected override Task<UserContract?> TransformResult(
        UserContract result,
        GetUserByIdQuery query,
        ClaimsPrincipal? currentUser)
    {
        // Remove sensitive data
        var sanitized = result with
        {
            PasswordHash = null!
        };

        return Task.FromResult<UserContract?>(sanitized);
    }
}

Benefits

  1. Minimal Boilerplate: Most query handlers are just 10-15 lines of code
  2. Security by Default: Transform methods provide a clear place to filter sensitive data
  3. Type Safety: Compile-time type checking for contract types
  4. Consistent Patterns: All GetAll/GetById queries follow the same structure
  5. Flexible: Can override transform methods for complex filtering logic

Data Filtering Strategies

Remove Sensitive Fields

var sanitized = results.Select(user => user with
{
    PasswordHash = null!,
    Secret ApiKey = null!
});

Role-Based Filtering

protected override Task<IEnumerable<UserContract>> TransformResults(
    IEnumerable<UserContract> results,
    GetAllUsersQuery query,
    ClaimsPrincipal? currentUser)
{
    var isAdmin = currentUser?.IsInRole(RoleDefinition.Admin) ?? false;

    var sanitized = results.Select(user => user with
    {
        PasswordHash = null!,
        // Admins can see email, others cannot
        Email = isAdmin ? user.Email : null
    });

    return Task.FromResult(sanitized);
}

Filter Entire Records

protected override Task<UserContract?> TransformResult(
    UserContract result,
    GetUserByIdQuery query,
    ClaimsPrincipal? currentUser)
{
    // Only allow users to see their own data or admins to see all
    var userId = currentUser?.GetUserId();
    var isAdmin = currentUser?.IsInRole(RoleDefinition.Admin) ?? false;

    if (!isAdmin && userId != result.Id.ToString())
    {
        return Task.FromResult<UserContract?>(null);  // Hide record
    }

    return Task.FromResult<UserContract?>(result with
    {
        PasswordHash = null!
    });
}

Implementation Details

Runtime Type Resolution

The base handlers use reflection to: 1. Resolve the contract type from the ContractTypeName string 2. Invoke Marten's generic Query<T>() and LoadAsync<T>() methods 3. Apply LINQ operations (Count, Skip, Take) dynamically 4. Execute ToListAsync and extract strongly-typed results

Security Validation

Security is validated at the contract level using the [RequiresRoles] attribute on the contract type:

[RequiresRoles(RoleDefinition.Member)]
[DataContract(Name = "User")]
public record UserContract : IContract
{
    // ...
}

The base handler automatically validates that the current user has the required roles before executing the query.

Caching

Both base query records implement ISecureCacheableQuery with sensible defaults: - Cache key: GetAll_{ContractTypeName} or GetById_{ContractTypeName}_{Id} - Cache duration: 1 hour (overridable) - Cache tags: Contract type name and ID (for invalidation)

Migration Guide

To migrate existing generic queries to this pattern:

  1. Move your query from deriving from a class to deriving from GetAllQuery or GetByIdQuery
  2. Override ContractTypeName to return the DataContract name
  3. Change your handler to extend GetAllQueryHandler<TQuery, TContract> or GetByIdQueryHandler<TQuery, TContract>
  4. If you need to filter sensitive data, override TransformResults() or TransformResult()
  5. Remove all the reflection and type resolution code - it's now in the base class

Best Practices

  1. Always filter sensitive data: Override transform methods for any contract containing passwords, tokens, secrets, or PII
  2. Use record with syntax: The with keyword makes it easy to create modified copies
  3. Consider role-based filtering: Different users may need different views of the same data
  4. Log transform decisions: If filtering records entirely, log why for audit purposes
  5. Keep transforms simple: Complex business logic should be in domain services, not transforms

Testing

Test your query handlers by: 1. Verifying sensitive fields are null in responses 2. Testing role-based filtering logic 3. Ensuring correct contracts are returned 4. Validating security attributes are enforced

[Fact]
public async Task GetAllUsersQuery_Should_Remove_PasswordHash()
{
    // Arrange
    var handler = new GetAllUsersQueryHandler(store, logger);
    var query = new GetAllUsersQuery();

    // Act
    var (results, _) = await handler.HandleAsync(query, null, 0, 10);

    // Assert
    results.Should().AllSatisfy(user => user.PasswordHash.Should().BeNull());
}