Skip to content

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 - abstract record for retrieving records with pagination and ordering.
  • GetByIdQuery - abstract record for retrieving a single record by Id.

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 / Take are properties on the query object (Take defaults to 50 when null).
  • Ordering: an OrderBy string property is carried on GetAllQuery; how (and whether) it is applied is up to the handler's QueryDatabaseAsync.
  • Contract-level security: handlers call SecurityValidator.ValidateContractAccess using the [RequiresRoles] attribute on the contract type.
  • Lightweight sessions: handlers use store.LightweightSession() for read-only access.
  • Result transformation: handlers may override TransformResults / TransformResult to strip sensitive fields or enrich results.
  • Caching: GetByIdQuery is cacheable (ISecureCacheableQuery); GetAllQuery is 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} (from GetByIdQuery.CacheKey)
  • Per-user isolation: CachedQueryService appends ::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 via SecurityValidator.ValidateContractAccess.
  • Empty GUID: GetByIdQueryHandler returns an empty result set (TotalCount 0) when Id is Guid.Empty.
  • Not found: GetByIdQueryHandler returns an empty result set when the record (or a transformed-to-null result) does not exist; TotalCount is 1 when found.
  • Authentication: both bases derive from ISecureQuery. ReadModelEndpoint returns 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

  1. Lightweight session: read-only sessions that don't track changes.
  2. Direct Marten calls: GetById uses LoadAsync<T>(id); GetAll builds an IQueryable<T> and applies Count, ordering, Skip, and Take.
  3. Always paginate: rely on Skip / Take rather than loading whole tables.
  4. Caching: only GetById benefits from the cache; design GetAll handlers to be efficient on every call.

Best practices

  1. Filter sensitive data in TransformResults / TransformResult for any contract holding secrets or PII.
  2. Set sensible Take values on every GetAll call.
  3. Keep handlers thin — push complex business logic into domain services.
  4. Register every handler in the owning feature's GetQueryHandlerTypes().

See also