.agents/skills/query-patterns/SKILL.md
The dominant pattern is raw IQueryable on the module DbContext (AsNoTracking) with manual
pagination. There is no generic repository and no PaginatedListAsync/EntitiesByPaginationFilterSpec/
PaginationFilter. Paged results are PagedResponse<T> (FSH.Framework.Shared.Persistence) — there is no PagedList<T>.
// Contracts/v1/{Area}/
public sealed record Search{Entities}Query(
string? Search = null,
bool? IsActive = null,
int PageNumber = 1,
int PageSize = 20,
string? SortBy = null,
string? SortDir = null) : IQuery<PagedResponse<{Entity}Dto>>;
// Features/v1/{Area}/Search{Entities}/
public sealed class Search{Entities}QueryHandler({X}DbContext dbContext)
: IQueryHandler<Search{Entities}Query, PagedResponse<{Entity}Dto>>
{
public async ValueTask<PagedResponse<{Entity}Dto>> Handle(
Search{Entities}Query query, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(query);
int page = query.PageNumber < 1 ? 1 : query.PageNumber;
int size = Math.Clamp(query.PageSize, 1, 100);
var q = dbContext.{Entities}.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(query.Search))
q = q.Where(x => EF.Functions.ILike(x.Name, $"%{query.Search}%"));
if (query.IsActive is { } active)
q = q.Where(x => x.IsActive == active);
q = ApplySort(q, query.SortBy, query.SortDir);
long total = await q.LongCountAsync(cancellationToken).ConfigureAwait(false);
var items = await q.Skip((page - 1) * size).Take(size)
.ToListAsync(cancellationToken).ConfigureAwait(false);
return new PagedResponse<{Entity}Dto>
{
Items = items.Select(x => x.ToDto()).ToList(),
PageNumber = page, PageSize = size,
TotalCount = total,
TotalPages = (int)Math.Ceiling(total / (double)size)
};
}
private static IQueryable<{Entity}> ApplySort(IQueryable<{Entity}> q, string? by, string? dir)
{
bool desc = string.Equals(dir, "desc", StringComparison.OrdinalIgnoreCase);
return by?.ToLowerInvariant() switch
{
"name" => desc ? q.OrderByDescending(x => x.Name) : q.OrderBy(x => x.Name),
_ => q.OrderByDescending(x => x.CreatedOnUtc)
};
}
}
Tenant + soft-delete filters apply automatically — don't re-filter them. Project to a DTO (.ToDto() mapper); never return entities.
public sealed record Get{Entity}Query(Guid Id) : IQuery<{Entity}Dto>;
public sealed class Get{Entity}QueryHandler({X}DbContext dbContext)
: IQueryHandler<Get{Entity}Query, {Entity}Dto>
{
public async ValueTask<{Entity}Dto> Handle(Get{Entity}Query query, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(query);
var entity = await dbContext.{Entities}.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == query.Id, cancellationToken).ConfigureAwait(false)
?? throw new NotFoundException($"{Entity} {query.Id} not found");
return entity.ToDto();
}
}
// list — bind the query with [AsParameters]
endpoints.MapGet("/{entities}", async ([AsParameters] Search{Entities}Query query,
IMediator mediator, CancellationToken ct) => Results.Ok(await mediator.Send(query, ct)))
.WithName("Search{Entities}").RequirePermission({X}Permissions.{Entities}.View);
// single
endpoints.MapGet("/{entities}/{id:guid}", async (Guid id,
IMediator mediator, CancellationToken ct) => Results.Ok(await mediator.Send(new Get{Entity}Query(id), ct)))
.WithName("Get{Entity}").RequirePermission({X}Permissions.{Entities}.View);
A paginated query needs a validator (Search{Entities}QueryValidator: PageNumber >= 1, PageSize in [1,100]) — enforced by Architecture.Tests.
Specification<T> (src/BuildingBlocks/Persistence/Specifications/) is for composing reusable query
shapes (protected Where(...)/Include(...)/OrderBy(...) in a derived spec's ctor; AsNoTracking
defaults true; specs never paginate). Reach for it when the same filter/include set is shared across
handlers; otherwise inline LINQ is the norm here.