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
GetAllQueryHandlerTransformResults() 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
GetByIdQueryHandlerTransformResult() 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¶
- Minimal Boilerplate: Most query handlers are just 10-15 lines of code
- Security by Default: Transform methods provide a clear place to filter sensitive data
- Type Safety: Compile-time type checking for contract types
- Consistent Patterns: All GetAll/GetById queries follow the same structure
- Flexible: Can override transform methods for complex filtering logic
Data Filtering Strategies¶
Remove Sensitive Fields¶
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:
- Move your query from deriving from a class to deriving from
GetAllQueryorGetByIdQuery - Override
ContractTypeNameto return the DataContract name - Change your handler to extend
GetAllQueryHandler<TQuery, TContract>orGetByIdQueryHandler<TQuery, TContract> - If you need to filter sensitive data, override
TransformResults()orTransformResult() - Remove all the reflection and type resolution code - it's now in the base class
Best Practices¶
- Always filter sensitive data: Override transform methods for any contract containing passwords, tokens, secrets, or PII
- Use record with syntax: The
withkeyword makes it easy to create modified copies - Consider role-based filtering: Different users may need different views of the same data
- Log transform decisions: If filtering records entirely, log why for audit purposes
- 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());
}