↑↓ to navigate Enter to open "…" all these words ANDOR to combine

Architecture Decision Record

ADR-034: Generic Entity Controllers with a Dynamic Query Contract

Status

Accepted (2026-06-30). Amended (2026-07-23): the filter strategy registry now also covers long/long? via LongFilterStrategy. Amended (2026-07-25): the keyed by-id fast path is named TryGetFastPathIncludes in code; citations rebased. Extended by ADR-099 (2026-08-29, v1.170.0): the write half this record left at create and delete gains a generic update, through an IEntityUpdateCommandApplier the module implements, an UpdateEntityCommand / UpdateEntityHandler pair, an AddEntityCrud registration for all three verbs, and a CrudEntityControllerBase carrying the PUT. The PUT ships on a new derived base rather than on AggregateRootEntityControllerBase, whose generic arity is deliberately unchanged; everything below is untouched. Revised (2026-08-31): the update-applier interface is named IEntityUpdateCommandApplier (Source/Core/MMCA.Common.Application/UseCases/Crud/IEntityUpdateCommandApplier.cs:38); source citations below rebased to their current lines. Revised 2026-09-07 (sort keys, filter keys and lookup name columns resolve against the response contract rather than the entity, navigation depth and lookup rows are capped, and the selector cache is bounded).

Context

Every module exposes many entities, and most of them need the same read and write surface: list, page, look up for a dropdown, fetch by id, create, delete. Hand writing a controller, a query service, and a filter/sort/paginate implementation per entity is repetitive, drifts in shape from one entity to the next, and is the bulk of the boilerplate a modular monolith accumulates as it grows.

The framework chose the opposite default: a generic resource layer plus an OData-lite dynamic query contract that every entity inherits for free, rather than bespoke per-entity endpoints. A concrete controller is a few lines that close the generic type parameters; the verbs, routes, filtering, sorting, pagination, field projection, and include behavior come from the base. The mechanics are documented in the onboarding chapters; the trade-off this represents (a generic, dynamically queryable contract coupled to the entity model versus narrow bespoke endpoints) was never recorded as a decision.

Decision

Give every entity a generic REST resource surface and a bounded dynamic query contract, supplied by two controller bases over a shared query pipeline.

  1. Generic read controller. EntityControllerBase<TEntity, TEntityDTO, TIdentifierType> (Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:34, [ApiController] / [Route("[controller]")] / [ApiVersion("1.0")] at EntityControllerBase.cs:31-33) exposes four core GET routes for any entity (plus a [HttpGet("export")] CSV action inserted between the paged and lookup routes): [HttpGet] list (EntityControllerBase.cs:107), [HttpGet("paged")] (EntityControllerBase.cs:154), [HttpGet("lookup")] for id/name dropdown entries (EntityControllerBase.cs:317), and [HttpGet("{id}")] (EntityControllerBase.cs:356).

  2. Generic write controller. AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest> (Source/Presentation/MMCA.Common.API/Controllers/AggregateRootEntityControllerBase.cs:28) inherits all of the above and adds [HttpPost] create (AggregateRootEntityControllerBase.cs:59, returning 201 CreatedAtRoute at :73) and [HttpDelete("{id}")] (AggregateRootEntityControllerBase.cs:85). The create action is decorated with [Idempotent] (AggregateRootEntityControllerBase.cs:60) so a retried POST does not create a duplicate (ADR-017).

  3. Sparse fieldsets via fields. A comma-separated fields query parameter (EntityControllerBase.cs:111, :163, :365) drives a server-side projection: QueryFieldService.ApplyFieldSelection (Source/Core/MMCA.Common.Application/Services/QueryFieldService.cs:283) builds a MemberInit expression that selects only the requested writable properties (QueryFieldService.cs:341) so only those columns leave the database. The compiled projections are cached per field set, capped at MaxCacheEntries 512 (QueryFieldService.cs:39); once the cache is full, a new field set skips the server-side projection (QueryFieldService.cs:302-303) and the response is still trimmed to the requested fields.

  4. Dynamic per-type filtering. The paged route binds Dictionary<string, (string Operator, string Value)> filters through [ModelBinder(typeof(QueryFilterModelBinder))] (EntityControllerBase.cs:166), which parses filters[Property].operator / filters[Property].value query keys (Source/Presentation/MMCA.Common.API/ModelBinders/QueryFilterModelBinder.cs:24). QueryFilterService.ApplyFilters (Source/Core/MMCA.Common.Application/Services/Filtering/QueryFilterService.cs:78) resolves a IFilterStrategy (Source/Core/MMCA.Common.Application/Services/Filtering/IFilterStrategy.cs:6) per property CLR type from a strategy registry (string, bool, int, long, DateTime, decimal, Guid and their nullables, declared at QueryFilterService.cs:32, entries at :35-47), each strategy declaring its SupportedOperators (IFilterStrategy.cs:24). Extra types register via QueryFilterService.RegisterStrategy (QueryFilterService.cs:62); a strongly typed identifier column (ADR-115) needs no registration, because ResolveStrategy builds a StronglyTypedIdFilterStrategy on first use and memoizes it beside the built-ins (QueryFilterService.cs:406-416).

  5. Sort. sortColumn / sortDirection (EntityControllerBase.cs:161-162) feed QueryFieldService.ApplySorting (QueryFieldService.cs:155), an OrderBy("<col> ascending|descending") over the entity property the DTO name maps to.

  6. Pagination and the X-Pagination header. The paged route clamps the requested page size with Math.Min(pageSize, MaxPageSize) (EntityControllerBase.cs:169), where MaxPageSize resolves IOptions<ApplicationSettings> from the request's services and falls back to 500 (EntityControllerBase.cs:59, resolution at :63, default at Source/Core/MMCA.Common.Application/Settings/ApplicationSettings.cs:17), then clamps the configured value to between 1 and EntityQueryPipeline.MaxUnboundedResultLimit (EntityControllerBase.cs:64), so configuration cannot raise the page size above the pipeline ceiling. It is read per request rather than captured in the constructor so a configuration change takes effect without a restart. The pagination metadata is serialized into the X-Pagination response header (EntityControllerBase.cs:188).

  7. A last-resort safety ceiling. Independent of the API page-size clamp, EntityQueryPipeline.MaxUnboundedResultLimit = 1000 (Source/Core/MMCA.Common.Application/Services/Query/EntityQueryPipeline.cs:23) caps any unpaginated query with query.Take(MaxUnboundedResultLimit), at three call sites (EntityQueryPipeline.cs:99, :195, :250), and ApplyPaging clamps a paged request to the same limit (EntityQueryPipeline.cs:278-281), so even a direct service caller that omits pagination cannot trigger an unbounded full-table load.

  8. Two include paths. includeFKs / includeChildren (EntityControllerBase.cs:112-113 for the list overload, :159-160 for the paged overload) select navigation loading. EntityQueryPipeline (EntityQueryPipeline.cs:13) runs PATH 1 for source-supported includes via EF Core .Include() translated to SQL (EntityQueryPipeline.cs:129-135) and PATH 2 for unsupported includes via manual INavigationPopulator batch loading after materialization (EntityQueryPipeline.cs:50), the populator strategy of ADR-002.

Rationale

  • Write the resource surface once, inherit it everywhere. The verbs, routes, filter contract, pagination, and header are defined once on the two bases; a concrete controller closes the type parameters and gets a uniform, predictable contract. New entities cost almost nothing and cannot drift in shape.
  • Bounded dynamic querying, not open SQL. Filtering is dynamic over the wire but not unbounded in the engine: each property is filtered only by a registered IFilterStrategy whose SupportedOperators are validated before the database is touched (QueryFilterService.ValidateFilters, QueryFilterService.cs:159, invoked at Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:301), and MaxUnboundedResultLimit (EntityQueryPipeline.cs:23) plus the MaxPageSize clamp (EntityControllerBase.cs:169) bound the result size.
  • Composes with manual DTO mapping (ADR-001). Entities are projected to DTOs by an injected IEntityDTOMapper (EntityQueryService.cs:36, property at :91) via DTOMapper.MapToDTOs (EntityQueryService.cs:360); a DTOToEntityPropertyMap (EntityQueryService.cs:101) translates DTO field names to entity property paths for filter and sort, so the wire contract speaks DTO names while the engine speaks entity names.
  • Composes with populators (ADR-002). The unsupported-include path delegates to INavigationPopulator (EntityQueryService.cs:37), the same cross-source batch loader that bridges relationships EF cannot JOIN.

Trade-offs

  • The wire contract tracks the entity model. Filterable, sortable, and projectable surface is the entity's property set. A model change is an API change unless mediated by the DTO and DTOToEntityPropertyMap (EntityQueryService.cs:101), which is the boundary that decouples the two when needed.
  • Dynamic filtering is an injection and over-fetch surface. Arbitrary client-supplied property/operator/value triples are an attack surface; it is bounded by validating properties and operators up front (QueryFilterService.ValidateFilters, QueryFilterService.cs:159), routing each type through its registered IFilterStrategy rather than free-form expression evaluation, and capping rows with MaxUnboundedResultLimit (EntityQueryPipeline.cs:23). Sparse fieldsets reject non-writable properties with InvalidEntityField during field validation (QueryFieldService.cs:559-561), and the projection itself selects only writable properties (QueryFieldService.cs:341).
  • Generic endpoints are less self-documenting than bespoke ones. One generic shape per entity is consistent but conveys less domain intent than a named, purpose-built endpoint; the query contract (filter key syntax, operators) must be learned once rather than read off each endpoint.
  • Opting out means overriding the base. All four reads and the two writes are virtual (EntityControllerBase.cs:110, AggregateRootEntityControllerBase.cs:64), so a controller that needs bespoke behavior overrides the specific action rather than abandoning the base, but the default surface is opt-out, not opt-in.

Revision (2026-09-07)

The query layer's shape is unchanged. What a caller is allowed to name is not, from the 2026-09-07 security review.

  1. Client keys resolve against the DTO contract (SEC-Common-25 / SEC-ADC-09). QueryFieldContract (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/QueryFieldContract.cs:34) holds a response type's public property names, built with For<TContract>() (:56) or narrowed with ForNames (:83), and tested through AllowsClientKey (:103). EntityQueryService.FieldContract defaults to QueryFieldContract.For<TEntityDTO>() (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:123) and flows through EntityQueryParameters into sorting and filtering. A key the property map does not cover must name a contract field, and a dotted path the server did not author is refused, so a caller can neither order a public page by a column the mapper redacts nor turn a list endpoint into an inference oracle over the object graph. Overriding FieldContract with null restores the old entity-based resolution.
  2. Navigation depth is capped at three segments. QueryFieldContract.MaxNavigationDepth (:42) is enforced by IsWithinNavigationDepth (:115, :130) on any resolved entity path, whoever authored it. A self-referencing segment repeated hundreds of times is otherwise that many LEFT JOINs from one anonymous request (SEC-Store-13).
  3. The lookup read is contract-bound and capped (SEC-Common-24). EntityQueryService.LookupNameContract (:135, defaulting to FieldContract) validates the caller's nameProperty and answers 400 for anything else (:393), and EFReadRepository.GetAllForLookupAsync applies EntityQueryPipeline.MaxUnboundedResultLimit (1000, MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/EntityQueryPipeline.cs:23) at MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepository.cs:244. The lookup was the one read that never entered the pipeline enforcing that limit.
  4. The selector cache is bounded and canonically keyed (SEC-Store-14). QueryFieldService caps the compiled-selector cache at MaxCacheEntries 512 (MMCA.Common/Source/Core/MMCA.Common.Application/Services/QueryFieldService.cs:39, documented at :323 and :574) and keys on the property's canonical name, so the case permutations of one real column collapse onto a single entry instead of minting an unevictable one each.

Adopting this is a behaviour change. A client key naming a field only the entity carries now answers 400. The fix is to add the field to the DTO, map it in DTOToEntityPropertyMap, or override FieldContract.

ADR-001 (manual DTO mapping: the generic controllers project through IEntityDTOMapper), ADR-002 (navigation populators: the unsupported-include path), ADR-013 (Result pattern at the edge: every action returns through HandleFailure(result.Errors), EntityControllerBase.cs:129), ADR-017 (idempotency: the generic create is [Idempotent], AggregateRootEntityControllerBase.cs:60), ADR-019 (rate limiting: these GET routes are the authenticated read surface the always-on global limiter caps per principal).

Revision (2026-07-24)

Filter and pagination corrections from a code review. The contract shape is unchanged; these close cases where the engine widened a result set instead of narrowing it, or reported a total it did not have.

  1. An unparseable filter value is a 400, not an unfiltered read. Every strategy silently returned the query unchanged when it could not parse a value, and validation never looked at values at all, so ?filter=id:equals:abc returned the whole (capped) result set rather than no matches. That is the wrong direction for a fail-safe default on an endpoint whose filter may be the only scoping. IFilterStrategy.CanParseValue (a default interface member returning true, so custom strategies are unaffected until they opt in) is implemented by the six value-type strategies and checked in ValidateFilters, which now emits Filter.Value.Invalid. Presence operators still ignore the value, IN needs at least one parseable item, and BETWEEN needs exactly two bounds.
  2. A mapped filter is applied, not silently dropped. ApplyFilters and ValidateFilters disagreed on how to resolve a DTOToEntityPropertyMap entry: validation fell back to the mapped entity name while application retried the DTO name. A plain rename entry therefore passed validation and was then skipped, returning an unfiltered result set with a 200. Both now share one resolver, so anything validation accepts is what gets applied.
  3. Pagination edges. The Skip offset was computed with checked 32-bit arithmetic, so a page number near int.MaxValue overflowed into a 500 instead of the empty page it describes; it is now 64-bit and range-checked. An unpaginated read reported the MaxUnboundedResultLimit safety cap as TotalItemCount, telling callers the set was exactly that size; it now issues a count query only when the materialized rows actually reach the cap. PaginationMetadata.PageSize reports the size the pipeline applied rather than the one requested.
  4. The keyed by-id fast path is reachable again. The fast-path predicate treated includeFKs as disqualifying while GetByIdAsync defaults it to true, so every REST by-id read fell through to the dynamic-filter pipeline (a parsed string predicate, TOP 1000, and a client-side FirstOrDefault) where a keyed TOP 1 WHERE Id = @id would do. The flags now disqualify only when the entity actually has navigations to include.

Revision (2026-07-25)

Citation maintenance from an ADR audit. No decision or behavior changed; the source anchors above were rebased to their current declaration and call-site lines.

  1. The fast-path predicate is named TryGetFastPathIncludes. Revision item 4 above referred to it as IsPrimaryKeyOnlyLookup, a symbol that no longer exists in the framework source. The current method (Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:162, called from the by-id fast path at EntityQueryService.cs:130) both decides whether the request is a plain key lookup and returns the navigations it must eager-load. The described behavior is unchanged: requested include flags no longer disqualify on their own, and only unsupported (cross-source) includes send the read back to the pipeline (EntityQueryService.cs:185-188).

Revision (2026-10-01)

No decision or rationale changed; this records clarifications and refreshed citations from an ADR audit.

  1. MaxPageSize is clamped to the pipeline ceiling. The resolved setting is now Math.Clamp(settings?.MaxPageSize ?? 500, 1, EntityQueryPipeline.MaxUnboundedResultLimit) (Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:64), so a configured page size above 1000 or below 1 no longer reaches the pipeline, and ApplyPaging clamps to the same limit (Source/Core/MMCA.Common.Application/Services/Query/EntityQueryPipeline.cs:278-281).
  2. The column-narrowing projection is bounded. Once the field-set projection cache holds MaxCacheEntries 512 entries (Source/Core/MMCA.Common.Application/Services/QueryFieldService.cs:39), a new field set skips server-side projection (QueryFieldService.cs:298-299) rather than growing the cache; the response is still shaped to the requested fields.
  3. Strongly typed identifiers filter without registration. ResolveStrategy builds and memoizes a StronglyTypedIdFilterStrategy for an ADR-115 identifier column (Source/Core/MMCA.Common.Application/Services/Filtering/QueryFilterService.cs:406-416).
  4. Non-writable sparse fields are rejected at validation. InvalidEntityField is raised in ValidateFields (QueryFieldService.cs:517-523); the projection only filters to writable properties (QueryFieldService.cs:337).

Citations in Decision, Rationale, Trade-offs and Related were rebased to their current lines (EntityControllerBase.cs, QueryFieldService.cs, QueryFilterService.cs, EntityQueryPipeline.cs, EntityQueryService.cs). Anchors inside the earlier Revision sections are left as recorded.

Revision (2026-10-06)

No decision, rationale or behavior changed; this pass refreshes citations from an ADR audit.

  1. Anchors recorded in earlier Revision sections have moved. They stay as recorded; the current locations are: the fast-path predicate TryGetFastPathIncludes is declared at Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:196, called from the by-id fast path at :164, and sends a read with unsupported includes back to the pipeline at :219-222; EFReadRepository.GetAllForLookupAsync applies MaxUnboundedResultLimit at Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepository.cs:246; the MaxCacheEntries cap is documented at QueryFieldService.cs:328 and :619; the projection-cache skip is at QueryFieldService.cs:302-303, the writable-only projection at :341, and the non-writable InvalidEntityField rejection at :559-561.

Anchors in Decision and Trade-offs were re-verified against current source and rebased (EntityControllerBase.cs, QueryFieldService.cs). Anchors inside the earlier Revision sections are left as recorded.