DKNet

DKNet.AspCore.Extensions

The minimal-API glue for DKNet-based web APIs: host-populated request members, discovered and versioned endpoint groups, one-line verb-to-command mappers, generic list/read/delete endpoints, and FluentResults-to-IResult conversion.

✨ Why use it?

DKNet.AspCore.Extensions covers five distinct jobs that show up in almost every DKNet host:

Reach for it whenever you are building minimal-API endpoints on top of DKNet’s SlimBus/CQRS and EF Core Specifications packages — it is what turns a command/query class, or a bare entity, into a routed, versioned, documented HTTP endpoint with almost no repeated code.

🚀 Quick Start

dotnet add package DKNet.AspCore.Extensions

Minimum wiring in Program.cs — AddApiVersioning() is required because UseEndpointConfigs defaults to versioned routes, and AddContextualRequestPopulation() is required the moment any request declares a [FromClaim], [FromRequestHeader] (or other IContextualSource) member:

using DKNet.AspCore.Extensions.Endpoints;   // UseEndpointConfigs
using DKNet.AspCore.Extensions.ModelBinding; // AddContextualRequestPopulation

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthorization();
builder.Services.AddApiVersioning();
builder.Services.AddContextualRequestPopulation();
builder.Services.AddOpenApi(); // optional, needed for the published API description

var app = builder.Build();

app.UseEndpointConfigs();      // discovers every IEndpointConfig across the loaded assemblies
app.MapOpenApi();

app.Run();

Namespaces at a glance

Every sample below assumes the using that owns the type it shows:

Namespace Types
DKNet.AspCore.Extensions IEndpointConfig
DKNet.AspCore.Extensions.ModelBinding FromClaimAttribute, FromRequestHeaderAttribute, IContextualSource, IContextualValueResolver, AddContextualRequestPopulation()
DKNet.AspCore.Extensions.Endpoints EndpointRegistrationOptions, UseEndpointConfigs(), the Map* mappers, ListQueryRequest, ListQueryOptions, AddListQueryOptions(), ListFilter, ListFilterJsonConverter, CrudMapOptions, CrudOp
DKNet.AspCore.Extensions.Responses PagedResponse<T>, ResultResponseExtensions, ProblemDetailsExtensions, ErrorResponseOptions, AddErrorResponses(), ErrorResponseContext, ErrorItem, ErrorSource

🧩 Features

Contextual request binding — [FromClaim], [FromRequestHeader] and IContextualSource

A request DTO often needs a value the caller must never control — who created it, which tenant it belongs to. IContextualSource marks a property as populated by the host instead of the caller; FromClaimAttribute is the built-in implementation, resolving the property from a named claim on the authenticated user.

FromClaimAttribute has exactly one form — a single required constructor argument, no named properties, AttributeTargets.Property, AllowMultiple = false, Inherited = false. What you write, and what the request instance actually carries by the time your handler sees it:

You declare The handler receives
[FromClaim(ClaimTypes.NameIdentifier)]
public string? CreatedBy { get; set; }
CreatedBy = "8f0c…" — the value of the caller’s nameidentifier claim, whatever the request body said
same, caller authenticated but the claim is absent CreatedBy = null — the property’s type default, never the caller’s value
[FromClaim("tenant_id")]
public Guid TenantId { get; set; }
TenantId = Guid.Parse(claim) via TypeDescriptor conversion; an unconvertible claim yields Guid.Empty, not a 400
[FromClaim("tenant_id")]
public Guid TenantId { get; } (no setter)
Nothing — InvalidOperationException at startup naming '{Type}.{Property}' declares a contextual source but has no setter
using System.Security.Claims;
using DKNet.AspCore.Extensions.ModelBinding;
using DKNet.SlimBus.Extensions;

public sealed record CreateProductCommand : Fluents.Requests.IWitResponse<ProductModel>
{
    public string Name { get; init; } = string.Empty;

    [FromClaim(ClaimTypes.NameIdentifier)]
    public string? CreatedBy { get; set; } // always overwritten — never trust a caller-supplied value here
}

Register the mechanism once:

builder.Services.AddContextualRequestPopulation();

That single call registers ClaimValueResolver and the population service as scoped, and adds the two OpenAPI transformers through ConfigureAll<OpenApiOptions>. Endpoint groups mapped by UseEndpointConfigs then populate declared members before validation and before the handler runs — for both JSON-body binding and [AsParameters]/query binding — and the declared members are removed from the published OpenAPI description (ContextualSourceSchemaTransformer for JSON bodies, ContextualSourceOperationTransformer for query/[AsParameters] parameters), since the caller can never actually supply them — with one exception, the header parameter a [FromRequestHeader] member publishes, described next.

A declared member the registered resolvers cannot resolve always holds its type’s default — there is no built-in substitute value. CanResolve keys on the declaration’s attribute type, not on the individual member and not on whether a value is actually available, and population consults exactly the first resolver whose CanResolve matches — it never chains to a second one. Registering a resolver ahead of AddContextualRequestPopulation() therefore replaces the built-in resolver for that attribute type outright, for every member declaring it — including an authenticated caller who has the real claim. A host that wants a value of its own, for example a system-account identity on an anonymous group, must perform the built-in lookup itself before substituting, or its resolver silently displaces ClaimValueResolver for every [FromClaim] member on every request:

public sealed class SystemAccountValueResolver : IContextualValueResolver
{
    public bool CanResolve(IContextualSource source) => source is FromClaimAttribute;

    public string? Resolve(IContextualSource source, HttpContext httpContext) =>
        httpContext.User.FindFirst(((FromClaimAttribute)source).ClaimType)?.Value
        ?? "system-account"; // only the caller who truly has no claim gets the substitute
}

builder.Services.AddScoped<IContextualValueResolver, SystemAccountValueResolver>();
builder.Services.AddContextualRequestPopulation();

Filling a member from a request header — [FromRequestHeader]

FromRequestHeaderAttribute is the second built-in source, and it takes the same shape as FromClaimAttribute: one required constructor argument — the header name, exposed as HeaderName — on a property that must have a set or init. It needs no registration of its own: the AddContextualRequestPopulation() call that already powers [FromClaim] powers it too, and no route-level code is involved.

using DKNet.AspCore.Extensions.ModelBinding;
using DKNet.SlimBus.Extensions;

public sealed record TransferCommand : Fluents.Requests.IWitResponse<TransferModel>
{
    public decimal Amount { get; init; }

    [FromRequestHeader("Idempotency-Key")]
    public string? IdempotencyKey { get; set; } // filled from the header, never from the body
}
You declare The handler receives
[FromRequestHeader("Idempotency-Key")]
public string? IdempotencyKey { get; set; }, caller sends Idempotency-Key: k-77
IdempotencyKey = "k-77" — whatever the request body said for that member
same, caller sends idempotency-key: k-77 IdempotencyKey = "k-77" — header-name matching is case-insensitive
same, header sent twice (k-77, then k-88) IdempotencyKey = "k-77" — the first value sent, never a joined "k-77,k-88"
same, header absent IdempotencyKey = null — the property’s type default, and the request is still dispatched

Three points worth stating plainly before you build on it:

The published API description also differs from [FromClaim]’s. A claim-filled member is hidden entirely, because the caller can never supply it. A header-filled member can only be supplied by the caller, so the operation declares the header as an in: header parameter — the caller has to know to send it. The member itself is still absent from the published request body, so it is advertised in exactly one place.

A new source kind needs only its own attribute plus a matching resolver — no change to this package. The mechanism dispatches on IContextualValueResolver.CanResolve, never on a concrete attribute type. A plain request header no longer needs one of these — [FromRequestHeader] covers it — so read the example below as the shape any other source kind takes:

public sealed class FromTenantHeaderAttribute : Attribute, IContextualSource;

public sealed class TenantHeaderResolver : IContextualValueResolver
{
    public bool CanResolve(IContextualSource source) => source is FromTenantHeaderAttribute;

    public string? Resolve(IContextualSource source, HttpContext httpContext) =>
        httpContext.Request.Headers["X-Tenant-Id"];
}

// builder.Services.AddScoped<IContextualValueResolver, TenantHeaderResolver>();

Endpoint group discovery and mapping — IEndpointConfig / UseEndpointConfigs

Implement IEndpointConfig per feature area instead of wiring MapGroup calls by hand. Only GroupEndpoint and Map are abstract; the other three members are default interface implementations you override when the default is wrong:

Member Default What it controls
string GroupEndpoint { get; } none — you must supply it Route segment appended after the version prefix, e.g. "/products".
void Map(RouteGroupBuilder group) none — you must supply it Where the group’s endpoints are registered. Runs last, after tags, filters and authorization.
string Tag { get; } GroupEndpoint with / replaced by - and leading - trimmed — so "/products" becomes "products" The OpenAPI tag. Resolving to an empty string falls back to EndpointRegistrationOptions.DefaultTag.
int Version { get; } 1 API version the group is mapped to, and the v{n} in the route and group name.
using DKNet.AspCore.Extensions;

public sealed class ProductsEndpointConfig : IEndpointConfig
{
    public string GroupEndpoint => "/products";          // Tag defaults to "products"
    public int Version => 1;                             // optional; defaults to 1

    public void Map(RouteGroupBuilder group)
    {
        group.MapGetById<Product, ProductModel>("/{id:guid}");
        group.MapGetList<Product, ProductModel>("/");
        group.MapPost<CreateProductCommand, ProductModel>("/");
    }
}

app.UseEndpointConfigs(...) scans the given assemblies (or every loaded assembly by default — so a consuming application’s own IEndpointConfig types are picked up automatically), builds one versioned RouteGroupBuilder per config via Asp.Versioning, tags it, requires authorization by default, and calls Map. It returns the created groups as an IReadOnlyList<RouteGroupBuilder>, or an empty list when nothing was discovered. Every default reproduces the DKNet template’s original hardcoded behaviour — a caller who supplies no options gets that behaviour unchanged:

app.UseEndpointConfigs(o =>
{
    o.EnableVersioning = false;          // drop the "/v{version}" prefix entirely
    o.RequireAuthorization = false;      // explicit host opt-out; the host owns this decision
    o.DefaultTag = "Root";               // used when a config resolves an empty Tag
    o.RouteTemplate = c => $"/api{c.GroupEndpoint}"; // override the generated route pattern
    o.ConfigureGroup = (group, config) =>
        group.AddEndpointFilter(async (ctx, next) => await next(ctx)); // per-group host setup
},
typeof(Program).Assembly);               // optional: restrict the scan to named assemblies

ConfigureGroup is the hook for host-specific setup that used to be built into this package — request validation (e.g. AddFluentValidationAutoValidation()), custom filters, and so on. It always runs after the contextual-population filter and before RequireAuthorization, so population can never be bypassed by a host filter, while real ASP.NET Core authorization middleware still runs ahead of every endpoint filter at request time.

Requiring a scope per HTTP method — [EndpointGroupScope]

[EndpointGroupScope] declares which authorization scope an IEndpointConfig group requires, above the class. It is the only way a package user requires a scope on a group — there is no group-wide policy member; the interface has never grown one back after AuthPolicy was removed (see Migrating off AuthPolicy below).

Two forms, both the same attribute:

A mixed group — a scope-only default plus one or more per-method declarations — is the common shape once you have more than one scope:

using DKNet.AspCore.Extensions;
using DKNet.AspCore.Extensions.Endpoints;

[EndpointGroupScope("accounts.read")]                                        // default: every method
[EndpointGroupScope("accounts.write", EndpointHttpMethods.Post, EndpointHttpMethods.Put, EndpointHttpMethods.Delete)] // wins for these methods
public sealed class AccountsEndpointConfig : IEndpointConfig
{
    public string GroupEndpoint => "/accounts";

    public void Map(RouteGroupBuilder group)
    {
        group.MapGet("/{id:guid}", (Guid id) => Results.Ok());   // needs accounts.read (the default)
        group.MapPost("/", () => Results.Ok());                  // needs accounts.write
        group.MapPut("/{id:guid}", (Guid id) => Results.Ok());   // needs accounts.write
        group.MapDelete("/{id:guid}", (Guid id) => Results.Ok()); // needs accounts.write
    }
}

GET /accounts/{id} falls through to the accounts.read default; POST, PUT and DELETE each have their own declaration, so they need accounts.write instead. A group carrying no [EndpointGroupScope] at all is unchanged — no attribute read, no policy applied beyond the plain RequireAuthorization() every group already gets.

One route inside the group can ask for its own scope instead of the group’s, with RequireAuthorization on that route:

group.MapGet("/{id:guid}/audit-trail", (Guid id) => Results.Ok())
    .RequireAuthorization("postings.read"); // wins over the group's declared "accounts.read"

Or open it to anonymous callers with AllowAnonymous:

group.MapGet("/health", () => Results.Ok())
    .AllowAnonymous(); // no token required, regardless of the group's declared scopes

Precedence when more than one rule could apply, most specific first:

  1. The route’s own RequireAuthorization(...) or AllowAnonymous() — always wins, for that method only.
  2. A per-method [EndpointGroupScope] declaration naming that method.
  3. A scope-only [EndpointGroupScope] declaration on the same group — the default.

A method the group serves but that no per-method declaration, no scope-only default, and no route-level rule covers fails the host at startup — app.StartAsync() throws an InvalidOperationException naming the route pattern and the served, undeclared HTTP method. A group carrying a scope-only default is never refused this way: the default covers every method the group serves, the verb-less case included (a route with no IHttpMethodMetadata, named * in the refusal message, is covered by the default exactly like a named method). Clear the refusal on a group with no default by adding an [EndpointGroupScope] declaration covering the missing method, by giving the route its own RequireAuthorization(...) or AllowAnonymous(), or by no longer serving the method.

Migrating off AuthPolicy

IEndpointConfig.AuthPolicy — one policy name for the whole group — is removed; this is a breaking change for any IEndpointConfig implementation that set it. Its replacement is the scope-only [EndpointGroupScope] form above the class:

// Before
public sealed class ProductsEndpointConfig : IEndpointConfig
{
    public string? AuthPolicy => "products:write";
    // ...
}

// After
[EndpointGroupScope("products:write")]
public sealed class ProductsEndpointConfig : IEndpointConfig
{
    // ...
}

A group that set AuthPolicy => null (the default) needs no replacement — a group carrying no [EndpointGroupScope] already keeps plain RequireAuthorization() with no policy, exactly what AuthPolicy == null gave before.

The whole mechanism is inert on a host running with EndpointRegistrationOptions.RequireAuthorization set to false: no attribute is read, no scope is enforced, and a method with no declared scope never triggers the startup refusal.

Fluent minimal-API mappers — verb to SlimMessageBus command

FluentsEndpointMapperExtensions maps an HTTP verb straight onto a SlimMessageBus fluent request/query (from DKNet.SlimBus.Extensions), dispatching through IMessageBus and turning the FluentResults outcome into the right IResult automatically. Every one of them is an extension on RouteGroupBuilder and every one calls .ProducesCommons():

Mapper Command constraint Binding Success status
MapGet<TCommand, TResponse>(endpoint) Fluents.Queries.IWitResponse<TResponse> [AsParameters] 200, or 404 when the query returns null
MapGetPage<TCommand, TResponse>(endpoint) Fluents.Queries.IWitPageResponse<TResponse> [AsParameters] 200 with PagedResponse<TResponse>
MapPost<TCommand, TResponse>(endpoint) Fluents.Requests.IWitResponse<TResponse> inferred body 201 when the command’s type name contains "Create" (case-insensitive), otherwise 200
MapPost<TCommand>(endpoint) Fluents.Requests.INoResponse inferred body same 201/200 rule, no body
MapPut<TCommand, TResponse>(endpoint) / MapPut<TCommand>(endpoint) IWitResponse<TResponse> / INoResponse inferred body 200
MapPatch<TCommand, TResponse>(endpoint) / MapPatch<TCommand>(endpoint) IWitResponse<TResponse> / INoResponse inferred body 200
MapDelete<TCommand, TResponse>(endpoint) IWitResponse<TResponse> explicit [FromBody] 200
MapDelete<TCommand>(endpoint) INoResponse [AsParameters] 200
MapPutById<TCommand, TKey, TResponse>(endpoint = "{id}") IWitResponse<TResponse> and Fluents.Requests.IWithKey<TKey> route id + inferred body 200
MapActionById<TCommand, TKey, TResponse>(endpoint, httpMethod) IWitResponse<TResponse> and IWithKey<TKey> route id + inferred body 200
MapParameterlessActionById<TCommand, TKey, TResponse>(endpoint, httpMethod) IWitResponse<TResponse> and IWithKey<TKey>, plus new() route id only — nothing is bound from the body 200
group.MapPost<CreateProductCommand, ProductModel>("/");    // 201 Created — type name contains "Create"
group.MapPost<RenameProductCommand, ProductModel>("/{id:guid}/rename"); // 200 Ok otherwise
group.MapPut<UpdateProductCommand, ProductModel>("/{id:guid}");
group.MapPutById<UpdateProductCommand, Guid, ProductModel>();           // binds route {id} into request.Id
group.MapActionById<ApproveOrderCommand, Guid, OrderModel>("{id}/approval", "POST");
group.MapParameterlessActionById<ArchiveOrderCommand, Guid, OrderModel>("{id}/archive", "PATCH"); // no body required
group.MapPatch<AdjustStockCommand>("/{id:guid}/stock");    // INoResponse overload — 200/no body
group.MapDelete<DeactivateProductCommand>("/{id:guid}");   // INoResponse — [AsParameters] binding
group.MapGet<FindProductQuery, ProductModel>("/find");     // Fluents.Queries.IWitResponse<T> -> 200 or 404
group.MapGetPage<ListProductsPageQuery, ProductModel>("/page"); // Fluents.Queries.IWitPageResponse<T>

Both action mappers register exactly the one verb they are handed, and both take the target id from the route. They differ only in what the request body is for:

MapPutById/MapActionById assign the route key onto the command before dispatch (request.Id = id), so the command never has to re-read it from the route:

public sealed record ApproveOrderCommand
    : Fluents.Requests.IWitResponse<OrderModel>, Fluents.Requests.IWithKey<Guid>
{
    public Guid Id { get; set; }           // assigned from the route by the mapper
    public string Approver { get; init; } = string.Empty;
}

ProducesCommons() adds two things: the shared 400/401/403/404/409/429/500 response metadata, so the published OpenAPI description is consistent across endpoints, and the endpoint filter that answers an unhandled exception with the standard error body (see One error-response setting). It is public — call it yourself on a hand-written RouteHandlerBuilder to get both:

app.MapGet("/health", () => "ok").ProducesCommons();

Generic entity endpoints — read, list, delete without a handler

FluentsEntityEndpointMapperExtensions skips SlimMessageBus entirely and goes straight to a DKNet.EfCore.Specifications IRepositorySpec. Each mapper comes in two shapes: an explicit-TKey form for any key type, and a Guid shorthand that forwards to it.

Mapper Default route Constraints Result
MapGetById<TEntity, TKey, TModel>(endpoint = "{id}") {id} TEntity : class, IEntity<TKey>, TKey : IEquatable<TKey>, TModel : class 200 with the projected model, 404 when no row matches
MapGetById<TEntity, TModel>(endpoint = "{id}") {id} TEntity : class, IEntity<Guid> forwards to the TKey form with TKey = Guid
MapDeleteById<TEntity, TKey>(endpoint = "{id}") {id} TEntity : class, IEntity<TKey>, TKey : IEquatable<TKey> 204, 404 when no row matches, 409 when SaveChangesAsync throws DbUpdateException
MapDeleteById<TEntity, TKey, TRequest>(endpoint = "{id}") {id} as above, plus TRequest : class, Fluents.Requests.IWithKey<TKey> identical 204/404/409 — TRequest is bound [AsParameters] so a group filter can validate it
MapDeleteById<TEntity>(endpoint = "{id}") {id} TEntity : class, IEntity<Guid> forwards to the TKey form
MapGetList<TEntity, TKey, TModel>(endpoint = "/") / TEntity : class, IEntity<TKey>, TKey : IEquatable<TKey>, TModel : class 200 with PagedResponse<TModel>, 400 on an unusable filter/search/orderBy
MapGetList<TEntity, TModel>(endpoint = "/") / TEntity : class, IEntity<Guid> forwards to the TKey form
group.MapGetById<Product, ProductModel>("/{id:guid}");        // Guid-keyed shorthand
group.MapGetById<Sprocket, int, SprocketModel>("/{id}");      // int key
group.MapGetById<Coupon, string, CouponModel>("/{id}");       // string key
group.MapDeleteById<Sprocket, int>("/{id}");
group.MapDeleteById<Product, Guid, DeleteProductRequest>();   // same route, but a rule can refuse the delete
group.MapGetList<Product, ProductModel>("/");

Attaching a rule to a delete — the three-type-argument overload

MapDeleteById<TEntity, TKey, TRequest> exists only so a delete can be refused. TRequest carries nothing but the key and is bound with [AsParameters], so a group-level validation filter — AddFluentValidationAutoValidation() via ConfigureGroup, for instance — sees one validatable argument even though the route still never reads a request body. The key is still bound from the route template, and the route address, HTTP verb and every status code are the same as the two-type-argument form:

public sealed record DeleteProductRequest : Fluents.Requests.IWithKey<Guid>
{
    public Guid Id { get; set; }
}

public sealed class DeleteProductRequestValidator : AbstractValidator<DeleteProductRequest>
{
    public DeleteProductRequestValidator(AppDbContext db) =>
        RuleFor(x => x.Id)
            .MustAsync(async (id, ct) => !await db.OrderLines.AnyAsync(l => l.ProductId == id, ct))
            .WithMessage("Product is still on an order and cannot be deleted.");
}

A refused delete is answered by whatever failure filter the group already registers — with the AddFluentValidationAutoValidation() pattern above that is a 400 ProblemDetails — and never reaches SaveChangesAsync, so the row survives and no audit entry or domain event is raised for it.

MapDeleteById<TEntity, TKey> and the Guid shorthand are unchanged: same signatures, same behaviour, same status codes. Both forms route through one shared delete helper, so a caller that registers no rule sees nothing different. DKNet.SlimBus.Generators emits a Delete{Entity}Request per entity and calls this overload from Map{Entity}Crud — see the generator’s docs.

TKey is constrained to IEquatable<TKey> rather than IParsable<TSelf> on purpose: the looser constraint keeps string keys usable, and minimal APIs bind those natively. The cost is that a key type the framework cannot bind fails when the route is built rather than at compile time.

All three require IRepositorySpec to be registered (services.AddSpecRepo<TDbContext>(), from DKNet.EfCore.Specifications). MapDeleteById hard-deletes through the repository’s save pipeline, so audit-log and domain-event hooks fire exactly as for any other removal.

Default ordering for MapGetList, when the caller supplies no orderBy: CreatedOn descending with Id descending as tie-break when the entity implements IAuditedEntity<TKey>, or Id descending alone otherwise. A caller-supplied orderBy replaces that default outright, and Id descending is appended as a tie-break unless the caller already ordered by Id.

What a bare GET against a MapGetList endpoint returns is decided by two more defaults, both host-configurable: it is served a full page of up to 1,000 items (Page-size defaults and ceiling) and — where the listed records carry audit timestamps — only the last three months of activity (Default recent-activity window).

The list-endpoint query contract — ListQueryRequest and ListFilter

MapGetList binds ListQueryRequest with [AsParameters], so its properties are the endpoint’s query string. Every property is nullable, so an absent parameter is distinguishable from a supplied one:

Query parameter Property Type Default Effect
pageNumber PageNumber int? null → page 1 One-based page. null or any value below 1 is treated as the first page.
pageSize PageSize int? null → 1,000 Items per page. null or below 1 becomes ListQueryOptions.DefaultPageSize (1,000); anything above ListQueryOptions.MaxPageSize (1,000) is clamped to it. Both are host-configurable — see Page-size defaults and ceiling.
filter Filter ListFilter[]? null Repeatable field:operation:value conditions, AND-combined. At most 20 per request.
search Search string? null Free-text LIKE '%…%' across the model’s text fields, OR-combined, then AND-ed onto filter. Minimum 2 characters after trimming; blank is treated as absent.
orderBy OrderBy string? null Field to sort by, replacing the endpoint’s default ordering.
desc Desc bool? null → false Sort descending. Ignored without orderBy.
fromDate FromDate DateTimeOffset? null → last 3 months Lower bound on when a record was last active. null on both bounds applies ListQueryOptions.DefaultActivityWindowMonths — see Default recent-activity window.
toDate ToDate DateTimeOffset? null → open-ended Upper bound on when a record was last active.

ListFilter is a readonly record struct (string Field, Ops Operation, string Value) implementing IParsable<ListFilter>, which is what lets minimal APIs bind a repeated ?filter=…&filter=… straight into an array. Its textual form is its representation — ListFilterJsonConverter (public, applied via [JsonConverter]) serialises it as the same colon-separated string, which is also why OpenAPI describes filter as an array of strings rather than an object:

GET /v1/products?filter=name:Contains:widget&filter=price:GreaterThan:100&orderBy=price&desc=true
GET /v1/products?filter=discontinuedOn:IsNull&search=blue&pageSize=50
// Composing the same conditions in code rather than formatting strings:
var conditions = new[]
{
    new ListFilter("Name", Ops.Contains, "widget"),
    new ListFilter("Price", Ops.GreaterThan, "100"),
};

Rules the parser and validator enforce, all traceable to ListFilter.TryParse and ListQuery.TryValidate:

Paged responses — PagedResponse<T>

PagedResponse<TResult> is the envelope MapGetList/MapGetPage return. It has a parameterless constructor (empty page) and one taking an X.PagedList.IPagedList<TResult>; build one directly when a handler already has a paged list (e.g. a Fluents.Queries.IPageHandler<TQuery, TResponse> result):

return Results.Ok(new PagedResponse<ProductModel>(pagedList));
Property Type Populated from
Items IList<TResult> the page’s items; [] from the parameterless constructor
PageNumber int IPagedList.PageNumber (1-based)
PageSize int IPagedList.PageSize
PageCount int IPagedList.PageCount
TotalItemCount int IPagedList.TotalItemCount
HasNextPage bool derived: PageNumber < PageCount
HasPreviousPage bool derived: PageNumber > 1

Result → IResult / ProblemDetails conversion

ResultResponseExtensions.Response()/Response<T>() convert a FluentResults IResultBase/ IResult<T> — the same result type DKNet’s SlimBus handlers already return — into the right minimal-API IResult. This is what the fluent mappers above call internally, and it is available standalone for any hand-written endpoint:

app.MapPost("/products", async (IMessageBus bus, CreateProductCommand cmd) =>
    (await bus.Send(cmd)).Response(isCreated: true));
Input isCreated Output
IResult<T> success, non-null value false TypedResults.Json(value)
IResult<T> success, null value false TypedResults.Ok()
IResult<T> success true TypedResults.Created("/", value) — the location is a literal "/" placeholder
IResultBase success false / true TypedResults.Ok() / TypedResults.Created()
either, failure any TypedResults.Problem(problemDetails), with a host’s registered error-response setting applied — resolved from the container when the response executes, so the endpoint never has to name it

Response(ErrorResponseOptions?, …) is retired (it let an endpoint skip the registered setting by passing null); the overloads above read the registered setting on their own.

Response()/Response<T>() are the only public path onto the standard body. The ProblemDetailsExtensions.ToProblemDetails() that builds it is internal — a caller can no longer answer a failure while skipping the registered setting, which is the whole point of having one setting. ToProblemDetails(this IResultBase, HttpStatusCode) and ToProblemDetails(this ModelStateDictionary) are retired outright.

The status a failure gets before any callback: 400, promoted to 404 when any error is a NotFoundError. On failure the body is the one shape every failure kind in this package answers with (see the next section) — Title is always "Error", Detail is never set, and errors is an ErrorItem[], never a flat string list or a field → messages map.

One error-response setting — AddErrorResponses

A DKNet host can refuse or fail a request in three different places: a SlimBus handler returns a failed FluentResults result, FluentValidation refuses the input before the handler ever runs, or an exception goes unhandled. AddErrorResponses is the one registration that shapes all three — call it once; no second call configures the validation path or wires exception handling separately (it swaps in the result factory that applies the setting, and registers the IExceptionHandler plus UseExceptionHandler() for you). The call is idempotent — a host that reaches it from two composition roots registers one setting, not two, and the first call’s configure wins:

using DKNet.AspCore.Extensions.Responses;

builder.Services.AddErrorResponses(o =>
{
    o.StatusCode = ctx => ctx.Errors.Any(e => e.Code == "business-refusal") ? 422 : null;
    o.Customize = (problem, ctx) => problem.Extensions["error-code"] = ctx.Errors.FirstOrDefault()?.Code;
});

A handler that fails with a business-refusal code and a validator that refuses the same rule with that ErrorCode now both answer 422 application/problem+json, each carrying the error-code member. One callback maps as many codes as you need — this one answers 409 Conflict for anything carrying a precondition code and leaves every other failure at the status it would have had:

builder.Services.AddErrorResponses(o =>
    o.StatusCode = ctx => ctx.Errors.Any(e => e.Code == "precondition") ? 409 : null);

The body that answers then reads "status": 409, "type": "Conflict" — type follows the status the callback chose, not the 400 the failure started at. Every endpoint the fluent mappers registered picks the setting up on its own — they resolve it from the container when the response executes, so registering it is the whole wiring step and leaving it unregistered is not an error.

The standard body. Every failure kind — a failed command, refused input, or an unhandled exception — answers with the same shape:

{
  "title": "Error",
  "status": 422,
  "type": "UnprocessableEntity",
  "traceId": "00-...-00",
  "errors": [ { "message": "...", "code": "business-refusal", "field": null } ]
}

type is always the final response status’ HttpStatusCode name — recomputed after StatusCode runs, never left at the status the failure would have had before the callback. traceId is Activity.Current?.Id, falling back to HttpContext.TraceIdentifier wherever a request is available. There is no Detail member.

An unhandled exception’s body says nothing about the exception. Outside the Development environment the single ErrorItem carries one fixed message — "An unexpected error occurred. Quote the trace-id when reporting this." — and nothing the exception carried: no exception message, no type name, no stack trace. Inside Development that one entry carries the exception’s own message instead, so a local run is still debuggable. type is the response status’ name (InternalServerError for the default 500) in every environment; it never names the exception type. traceId is the one member a caller quotes to get the real exception out of your logs.

Where an unhandled exception is caught. Two places, both building the body through the same factory, so a caller cannot tell them apart:

Endpoint Caught by
Anything the fluent mappers registered — every MapPost/MapPut/MapPatch/MapDelete/MapGet/MapGetPage and the generic MapGetById/MapGetList/MapDeleteById The single endpoint filter ProducesCommons() adds.
Everything else in the host The IExceptionHandler that AddErrorResponses registers.

The endpoint filter exists because ASP.NET Core’s Development-only developer exception page sits closer to the endpoint than any IExceptionHandler, and would otherwise answer first with an HTML page instead of the standard body. A filter sits closer still, so it wins.

Known boundary. An endpoint that does not go through ProducesCommons() — a raw app.MapGet(...), or anything registered outside the fluent mappers — has no filter, so in Development an exception it raises is answered by the developer exception page rather than the unified body. Production is unaffected: the IExceptionHandler covers those endpoints, and the developer exception page is not registered there. Call ProducesCommons() on a hand-written endpoint to put it on the same path as the mapped ones.

The status comes from the failure, never from the route. StatusCode receives an ErrorResponseContext and nothing else:

Member Type What it carries
Source ErrorSource Command for a failed FluentResults handler, Validation for input FluentValidation refused, Unhandled for an exception.
Errors IReadOnlyList<ErrorItem> ErrorItem(Message, Code?, Field?). Code is the FluentResults error’s "Code" metadata entry (Command) or the validation failure’s ErrorCode (Validation); Field names the refused input member and is always null otherwise. An Unhandled context carries exactly one ErrorItem — a fixed message outside Development, the exception’s own message inside it.
Exception Exception? The exception that was raised, for ErrorSource.Unhandled only; always null for Command and Validation.

The context deliberately carries no HttpContext, request path or HTTP method, so the same failure maps to the same status wherever it is raised — two routes cannot disagree about what a business-refusal means. Returning null keeps the status that failure would have had anyway, which is how one callback can map several error codes (or exception types) and leave everything else alone.

Customize applies to every failure kind, command, validation and unhandled alike. It runs after the status is chosen, against the ProblemDetails about to be written — a member added there cannot appear on one failure kind only. Use ctx.Source if the value should differ; the member itself is always present on all three.

UnhandledError replaces the built-in body for an unhandled exception only. Leaving it null, or returning null from it, keeps the library’s own body (the fixed/exception message shown above). StatusCode still wins over the status this callback sets when it also returns non-null, and Customize still runs afterwards:

o.UnhandledError = ctx => new ProblemDetails
{
    Status = StatusCodes.Status503ServiceUnavailable,
    Title = "Error",
    Extensions = { ["retryDelaySeconds"] = 30 }
};

⚠️ The setting is host-wide. It applies to every route in the host, so anything Customize adds appears on every error response the API returns — including endpoints you were not thinking about when you wrote the callback. That is why nothing is added for you: name each member you add, and add only what every caller of every endpoint is allowed to see.

What a host that registers nothing still gets. AddErrorResponses is optional. Without it — and, member by member, wherever it is called but left unset — the responses are:

Failure Status Body
Command handler returns a failed result 400 application/problem+json, the standard shape above (errors an ErrorItem[]) — Response()/Response<T>() answer this way whether or not a setting is registered.
Validator refuses the input 400 application/problem+json, errors a field → messages map (FluentValidation auto-validation’s own default factory — only replaced once AddErrorResponses is called).
Failure carries a NotFoundError 404 application/problem+json. Needs no setting, and survives a StatusCode callback that returns null for it.
An unhandled exception, AddErrorResponses never called Framework default Neither the standard body nor UnhandledError apply — nothing in this package handles the exception.

Generated CRUD endpoints — CrudMapOptions and CrudOp

CrudMapOptions and CrudOp are this package’s half of the vertical-slice CRUD generator. You never write a Map{Entity}Crud method: DKNet.SlimBus.Generators emits it from the [CrudCreate]/[CrudUpdate]/[CrudAction] markers on your entity (see DKNet.EfCore.Abstractions), composing the mappers above. The generated file is skipped entirely when the compilation does not reference this package.

What you write:

using DKNet.EfCore.Abstractions.Attributes;
using DKNet.EfCore.Abstractions.Entities;
using DKNet.EfCore.DtoGenerator;

public class Product : IEntity<Guid>
{
    [CrudCreate]
    public Product(string name, decimal price) { Name = name; Price = price; }

    [CrudUpdate] public void UpdatePrice(decimal price) => Price = price;
    [CrudUpdate] public void UpdateName(string name) => Name = name;
    [CrudAction("approval")] public void Approve(string approver) => Approver = approver;

    public Guid Id { get; private set; }
    public string Name { get; private set; } = string.Empty;
    public decimal Price { get; private set; }
    public string? Approver { get; private set; }
}

[GenerateDto(typeof(Product))]
public partial record ProductDto;

What the generator emits into ProductCrudEndpoints.g.cs, verbatim in shape:

// <auto-generated by DKNet.SlimBus.Generators />
#nullable enable
using DKNet.AspCore.Extensions.Endpoints;
namespace MyApi.Crud;                          // always {AssemblyName}.Crud

/// <summary>Registers the generated CRUD endpoints for Product.</summary>
public static class ProductCrudEndpointExtensions
{
    /// <summary>Maps GET {id}, GET /, POST /, PUT {id} (per update request), DELETE {id} and each generated domain-action endpoint for Product.</summary>
    public static global::Microsoft.AspNetCore.Routing.RouteGroupBuilder MapProductCrud(
        this global::Microsoft.AspNetCore.Routing.RouteGroupBuilder group,
        global::System.Action<global::DKNet.AspCore.Extensions.Endpoints.CrudMapOptions>? configure = null)
    {
        var options = new global::DKNet.AspCore.Extensions.Endpoints.CrudMapOptions();
        configure?.Invoke(options);
        options.ValidateRouteNames("Product", "GetById", "GetList", "Create", "Delete", "UpdatePrice", "UpdateName", "Approve");
        if (!options.IsExcluded(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.GetById) && !options.IsExcluded("GetById"))
        {
            var routeBuilder = group.MapGetById<global::MyDomain.Product, global::System.Guid, global::MyApi.ProductDto>();
            options.Apply(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.GetById, "GetById", routeBuilder);
        }
        if (!options.IsExcluded(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.GetList) && !options.IsExcluded("GetList"))
        {
            var routeBuilder = group.MapGetList<global::MyDomain.Product, global::System.Guid, global::MyApi.ProductDto>();
            options.Apply(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.GetList, "GetList", routeBuilder);
        }
        if (!options.IsExcluded(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Delete) && !options.IsExcluded("Delete"))
        {
            var routeBuilder = group.MapDeleteById<global::MyDomain.Product, global::System.Guid, DeleteProductRequest>();
            options.Apply(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Delete, "Delete", routeBuilder);
        }
        if (!options.IsExcluded(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Create) && !options.IsExcluded("Create"))
        {
            var routeBuilder = group.MapPost<CreateProductRequest, global::MyApi.ProductDto>("/");
            options.Apply(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Create, "Create", routeBuilder);
        }
        if (!options.IsExcluded(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Update) && !options.IsExcluded("UpdatePrice"))
        {
            var routeBuilder = group.MapPutById<UpdatePriceProductRequest, global::System.Guid, global::MyApi.ProductDto>("{id}");
            options.Apply(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Update, "UpdatePrice", routeBuilder);
        }
        if (!options.IsExcluded(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Update) && !options.IsExcluded("UpdateName"))
        {
            var routeBuilder = group.MapPutById<UpdateNameProductRequest, global::System.Guid, global::MyApi.ProductDto>("{id}/update-name");
            options.Apply(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Update, "UpdateName", routeBuilder);
        }
        if (!options.IsExcluded(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Action) && !options.IsExcluded("Approve"))
        {
            var routeBuilder = group.MapActionById<ApproveProductRequest, global::System.Guid, global::MyApi.ProductDto>("{id}/approval", "POST");
            options.Apply(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Action, "Approve", routeBuilder);
        }
        return group;
    }
}

Every registration passes two guards — its CrudOp and its own route name — so Exclude(CrudOp.Update) drops both PUTs while Exclude("UpdateName") drops only the second. ValidateRouteNames runs before any mapping, with every name the entity has, which is what turns an unmatched Exclude/Configure name into an ArgumentException instead of a silent no-op.

Note the routing rules that fall out of that emission: registration order is GetById, GetList, Delete, Create, then each [CrudUpdate] in declaration order, then each [CrudAction]. The first [CrudUpdate] claims the plain {id} route and each additional one gets {id}/{kebab-cased-method}; an action never claims the plain {id} route, whatever verb it uses, and defaults its segment to the kebab-cased method name when [CrudAction] carries no explicit route.

CrudMapOptions is the only knob on the generated method — it excludes whole operation kinds or individual named routes, and it attaches RouteHandlerBuilder settings to the routes that survive:

// By operation kind — drops every delete route and every action route.
group.MapProductCrud(o => o.Exclude(CrudOp.Delete, CrudOp.Action));

// By route name — drops only the UpdateName route. UpdatePrice keeps `{id}` and
// Approve keeps `{id}/approval`; neither moves.
group.MapProductCrud(o => o.Exclude("UpdateName"));

group.MapProductCrud(o => o
    .Configure(CrudOp.Update, b => b.RequireAuthorization("product.write"))
    .Configure("UpdateName", b => b.RequireAuthorization("product.rename")));
Member Signature Behaviour
Exclude CrudMapOptions Exclude(params CrudOp[] operations) Adds each operation to the exclusion set and returns this for chaining. Nothing is excluded by default.
Exclude CrudMapOptions Exclude(params string[] routeNames) Adds each route name to the exclusion set and returns this. Drops the one route carrying that name and leaves the entity’s other routes of the same kind published, at the addresses they already had. A name the entity has no route for throws ArgumentException at registration — the same check a misspelt Configure(string, …) name hits, never a silent no-op. Nothing is excluded by default.
IsExcluded bool IsExcluded(CrudOp operation) What the generated code calls per registration, for the route’s operation kind.
IsExcluded bool IsExcluded(string routeName) What the generated code calls per registration, for the route’s own name. A route is registered only when neither check excludes it.
Configure CrudMapOptions Configure(CrudOp operation, Action<RouteHandlerBuilder> configure) Runs the setting against every generated route of that operation kind. Additive — several calls for one operation all run, in call order — and returns this.
Configure CrudMapOptions Configure(string routeName, Action<RouteHandlerBuilder> configure) Runs the setting against the one route carrying that name. Additive and chainable in the same way.
CrudOp enum GetById, GetList, Create, Update, Delete, Action. Excluding Update or Action drops every route of that kind at once; naming a [CrudUpdate]/[CrudAction] member excludes that one route alone.

Route names come from the generator, not from this package: GetById, GetList, Create and Delete for the four fixed operations, and each [CrudUpdate]/[CrudAction] member’s own C# method name for the rest — not the kebab-cased segment. The rule and a worked example live in DKNet.SlimBus.Generators.

The generated method validates every name given to Exclude(string, …) and to Configure(string, …) before it maps anything, so a name the entity does not have throws ArgumentException and the group publishes nothing — a misspelt name fails loudly instead of dropping a RequireAuthorization on the floor or leaving a route you meant to withdraw published. For each route, operation-kind settings run first and name settings after. A setting naming a route whose operation was excluded is still validated, then dropped with the route; that combination is not an error.

⚙️ Configuration reference

AddContextualRequestPopulation() takes no configure delegate and has no options of its own — a declared member the registered resolvers cannot resolve always holds its type’s default. See Contextual request binding above for supplying a value of your own via a custom IContextualValueResolver.

EndpointRegistrationOptions — via UseEndpointConfigs(Action<EndpointRegistrationOptions>?, params Assembly[]):

Option Type Default Effect
RouteTemplate Func<IEndpointConfig, string>? null null uses /v{version:apiVersion}{GroupEndpoint} when versioning is enabled, or {GroupEndpoint} otherwise.
DefaultTag string "Root" Used when an IEndpointConfig.Tag resolves to an empty string.
RequireAuthorization bool true When true, applies plain RequireAuthorization() to every group, then reads any [EndpointGroupScope] declared above the IEndpointConfig to add per-method (or group-default) scopes. Disabling it is an explicit per-host opt-out — it makes [EndpointGroupScope] inert (no attribute read, no scope enforced, no startup refusal).
EnableVersioning bool true Adds the version prefix and API-version metadata. Requires AddApiVersioning() to be registered, or UseEndpointConfigs throws at startup — even with zero discovered configs.
ConfigureGroup Action<RouteGroupBuilder, IEndpointConfig>? null Runs after mapping/tags/version metadata, before authorization is applied and before IEndpointConfig.Map.

UseEndpointConfigs’s own parameters:

Parameter Type Default Effect
configureOptions Action<EndpointRegistrationOptions>? null Leave null to keep every default above.
assemblies params Assembly[] empty → AppDomain.CurrentDomain.GetAssemblies() Assemblies scanned for IEndpointConfig implementations.

ErrorResponseOptions — via AddErrorResponses(Action<ErrorResponseOptions>?). Registered once and host-wide: every knob applies to every route, and to a failed command handler, refused validation input and an unhandled exception alike. See One error-response setting:

Option Type Default Effect
StatusCode Func<ErrorResponseContext, int?>? null Chooses the status from the failure’s own errors. The context carries no HttpContext, path or HTTP method, so the route cannot influence it. Returning null, or leaving this unset, keeps the status that failure would have had anyway — 400, 404 when it carries a NotFoundError, or 500 for an unhandled exception. Still wins over the status UnhandledError set, when both are configured and both return non-null.
Customize Action<ProblemDetails, ErrorResponseContext>? null Adds members to the ProblemDetails after its status is chosen, for ErrorSource.Command, ErrorSource.Validation and ErrorSource.Unhandled alike. Whatever it adds appears on every error response the host returns.
UnhandledError Func<ErrorResponseContext, ProblemDetails?>? null Supplies the response body for an unhandled exception in place of the library’s own body. Leaving it null, or returning null from it, keeps the library’s own body. Has no effect on ErrorSource.Command/Validation.

AddErrorResponses’s own parameter:

Parameter Type Default Effect
configure Action<ErrorResponseOptions>? null Leave null to keep both knobs unset — each unset knob is a no-op, so the responses stay today’s. Skipping the call entirely leaves the setting unregistered, which the mappers resolve as an optional service and fall back the same way.

Page-size defaults and ceiling

ListQueryOptions (namespace DKNet.AspCore.Extensions.Endpoints) holds the page size every MapGetList endpoint actually pages by. It is global to the host, not per endpoint:

Option Type Default Effect
DefaultPageSize int, [Range(1, int.MaxValue)] 1000 Page size used when pageSize is absent, null or below 1.
MaxPageSize int, [Range(1, int.MaxValue)] 1000 Ceiling every page is subject to. Still a clamp — an oversized request is served trimmed, never rejected with 400.
DefaultActivityWindowMonths int, minimum 0 3 How many months back a listing of audited records reaches when the caller names neither fromDate nor toDate. 0 switches the default window off. See Default recent-activity window.
ConfigSectionName const string "DKNet:ListQuery" The configuration section the options are meant to bind from.

MaxPageSize is a hard ceiling on every path, the default included: a caller who omits pageSize receives min(DefaultPageSize, MaxPageSize), so a MaxPageSize configured below DefaultPageSize lowers the default page too rather than being bypassed by it.

Both DefaultPageSize and DefaultActivityWindowMonths are the values used when the host configures nothing: a host that already sets either one keeps its own value untouched.

Raise or lower it from configuration:

{
  "DKNet": {
    "ListQuery": {
      "DefaultPageSize": 1000,
      "MaxPageSize": 5000,
      "DefaultActivityWindowMonths": 3
    }
  }
}
builder.Services.Configure<ListQueryOptions>(
    builder.Configuration.GetSection(ListQueryOptions.ConfigSectionName));

…or in code:

builder.Services.AddListQueryOptions(o => o.MaxPageSize = 5000);

To keep the behaviour a host had before these defaults changed — 20 items for a bare request, and no default time window — set both explicitly:

builder.Services.AddListQueryOptions(o =>
{
    o.DefaultPageSize = 20;
    o.DefaultActivityWindowMonths = 0;
});

AddListQueryOptions binds no configuration of its own — it registers the options with ValidateDataAnnotations().ValidateOnStart(), so a DefaultPageSize or MaxPageSize below 1 fails the host at start-up rather than silently degrading a live endpoint. Because that validation runs against the final computed options, calling it alongside Configure<ListQueryOptions>(…) extends the same start-up check to configuration-supplied values. Both calls are optional: a host that makes neither still resolves IOptions<ListQueryOptions> and pages by the defaults above.

ListQueryRequest’s remaining query-string defaults are listed under The list-endpoint query contract.

Default recent-activity window

A MapGetList endpoint over records that carry audit timestamps answers a bare request — no fromDate, no toDate — with the last 3 months of activity rather than the whole table. The length of that window is ListQueryOptions.DefaultActivityWindowMonths, host-configurable through the same DKNet:ListQuery section:

{
  "DKNet": {
    "ListQuery": {
      "DefaultActivityWindowMonths": 6
    }
  }
}
builder.Services.AddListQueryOptions(o => o.DefaultActivityWindowMonths = 0); // no default window

How the window is decided, in order:

The caller sends The listing covers
neither bound now minus DefaultActivityWindowMonths up to now
neither bound, with DefaultActivityWindowMonths = 0 all history — the window is switched off
fromDate only fromDate onwards, open-ended at the top
toDate only everything up to toDate, open-ended at the bottom
both exactly the range named
fromDate=0001-01-01T00:00:00Z all history — the documented way for a caller to opt out

Naming either bound replaces the default rather than narrowing it, which is why a single fromDate widens the listing rather than restricting it further:

GET /v1/products                                        # last 3 months of activity
GET /v1/products?fromDate=2026-01-01T00:00:00Z          # 2026-01-01 onwards, no upper bound
GET /v1/products?fromDate=0001-01-01T00:00:00Z          # all history
GET /v1/products?fromDate=2026-01-01T00:00:00Z&toDate=2026-03-31T23:59:59Z

What “last active” means: a record is in range when either its CreatedOn or its UpdatedOn moment falls inside the bounds. A record never updated since creation is matched on CreatedOn alone and is never dropped for lacking an update; a record created years ago but edited yesterday is in a 3-month window, which is the intended reading of recently active.

The rest of the contract:

The window applies on the strength of the audit timestamps alone — any TEntity assignable to IAuditedProperties (CreatedOn/UpdatedOn) — which is a wider set of types than the newest-first default ordering recognises: that check is against IAuditedEntity<TKey>. A type that carries the timestamps without implementing IAuditedEntity<TKey> is therefore windowed but not reordered, until the two are aligned.

🧱 Where it fits

Workflow diagram of UseEndpointConfigs: a fail-fast versioning guard runs before discovery, discovered IEndpointConfig implementations each become a versioned route group, the contextual-population filter is registered first, then the host's ConfigureGroup callback, then authorization and finally the config's own Map. Two InvalidOperationException exits and an empty-list exit are shown.

The two early exits on the left of that diagram are the failures worth internalising: a missing AddApiVersioning() throws before discovery even runs, and a request type that declares a contextual source without AddContextualRequestPopulation() throws while endpoint metadata is built. Neither is a runtime surprise — both happen at startup.

⚠️ Gotchas & Limits