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.
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.
Generic read controller.
EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>(Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:36,[ApiController]/[Route("[controller]")]/[ApiVersion("1.0")]atEntityControllerBase.cs:33-35) 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:106),[HttpGet("paged")](EntityControllerBase.cs:153),[HttpGet("lookup")]for id/name dropdown entries (EntityControllerBase.cs:371), and[HttpGet("{id}")](EntityControllerBase.cs:410).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 201CreatedAtRouteat: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).Sparse fieldsets via
fields. A comma-separatedfieldsquery parameter (EntityControllerBase.cs:110,:162,:419) drives a server-side projection:QueryFieldService.ApplyFieldSelection(Source/Core/MMCA.Common.Application/Services/QueryFieldService.cs:229) builds aMemberInitexpression that selects only the requested writable properties so only those columns leave the database.Dynamic per-type filtering. The paged route binds
Dictionary<string, (string Operator, string Value)> filtersthrough[ModelBinder(typeof(QueryFilterModelBinder))](EntityControllerBase.cs:165), which parsesfilters[Property].operator/filters[Property].valuequery keys (Source/Presentation/MMCA.Common.API/ModelBinders/QueryFilterModelBinder.cs:24).QueryFilterService.ApplyFilters(Source/Core/MMCA.Common.Application/Services/Filtering/QueryFilterService.cs:77) resolves aIFilterStrategy(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,QueryFilterService.cs:31-47), each strategy declaring itsSupportedOperators(IFilterStrategy.cs:24). Extra types register viaQueryFilterService.RegisterStrategy(QueryFilterService.cs:61).Sort.
sortColumn/sortDirection(EntityControllerBase.cs:160-161) feedQueryFieldService.ApplySorting(QueryFieldService.cs:155), anOrderBy("<col> ascending|descending")over the entity property the DTO name maps to.Pagination and the
X-Paginationheader. The paged route clamps the requested page size withMath.Min(pageSize, MaxPageSize)(EntityControllerBase.cs:168), whereMaxPageSizeresolvesIOptions<ApplicationSettings>from the request's services and falls back to 500 (EntityControllerBase.cs:58, resolution at:62, default atSource/Core/MMCA.Common.Application/Settings/ApplicationSettings.cs:17). 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 theX-Paginationresponse header (EntityControllerBase.cs:187).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 withquery.Take(MaxUnboundedResultLimit), at three call sites (EntityQueryPipeline.cs:98,:193,:247), so even a direct service caller that omits pagination cannot trigger an unbounded full-table load.Two include paths.
includeFKs/includeChildren(EntityControllerBase.cs:111-112for the list overload,:158-159for 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:128-134) and PATH 2 for unsupported includes via manualINavigationPopulatorbatch 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
IFilterStrategywhoseSupportedOperatorsare validated before the database is touched (QueryFilterService.ValidateFilters,QueryFilterService.cs:119, invoked atSource/Core/MMCA.Common.Application/Services/EntityQueryService.cs:267), andMaxUnboundedResultLimit(EntityQueryPipeline.cs:23) plus theMaxPageSizeclamp (EntityControllerBase.cs:168) 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) viaDTOMapper.MapToDTOs(EntityQueryService.cs:325); aDTOToEntityPropertyMap(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:119), routing each type through its registeredIFilterStrategyrather than free-form expression evaluation, and capping rows withMaxUnboundedResultLimit(EntityQueryPipeline.cs:23). Sparse fieldsets reject non-writable properties at projection (QueryFieldService.cs:287). - 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:109,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.
Related
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:128), 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.
- 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:abcreturned 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 returningtrue, so custom strategies are unaffected until they opt in) is implemented by the six value-type strategies and checked inValidateFilters, which now emitsFilter.Value.Invalid. Presence operators still ignore the value,INneeds at least one parseable item, andBETWEENneeds exactly two bounds. - A mapped filter is applied, not silently dropped.
ApplyFiltersandValidateFiltersdisagreed on how to resolve aDTOToEntityPropertyMapentry: 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. - Pagination edges. The Skip offset was computed with checked 32-bit arithmetic, so a page
number near
int.MaxValueoverflowed into a 500 instead of the empty page it describes; it is now 64-bit and range-checked. An unpaginated read reported theMaxUnboundedResultLimitsafety cap asTotalItemCount, telling callers the set was exactly that size; it now issues a count query only when the materialized rows actually reach the cap.PaginationMetadata.PageSizereports the size the pipeline applied rather than the one requested. - The keyed by-id fast path is reachable again. The fast-path predicate treated
includeFKsas disqualifying whileGetByIdAsyncdefaults it totrue, so every REST by-id read fell through to the dynamic-filter pipeline (a parsed string predicate,TOP 1000, and a client-sideFirstOrDefault) where a keyedTOP 1 WHERE Id = @idwould 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.
- The fast-path predicate is named
TryGetFastPathIncludes. Revision item 4 above referred to it asIsPrimaryKeyOnlyLookup, 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 atEntityQueryService.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).