Generic Queries¶
See also: Generic Query Pattern for the abstract design and architectural rationale.
Overview¶
LBS.Augment.Query.GenericQueries provides two abstract base records that cut the boilerplate for the two most common read-model query shapes. They are not generic types — instead each carries a ContractTypeName string and concrete queries subclass them:
GetAllQuery- abstractrecordfor retrieving records with pagination and ordering.GetByIdQuery- abstractrecordfor retrieving a single record byId.
A concrete query is a record that inherits from one of these and sets ContractTypeName. Its matching handler inherits from GetAllQueryHandler<TQuery, TContract> or GetByIdQueryHandler<TQuery, TContract> and supplies the actual Marten call.
Features¶
- Pagination:
Skip/Takeare properties on the query object (Takedefaults to 50 when null). - Ordering: an
OrderBystring property is carried onGetAllQuery; how (and whether) it is applied is up to the handler'sQueryDatabaseAsync. - Contract-level security: handlers call
SecurityValidator.ValidateContractAccessusing the[RequiresRoles]attribute on the contract type. - Lightweight sessions: handlers use
store.LightweightSession()for read-only access. - Result transformation: handlers may override
TransformResults/TransformResultto strip sensitive fields or enrich results. - Caching:
GetByIdQueryis cacheable (ISecureCacheableQuery);GetAllQueryis secured but not cached (ISecureQuery). See Caching.
Defining a query¶
GetAll¶
A GetAll query subclasses GetAllQuery, carries [RequiresRoles(...)] plus [DataContract(...)], and sets ContractTypeName. The base already provides Skip, Take, and OrderBy; add any extra filter properties you need.
// src/Domain/LBS.Domain.Sport/Competition/Queries/GetAllCompetitionsQuery.cs
[RequiresRoles(RoleDefinition.Member)]
[DataContract(Name = "GetAllCompetitions", Namespace = LbsNamespace.SportQueries)]
public record GetAllCompetitionsQuery : GetAllQuery
{
public override string ContractTypeName { get; init; } = "Competition";
[DataMember]
public string? Search { get; init; }
[DataMember]
public string? Sport { get; init; }
}
The matching handler inherits GetAllQueryHandler<TQuery, TContract> and implements QueryDatabaseAsync, which receives the resolved skip / take and the query object:
// src/Domain/LBS.Domain.Sport/Competition/Handler/GetAllCompetitionsQueryHandler.cs
[RequiresModule(ModuleDefinition.SportCore)]
public class GetAllCompetitionsQueryHandler : GetAllQueryHandler<GetAllCompetitionsQuery, CompetitionContract>
{
public GetAllCompetitionsQueryHandler(IDocumentStore store, ILogger<GetAllCompetitionsQueryHandler> logger, IProviderMappingEnricher providerMappingEnricher)
: base(store, logger)
{
this.providerMappingEnricher = providerMappingEnricher;
}
protected override async Task<(List<CompetitionContract> Results, int TotalCount)> QueryDatabaseAsync(
IQuerySession session,
GetAllCompetitionsQuery query,
int skip,
int take)
{
IQueryable<CompetitionContract> baseQuery = session.Query<CompetitionContract>();
if (!string.IsNullOrWhiteSpace(query.Search))
{
// apply search filters ...
}
var totalCount = await baseQuery.CountAsync();
var results = await baseQuery
.OrderBy(c => c.Name)
.Skip(skip)
.Take(take)
.ToListAsync();
return (results.ToList(), totalCount);
}
}
GetById¶
A GetById query subclasses GetByIdQuery. The base supplies the Id property, so most concrete queries only set ContractTypeName:
// src/Domain/LBS.Domain.Sport/Team/Queries/GetTeamByIdQuery.cs
[RequiresRoles(RoleDefinition.Member)]
[DataContract(Name = "GetTeamById", Namespace = LbsNamespace.SportQueries)]
public record GetTeamByIdQuery : GetByIdQuery
{
public override string ContractTypeName { get; init; } = "Team";
}
The handler inherits GetByIdQueryHandler<TQuery, TContract> and implements LoadFromDatabaseAsync:
// src/Domain/LBS.Domain.Sport/Team/Handler/GetTeamByIdQueryHandler.cs
[RequiresModule(ModuleDefinition.SportCore)]
public class GetTeamByIdQueryHandler : GetByIdQueryHandler<GetTeamByIdQuery, TeamContract>
{
public GetTeamByIdQueryHandler(IDocumentStore store, ILogger<GetTeamByIdQueryHandler> logger, IProviderMappingEnricher providerMappingEnricher)
: base(store, logger)
{
this.providerMappingEnricher = providerMappingEnricher;
}
protected override async Task<TeamContract?> LoadFromDatabaseAsync(IQuerySession session, GetTeamByIdQuery query)
{
return await session.LoadAsync<TeamContract>(query.Id);
}
}
Registration¶
Register the handler in the owning feature's GetQueryHandlerTypes(), e.g. in SportDomainCoreFeature:
public Type[] GetQueryHandlerTypes()
{
return new[]
{
typeof(GetAllCompetitionsQueryHandler),
typeof(GetCompetitionByIdQueryHandler),
typeof(GetAllTeamsQueryHandler),
typeof(GetTeamByIdQueryHandler),
// ...
};
}
Usage¶
Invocation¶
There is no ExecuteAsync(query, skip, take) overload. Pagination and ordering are properties of the query object, and execution goes through IQueryService.ExecuteQueryAsync(ISearchQuery query):
// Pagination and ordering live on the query
var query = new GetAllCompetitionsQuery { Skip = 0, Take = 50, OrderBy = "name" };
var (results, totalCount) = await this.queryService.ExecuteQueryAsync(query);
var competitions = results.OfType<CompetitionContract>();
// GetById — Id is a property on the query
var query = new GetTeamByIdQuery { Id = teamId };
var (results, _) = await this.queryService.ExecuteQueryAsync(query);
var team = results.OfType<TeamContract>().FirstOrDefault();
IQueryService is decorated by SecurityQueryService (role enforcement) and CachedQueryService (caching), so calling ExecuteQueryAsync gives you security and caching for free.
HTTP / SDK¶
Concrete queries are dispatched over HTTP by the generic ReadModelEndpoint (POST /api/readmodel). The request body carries the query plus top-level Skip, Take, and OrderBy; for a GetAllQuery the endpoint copies those onto the query object (only when the query has not already set them) before calling ExecuteQueryAsync. The generated SDK builds the same payload, so a typed client call resolves to a single POST against /api/readmodel.
Pagination¶
// Page 2 (records 50-99)
var query = new GetAllTeamsQuery { Skip = 50, Take = 50 };
var (results, totalCount) = await this.queryService.ExecuteQueryAsync(query);
// Take defaults to 50 in the handler when null
Caching¶
Caching is applied by CachedQueryService, which only caches queries implementing ICacheableQuery and isolates secure-query results per user.
GetById (cacheable)¶
GetByIdQuery implements ISecureCacheableQuery, so its results are cached:
- Cache key:
GetById_{ContractTypeName}_{Id}(fromGetByIdQuery.CacheKey) - Per-user isolation:
CachedQueryServiceappends::user:{userId}(or::user:anonymous) to the key for secure queries - Cache duration:
DefaultCacheDuration= 1 hour (overridable per query) - Cache tags:
[ContractTypeName.ToLowerInvariant(), Id.ToString()]
// Invalidate one record, or every record of a type, by tag
await cache.RemoveByTagAsync(teamId.ToString());
await cache.RemoveByTagAsync("team");
GetAll (not cached)¶
GetAllQuery implements ISecureQuery only — it is not an ICacheableQuery, so CachedQueryService executes it directly without caching. The base record still exposes a CacheKey property (default GetAll_{ContractTypeName}_Order:{OrderBy}_Skip:{Skip}_Take:{Take}), and concrete GetAll queries override it to fold in their extra filter parameters, but that key is only consulted if a query opts in to ICacheableQuery.
Validation and security¶
- Contract roles: access is gated by the
[RequiresRoles]attribute on the contract type; handlers enforce it viaSecurityValidator.ValidateContractAccess. - Empty GUID:
GetByIdQueryHandlerreturns an empty result set (TotalCount0) whenIdisGuid.Empty. - Not found:
GetByIdQueryHandlerreturns an empty result set when the record (or a transformed-to-null result) does not exist;TotalCountis1when found. - Authentication: both bases derive from
ISecureQuery.ReadModelEndpointreturns empty results (not an error) for secure queries from unauthenticated callers.
Result transformation¶
Override the transform hook to filter sensitive fields or enrich results. For GetAll, override TransformResults; for GetById, override TransformResult (return null to hide a record):
protected override async Task<TeamContract?> TransformResult(
TeamContract result,
GetTeamByIdQuery query,
ClaimsPrincipal? currentUser)
{
result.ProviderMappings = await this.providerMappingEnricher.GetProviderMappingsAsync(result.Id);
return result;
}
Performance notes¶
- Lightweight session: read-only sessions that don't track changes.
- Direct Marten calls:
GetByIdusesLoadAsync<T>(id);GetAllbuilds anIQueryable<T>and appliesCount, ordering,Skip, andTake. - Always paginate: rely on
Skip/Takerather than loading whole tables. - Caching: only
GetByIdbenefits from the cache; designGetAllhandlers to be efficient on every call.
Best practices¶
- Filter sensitive data in
TransformResults/TransformResultfor any contract holding secrets or PII. - Set sensible
Takevalues on everyGetAllcall. - Keep handlers thin — push complex business logic into domain services.
- Register every handler in the owning feature's
GetQueryHandlerTypes().
See also¶
- Generic Query Pattern - abstract design and rationale.
- Common Tasks: adding a query or command - end-to-end query/command workflow.