Every generator-driven CRUD slice gets a GET / list route for free. It is a single, uniform query
surface — pagination, multi-field filtering, free-text search, and ordering — driven entirely by the
query string, with no per-feature code to write. This page is the full contract for that route.
This is the automated path. Hand-written slices (
ManualSample/PurchaseOrder) instead expose a bespokeListPurchaseOrdersQuerywith its own hand-picked parameters — see Querying and Specifications. The two do not share a contract; everything below applies only to routes mapped through the generator.
The route is MapGetList<TEntity, TKey, TModel>(), part of the DKNet.AspCore.Extensions NuGet
package (version pinned in src/Directory.Packages.props). You never call it directly in this
template. The DKNet.SlimBus.Generators source generator emits the call for you inside the generated
Map<Entity>Crud() extension whenever an entity carries [CrudCreate]/[CrudUpdate].
Worked instance — the automated Product sample:
// src/ApiEndpoints/Minimal.Api/ApiEndpoints/AutomatedSample/ProductV1Endpoint.cs
public void Map(RouteGroupBuilder group)
{
// generated → group.MapGetList<Product, Guid, ProductDto>(); its products.read scope comes from
// the class-level [EndpointGroupScope(ProductScopes.Read, EndpointHttpMethods.Get)] declaration
group.MapProductCrud();
}
So GET /v1/products is a fully capable list endpoint even though no list handler, validator, or
query object was hand-written anywhere in the slice.
Signature (from the package):
public RouteHandlerBuilder MapGetList<TEntity, TKey, TModel>(string endpoint = "/")
where TEntity : class, IEntity<TKey>
where TKey : IEquatable<TKey>
where TModel : class;
// Guid-key shorthand
public RouteHandlerBuilder MapGetList<TEntity, TModel>(string endpoint = "/")
where TEntity : class, IEntity<Guid>
where TModel : class;
TModel is the projected DTO (ProductDto for the sample). It is central to the whole contract:
you can only filter, search, and order by fields that exist on the DTO, never on the raw entity.
See The DTO is the boundary below.
| Parameter | Type | Default | Notes |
|---|---|---|---|
pageNumber |
int |
1 |
1-based. A value < 1 is silently clamped to 1. |
pageSize |
int |
1000 |
A value < 1 falls back to 1000. Max 1000 — larger is clamped down, not rejected. Both figures are host-configurable — see Configuring the defaults. |
filter |
repeatable | none | field:operation:value. Repeat the param to AND multiple conditions. Max 20. |
search |
string |
none | Free-text OR across all string DTO fields. Min 2 characters. |
orderBy |
string |
none | A single DTO field name to sort by. |
desc |
bool |
false |
Reverses the orderBy direction. |
fromDate |
ISO-8601 | none | Inclusive lower bound on when a record was last active — see Recent-activity window. |
toDate |
ISO-8601 | none | Inclusive upper bound on when a record was last active. Omitting both bounds does not mean “all history” — see the same section. |
Paging is always applied. Filter, search, and order are each optional and independent; when present
they are ANDed together (search is one OR-group, then ANDed with the filter predicate). Over records
that carry audit timestamps a recent-activity window is also always applied — the fromDate/toDate
you named, or a default one if you named neither — and it is ANDed with everything else.
Each filter value is a colon-delimited triple parsed into a ListFilter(Field, Operation, Value).
Repeat the parameter to combine conditions with AND:
GET /v1/products?filter=Price:GreaterThan:100&filter=IsDiscontinued:Equal:false
| Operation | Value form | Meaning |
|---|---|---|
Equal |
scalar | field == value |
NotEqual |
scalar | field != value |
GreaterThan |
scalar | field > value |
GreaterThanOrEqual |
scalar | field >= value |
LessThan |
scalar | field < value |
LessThanOrEqual |
scalar | field <= value |
Contains |
string | field.Contains(value) |
NotContains |
string | !field.Contains(value) |
StartsWith |
string | field.StartsWith(value) |
EndsWith |
string | field.EndsWith(value) |
In |
comma-separated | value.Contains(field) — any of the listed values |
NotIn |
comma-separated | none of the listed values |
IsNull |
(omit value) | field == null — two-part form field:IsNull |
IsNotNull |
(omit value) | field != null — two-part form field:IsNotNull |
In / NotIn take a comma-separated list; each element is coerced to the property’s CLR type.
Example: filter=Status:In:Active,Pending.IsNull / IsNotNull use the two-segment form with no value: filter=UpdatedOn:IsNull.Value is coerced to the DTO property’s CLR type (int, decimal, bool, Guid, DateTime, …).
A value that cannot be coerced is a 400, not a silent no-op.Field is normalised to PascalCase, so unit_price, unit-price, and UnitPrice all resolve to
the same DTO property, matched case-insensitively. The resolved name must be a public property
on TModel. If it is not, the request fails with 400 Bad Request — unknown fields are never
silently dropped, because dropping a condition would answer a filtered query with unfiltered data.
Only the first two colons split the triple, so a value may contain colons of its own — an
ISO-8601 timestamp being the case that matters:
filter=CreatedOn:GreaterThan:2026-01-31T00:00:00Z.
400.orderBy names one DTO field (PascalCased, same rules as filter fields; it must exist on both the DTO
and the entity). desc=true sorts descending; omitted or false sorts ascending.
GET /v1/products?orderBy=Price&desc=true
Id is appended as a descending tie-breaker so paging is deterministic — unless you
already ordered by Id.orderBy given):
IAuditedEntity<TKey>, which every AggregateRoot here is) → CreatedOn
descending, then Id descending. Newest first.Id descending only.orderBy field is a 400, not a silent fallback to the default.search is a single free-text term matched with Contains across every string property of the
DTO, OR’d together, then ANDed with any filter/default predicate:
GET /v1/products?search=widget
TModel. As with filter and orderBy, the DTO is the
boundary — a column it does not expose is never searched.MaxDepth = 2 in ModelSearch), so Name and
Merchant.Name are searched but Merchant.Address.City is not. A collection member is wrapped in
Any(...); dictionaries and byte[] are never descended into or searched.LIKE '%…%'), not prefix match. Each clause is emitted as
Field != null && Field.Contains(term) — the null guard matters for a provider that evaluates in
memory.search is a 400. Blank or
omitted is treated as absent.search matches nothing (an empty page, not an error).For ProductDto the string fields are Name plus the mapped audit columns CreatedBy / UpdatedBy,
so a search hits any of those. (Every searched field must map to a real column — see
the trap below.)
fromDate / toDate)fromDate and toDate are inclusive ISO-8601 bounds on when a record was last active. A record is
in range when either the moment it was created or the moment it was last updated falls inside the
bounds — so a record never updated since creation is matched on its creation moment alone, and is never
dropped for lacking an update.
GET /v1/products?fromDate=2026-06-01T00:00:00Z&toDate=2026-06-30T23:59:59Z
?fromDate=0001-01-01T00:00:00Z the documented way to ask for all history.fromDate later than toDate is a 400. An impossible window is a caller mistake, not an empty
page; it is the one date-bound error.TotalItemCount to match, it
combines with filter/search by AND, and it does not affect ordering.The automated sample’s Product is an AggregateRoot and therefore audited
(src/ApiEndpoints/Minimal.Domains/Features/AutomatedSample/Entities/Product.cs), so a bare
GET /v1/products is subject to the default window: the last three months of product activity, not
every product ever created.
The page size and the window length are host settings on ListQueryOptions, bound from the
DKNet:ListQuery configuration section:
| Option | Config key | Type | Default | Notes |
|---|---|---|---|---|
DefaultPageSize |
DKNet:ListQuery:DefaultPageSize |
int |
1000 |
Used when pageSize is omitted or < 1. |
MaxPageSize |
DKNet:ListQuery:MaxPageSize |
int |
1000 |
Ceiling an explicit pageSize is clamped to. It also caps the default, so an unspecified request is served the lower of the two. |
DefaultActivityWindowMonths |
DKNet:ListQuery:DefaultActivityWindowMonths |
int |
3 |
Length of the default activity window. Minimum 0; 0 switches the default window off and leaves a bare listing unbounded in time. |
A service that configures its own values keeps them — the figures above are only the built-in fallbacks used when a service configures nothing.
The route returns 200 OK with a PagedResponse<TModel>:
public sealed record PagedResponse<TResult>
{
public IList<TResult> Items { get; init; } = [];
public int PageCount { get; init; }
public int PageNumber { get; init; }
public int PageSize { get; init; }
public int TotalItemCount { get; init; }
public bool HasNextPage { get; init; }
public bool HasPreviousPage { get; init; }
}
Items holds the projected DTOs for the current page; the rest is paging metadata. It is built from
X.PagedList via the repository’s ToPagedListAsync(...), so TotalItemCount is the full unpaged
count.
Every malformed input is a 400 Bad Request with a reason (via Results.Problem), never a
silently-ignored parameter:
search shorter than 2 characters,fromDate later than the toDate — an impossible activity window.Out-of-range paging is the one exception: pageNumber < 1 and pageSize outside 1..1000 are
clamped, not rejected. The ceiling is MaxPageSize — see
Configuring the defaults.
Filter, search, and order fields are resolved against TModel (the returned DTO), never the raw
entity. This is a deliberate security boundary: a column the DTO doesn’t expose cannot be sorted on,
filtered on, or searched — no hidden column leaks through the query surface. Widen or narrow the query
surface by changing what the DTO exposes ([GenerateDto(... Exclude/Include ...)]), not the endpoint.
The Product sample DTO is declared as:
[GenerateDto(typeof(Product),
Exclude = [nameof(Product.OwnedBy), nameof(AuditedEntity<Guid>.LastModifiedBy), nameof(AuditedEntity<Guid>.LastModifiedOn)])]
public sealed partial record ProductDto
{
[SensitiveData("pricing")]
public decimal? GrossMargin { get; init; }
}
OwnedBy is excluded, so it is unqueryable through this route by construction — you cannot filter or
sort products by their ownership key over HTTP, even though the column exists on the entity.
GrossMargin is the opposite case: a property written by hand onto the generated partial record
and mapped from two columns (Price minus SupplierCostPrice) by a Mapster IRegister, so it is on
the response but has no entity counterpart of its own. A derived DTO field cannot be filtered,
searched or ordered on, and asking for one is refused rather than attempted —
?orderBy=grossMargin and ?filter=grossMargin:GreaterThan:0 each answer 400 naming the field,
alongside the other unsupported-field errors listed above
(Minimal.App.Tests/Integration/AutomatedSample/V1/ProductGrossMarginTests.cs:181-210). The rest of
the query surface is unaffected: filtering and searching on the convention-mapped fields behaves as
before, with the margin reported on each item that survives. How the property is put on the response:
the automated sample.
Because filter/search/order build EF predicates against the entity by property name, every
queryable DTO field has to resolve to a mapped entity column. A DTO property whose entity counterpart
is computed or [NotMapped] makes the whole query fail to translate — a 500, not a 400, and it
fires the moment such a field is touched (search touches every string field, so it breaks on the
first search).
This is the narrower case, and it is why it bites: the field has to be declared on the entity but
not mapped to a column. A DTO field with no entity counterpart at all — GrossMargin above — is
caught by the field check and refused with 400. One that the entity declares and EF does not map
passes that check and fails later, in the database provider.
This is exactly why LastModifiedBy / LastModifiedOn are excluded above. On AuditedEntity<TKey>
they are computed conveniences (“the updated value, or the created one if never modified”), not columns
— [GenerateDto] would otherwise surface them and every ?search= would 500. The mapped
UpdatedBy / UpdatedOn stay and cover the same intent. When you point [GenerateDto] at an entity
with computed or unmapped members, Exclude them or the free list route inherits a latent 500.
GET /v1/products
?search=widget
&filter=Price:GreaterThanOrEqual:100
&filter=IsDiscontinued:Equal:false
&orderBy=Price
&desc=true
&fromDate=2026-06-01T00:00:00Z
&pageNumber=2
&pageSize=50
Reads as: products whose Name/CreatedBy/UpdatedBy contains “widget”, priced at 100 or more, not
discontinued, last active on or after 1 June 2026 (with no upper bound, because toDate was omitted),
sorted by price descending (with Id as tie-break), returning the second page of 50.
Drop the fromDate line and the listing falls back to the default three-month window rather than to all
history; to get all history, name fromDate=0001-01-01T00:00:00Z instead.