Architecture Decision Record
ADR-055: Repository plus Specification as the Data-Access Contract
Status
Accepted (2026-07-25). Revised 2026-08-01 (qualified the "referenced nowhere" claim about
IEntityReader / IEntityQuerier: an ADC doc comment now names IEntityQuerier, though no code
depends on either as a type; refreshed the ADC SessionsController and MMCA.Common
DependencyInjection anchors, and the leak rationale, which now reads non-accepted sessions to
non-privileged callers rather than declined sessions). Revised 2026-08-07 (withdrew that
qualification: the ADC file it pointed at no longer exists, so neither interface has any reference
outside its own declaration file; replaced the ADC concrete-specification example, which named a
class that does not exist, with the specifications ADC actually ships; refreshed the
EFReadRepository, SessionsController, and IEntityQueryService anchors). Revised 2026-08-14
(re-anchored every IRepository.cs, EFReadRepository.cs, DependencyInjection.cs, and
stage.ps1 citation after a named-query-filter remarks block shifted IRepository.cs down; narrowed
"every read" on IEntityQueryService to the four reads that actually take a specification, and
narrowed the "no reference" claim about IEntityReader / IEntityQuerier to C# code, since the
Helpdesk staging-script comments named in the same paragraph are references of a kind).
Revised 2026-08-18 (substantive: QuerySpecification gives a specification ordering, includes,
paging and tracking; composition drops Expression.Invoke for parameter substitution, retiring the
provider-bet trade-off; IEntityQuerier gains specification-first reads and keyset pagination; an
optional projector pushes DTO projection into SQL; and paginated reads become deterministically
ordered. Two Trade-offs entries below are superseded. See the Revision (2026-08-18) at the end;
the Decision section's composition bullet and its IRepository.cs / EFReadRepository.cs anchors
were re-stated against that same revision). Revised 2026-08-19 (marked the two superseded
Trade-offs entries in place, matching how ADR-014 marks its superseded order block, and refreshed
the second stage.ps1 anchor, which moved to :983). Revised 2026-08-21 (substantive in
consequence, not in decision: the narrow interfaces and the composition surface gained their first
real consumers. MMCA.ADC shipped fluent .And() composition, specification-first ListAsync reads,
and twelve IEntityReader / IEntityQuerier declarations to production; MMCA.Store has the same
narrowing staged on an open pull request. The Decision's consumption paragraph is restated, the "ISP
split is guidance" Trade-offs entry is marked superseded in place, and two sentences of the
Revision (2026-08-18) carry pointer notes. See the Revision (2026-08-21) at the end).
Revised 2026-08-23 (substantive in consequence, not in decision: the MMCA.Store counterpart the
Revision (2026-08-21) recorded as staged on an open pull request merged to main on 2026-08-22, so
the narrow read interfaces now have shipped dependents in both consumer applications. The Decision's
consumption paragraph, the superseded "ISP split is guidance" Trade-offs note, and the Store
paragraph plus the adoption-sample caution in the Revision (2026-08-21) are restated to that
reality, with the Store holders anchored by file and line for the first time).
Revised 2026-08-31 (substantive in consequence, not in decision: IEntityQuerier carries five
further members this record described nowhere, two FirstOrDefaultAsync overloads, the grouped
CountByAsync and SumByAsync, and FindIncludingDeletedAsync, so the interface is wider again;
MMCA.Helpdesk now has a narrowed IEntityReader holder of its own, which retires the "Helpdesk
consumes none of this" caution and makes three of the four repositories narrowed dependents; ADC's
narrowing grew to fifteen declarations across eleven files; MMCA.Store's AssignParentCategoryHandler
is now CategoryAssignParentUpdateHandler. Every IRepository.cs, EFReadRepository.cs,
EFReadRepositoryDecorator.cs, EntityQueryService.cs, DependencyInjection.cs and consumer anchor
is re-verified, and the superseded Expression.Invoke entry drops its line number, which now points
at unrelated text. See the Revision (2026-08-31) at the end).
Context
Every read an application handler performs has to come from somewhere, and the shape of that contract
decides whether the module can still be lifted into its own service later (ADR-007, ADR-008). The
framework already keeps persistence out of Application by reference: the interfaces live in
Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs and the EF
implementation in
Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepository.cs:19-21, so no
Application project references EF Core. That is not enough on its own. A repository that hands back
an IQueryable re-couples the caller through the back door: the query is composed inside the
handler, translated by whatever provider sits behind the DbSet, and there is no way to answer that
same call over a gRPC boundary. The generic query pipeline needs a queryable; a use-case handler does
not.
The other half of the problem is the predicate. Row-scoping (a customer sees only their own orders), business filters (published events only), and authorization scopes are all "a boolean over an entity" that must be reusable, unit-testable without a database, composable with other filters, and translatable to SQL. Written inline as lambdas inside handlers they are none of those things.
Several accepted records already lean on this contract without ever deciding it: ADR-018 depends on
criteria that stay engine-portable, ADR-033 row-scopes collection queries with an ownership
specification, ADR-035 hangs its concurrency-token hook on the write half of the repository, and
ADR-048 supplies the TIdentifierType every one of these interfaces is generic over. This record
states the contract itself.
Decision
Data access is repository plus specification: interface-segregated read interfaces for the
operations, expression-tree specifications for the predicates, and a build-failing fitness rule that
keeps the raw IQueryable surfaces out of Application code.
- The read contract is split by responsibility.
IEntityReader<TEntity, TIdentifierType>(IRepository.cs:21) carries single-entity lookups:GetByIdAsync(IRepository.cs:26,:31),GetByIdsAsync(:48), and the twoExistsAsyncoverloads (:56,:62).IEntityQuerier<TEntity, TIdentifierType>(:80) carries collection work:GetAllAsync(:85),GetProjectedAsync(:105),GetAllForLookupAsync(:222), andCountAsync(:229,:232). It also carries the reads that keep a single row or an aggregate in the database rather than folding it in memory:FirstOrDefaultAsyncover a predicate (:133) or over a specification, which honors the specification's ordering so "first" is deterministic (:148); the groupedCountByAsync(:167) andSumByAsync(:184), which are how an Application layer that references no EF Core asks for aGROUP BYinstead of projecting every row out and grouping client-side; andFindIncludingDeletedAsync(:215), which returns the active and the soft-deleted matches as two collections from one read.IReadRepositorycomposes exactly those two (:330-331), andIRepositorycomposes read plus write (:467). The interface documentation directs new handlers at the focused sub-interfaces and leaves existing code on the composite (:325-326). - The raw queryables live on the composite only.
Table,TableNoTracking,TableNoTrackingSingleQuery, andTableNoTrackingSplitQueryare declared onIReadRepository(IRepository.cs:336,:339,:342,:345) and implemented as EFDbSetexpressions (EFReadRepository.cs:406,:409,:412,:415). They are deliberately absent fromIEntityReaderandIEntityQuerier, so a handler that declares the narrow dependency cannot reach a queryable at all. - Application code must not touch those queryables, and a fitness rule fails the build.
RawQueryableConventionTestsBase.ApplicationLayer_DoesNotUseRawQueryableSurfaces(Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Cqrs/RawQueryableConventionTestsBase.cs:61) scans the.csfiles of each mapped module's Application project (:45-58,:71-82) for.Table/.TableNoTracking*member access (:103). The stated reason is the extraction promise: a raw-queryable handler is EF-coupled and its query shape cannot move behind a gRPC boundary (:6-12,:85). - Predicates are
Specificationexpression trees.ISpecification<TEntity, TIdentifierType>(Source/Core/MMCA.Common.Domain/Interfaces/ISpecification.cs:12) exposes a singleCriteriaexpression for LINQ-to-DB translation (:17) plusIsSatisfiedByfor in-memory evaluation (:22); the abstract base caches the compiled delegate (Source/Core/MMCA.Common.Domain/Specifications/Specification.cs:15,:32). A concrete specification is a constructor argument plus one expression, for exampleOrdersByCustomerSpecification(MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Application/Orders/Specifications/OrdersByCustomerSpecification.cs:13,:17). A business filter that takes no argument is smaller still, one overriddenCriteria, as in ADC'sPublishedEventSpecification(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Specifications/PublishedEventSpecification.cs:11,:14), the only class in that module'sEvents/Specificationsdirectory. Row-scoping is not hand-written per entity at all: the genericOwnedByUserSpecification<TEntity, TIdentifierType>filters on theCreatedByaudit field once in the framework (Source/Core/MMCA.Common.Domain/Specifications/OwnedByUserSpecification.cs:20,:29-30) and is closed over the entity type at the call site, which is how ADC scopes an attendee to their own answers (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Events/EventQuestionAnswersController.cs:75). - Specifications compose.
AndSpecification(Specification.cs:81),OrSpecification(:105), andNotSpecification(:128) each expose aCriteriathat delegates to the internalSpecificationComposer(:146).Combine(:155) rebinds the right operand's parameter onto the left's with anExpressionVisitor,ParameterReplacer.Replace(ParameterReplacer.cs:34), and joins the two bodies withAndAlsoorOrElse(Specification.cs:169-173);Negate(:181) wraps the inner body inExpression.Notand keeps the inner lambda's own parameter (:189-191). NoExpression.Invokeis involved, so the composed result is a single expression tree any provider can translate, and each combinator caches it in a per-instance lazy field (:91-93,:115-117,:137-138).InlineSpecification(:45) wraps a predicate that was assembled dynamically and has no hand-written class. Composition is used in production: ADC's paged session read ANDs the public-session specification with the speaker-scoped one rather than substituting, because dropping the public filter would leak non-accepted sessions to non-privileged callers (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionsController.cs:111,:129, rationale at:101-105). Since 2026-08-21 all three composing call sites write the fluent form,publicSpecification.And(...)(SessionsController.cs:129), and the hand-builtnew AndSpecification<...>(a, b)construction no longer appears in any consumer. - The specification enters the same query pipeline, not a parallel one.
IEntityQueryServicetakes an optionalISpecification<TEntity, TIdentifierType>on every DTO or entity read: the twoGetAllAsyncoverloads,GetEntityByIdAsync, andGetByIdAsync(Source/Core/MMCA.Common.Application/Interfaces/Mapping/IEntityQueryService.cs:40,:63,:110,:131). The other two reads on the interface take a raw predicate rather than a specification,GetAllForLookupAsync(:87-91) andExistsAsync(:143-146).EntityQueryServicepasses the specification'sCriteriainto the query parameters (Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:287on the list path,:519inBuildQueryAsync) alongside the dynamic filters, sorting, and paging. Cross-source predicates are produced the same way:CrossSourceSpecification.BuildAsync(Source/Core/MMCA.Common.Application/Specifications/CrossSourceSpecification.cs:39) resolves the principal keys withGetProjectedAsync(:55-56) and returns anInlineSpecificationofFK IN (keys)ANDed with the local predicate (:62-63,:74-90).
Enforcement of the queryable ban is opt-in per repository, by subclassing the base, and today
three repositories opt in. MMCA.Common scans its own framework Application project
(Tests/Architecture/MMCA.Common.Architecture.Tests/Cqrs/RawQueryableConventionTests.cs:18-22),
MMCA.ADC scans its mapped Identity/Conference/Engagement Application projects plus the thin
Notification module appended by hand
(MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Cqrs/RawQueryableConventionTests.cs:21-30),
and MMCA.Store scans its Catalog/Sales/Identity Application projects
(MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/Cqrs/RawQueryableConventionTests.cs).
Store adopted on the 2026-07-28 drift wave with an empty AllowedFiles: its Application layer
had zero raw-queryable uses at adoption, so unlike the other two it starts with no exemptions to
ratchet down. MMCA.Helpdesk has no subclass, so its Application code is not scanned today. The
rule also ships with a documented exemption list, AllowedFiles
(RawQueryableConventionTestsBase.cs:38), used as an adoption ratchet (:24-27): MMCA.Common
exempts six files, the generic query pipeline itself (EntityQueryService.cs, which builds its base
query from Table / TableNoTracking at EntityQueryService.cs:299 and :511-513) plus five Notifications
handlers (RawQueryableConventionTests.cs:26-36), and MMCA.ADC exempts eight, the Engagement live
layer and bookmark aggregations, the Identity user-list projection, and the Notification GDPR export
(MMCA.ADC/.../RawQueryableConventionTests.cs:34-52).
The interface split is shipped and, since 2026-08-21, consumed. MMCA.ADC declares the narrow
interfaces at fifteen read-only sites across eleven Application files: helper parameters narrowed to
IEntityReader where the helper only looks up or checks existence (for example
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/Validation/SessionRoomScheduling.cs:45)
or to IEntityQuerier where it projects or counts (for example
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Users/IntegrationEventHandlers/UserRegisteredHandler.cs:130),
and locals typed to the querier where a visibility helper reads through the unit of work
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:40,
:75, :126, :151). MMCA.Store ships the same narrowing at three read-only holders: a
by-id/existence service field
(MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Customers/CustomerService.cs:14),
a handler parameter narrowed to IEntityReader
(MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Application/Categories/UseCases/AssignParentCategory/CategoryAssignParentUpdateHandler.cs:98),
and a querier parameter on the customer-provisioning handler
(MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/DomainEventHandlers/UserRegisteredHandler.cs:96);
a fourth, ProductVariantService
(MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Application/Products/ProductVariantService.cs:15),
deliberately stays on IReadRepository because it calls members of both halves. MMCA.Helpdesk
narrows one site, the reference app's by-id query handler, whose local is typed to
IEntityReader<Ticket, TicketIdentifierType> and assigned from GetReadRepository
(MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Application/Tickets/UseCases/GetById/GetTicketByIdHandler.cs:24,
with the reason written next to it at :22-23), so the seed teaches the narrowing rather than only
describing it in the comments its template staging script carries
(MMCA.Helpdesk/build/templates/stage.ps1:517, :1262).
What has not changed is the wiring, and that is deliberate. IUnitOfWork hands out only the
composites (IRepository at
Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IUnitOfWork.cs:19, IReadRepository
at :29), and the container registers only the open generic IRepository<,>
(Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:119). Every narrowed holder is
assigned from IUnitOfWork.GetReadRepository<,>() (or GetRepository<,>() where the same handler
also writes) and narrows by an implicit reference conversion at the assignment, because
IReadRepository derives from both narrow interfaces (IRepository.cs:330-331). Nothing
constructor-injects IEntityReader or IEntityQuerier, and nothing should: the unit of work is
where an entity's physical data source resolves to the right context and where the repository
instance is cached for the scope, so a narrow interface resolved straight from the container would
bypass both. The ISP split is therefore the declared dependency shape at read-only call sites in all
three consumer applications, sitting on an unchanged registration surface
(IRepository.cs:325-326).
Rationale
- A narrow interface is the enforcement, not a style preference. A handler that asks for
IEntityReadercannot reachTableNoTracking, because the member is not on the interface. The fitness rule is the backstop for code that still takes the composite. - Method calls are transportable, queryables are not.
GetByIdAsync,GetProjectedAsync, andCountAsynceach map onto a request/response message; a composedIQueryableonly means anything to the provider that will translate it, which is precisely why the rule exists (RawQueryableConventionTestsBase.cs:6-12). - One expression tree serves both the database and the unit test.
Criteriais translated to SQL in a query and compiled in memory byIsSatisfiedBy(Specification.cs:9-11,:32), so an authorization predicate can be asserted in a domain test with no database at all. - Composability keeps filters additive. ANDing an authorization scope onto a business filter is a
two-line construction (
SessionsController.cs:128) instead of a second hand-written query path, which is what makes "never substitute the public filter" a cheap rule to follow. - A textual scan was chosen deliberately over IL analysis. NetArchTest and reflection cannot see
member usage inside method bodies, and the testing package carries no IL or Roslyn dependency on
purpose, so the rule is an honest line scan with its limits written down
(
RawQueryableConventionTestsBase.cs:13-23). - The ratchet beats a big-bang cleanup. Recording existing raw-queryable files in
AllowedFilesmakes new code clean immediately while the list shrinks over time (:24-27), the same adoption posture used elsewhere in the fitness suite (ADR-015).
Trade-offs
- The ISP split is guidance, not yet a wired dependency. (Superseded by the Revision
(2026-08-21) below: the split has real dependents shipped in both MMCA.ADC and MMCA.Store, obtained
from the unchanged
IUnitOfWorkaccessors by implicit conversion, so no change to how the repository is obtained was needed after all; kept as the record of the trade-off as originally accepted.) Because the only accessors return the composites (IUnitOfWork.cs:19,:29), depending onIEntityReadertoday means changing how the repository is obtained. Until that happens, the split buys documentation and future optionality rather than a compiler-enforced narrowing. - The queryable ban is textual, so it is both over- and under-inclusive. It skips only whole-line
//comments, so a match inside a string literal or a trailing comment is a false positive, and it cannot see through variable indirection or an interface alias that re-exposes a queryable (RawQueryableConventionTestsBase.cs:17-22). - Coverage is partial by construction. Three of the four repositories run the rule; MMCA.Helpdesk currently does not, so its Application layer relies on review alone.
- The exemptions are real coupling, not paperwork. The fourteen allowlisted files across MMCA.Common and MMCA.ADC are EF-coupled on purpose (aggregations and projections the focused surface cannot express); each is intra-module or framework-owned, but every one of them would need rework if its module moved behind a transport boundary.
Expression.Invokecomposition is a provider bet. (Superseded by the Revision (2026-08-18) below: the combinators now rebind parameters viaParameterReplacerandExpression.Invokeno longer appears inSpecification.cs; kept as the record of the trade-off as originally accepted, without a line anchor: the numbers it cited now point at unrelated text.) The And/Or/Not combinators embed each operand withExpression.InvokeinSpecification.cs, which the SQL Server provider translates; the cross-source helper deliberately avoids it and rebinds parameters instead so the combined predicate stays translatable on every provider (CrossSourceSpecification.cs:83-87). Composed specifications are therefore not automatically portable to every engine.- A specification carries only a predicate. (Superseded by the Revision (2026-08-18) below:
QuerySpecificationnow also carries ordering, includes, paging and tracking;ISpecificationitself is unchanged.)ISpecificationexposesCriteriaand nothing else (ISpecification.cs:17), so includes, ordering, paging, and projection stay parameters of the repository and query-service methods. This is a smaller specification pattern than the variants that also own eager-load and sort state.
Related
ADR-007 and ADR-008 (the extraction promise the queryable ban exists to protect), ADR-015 (the
fitness-function machinery that runs this rule and its per-repo maps), ADR-014 (the handlers that
consume this contract), ADR-018 (engine portability of a specification's criteria, and the
SpecificationsDoNotNavigateToOtherEntities guard on it), ADR-033 (the ownership specification that
row-scopes collection queries through this contract), ADR-034 (the HTTP query contract one layer
above: dynamic filters and paging arrive from the request, the specification is applied beneath
them), ADR-035 (the concurrency-token hooks on the write half of the repository), ADR-006 (the
per-service database each repository instance resolves to), ADR-048 (the identifier aliases that
supply TIdentifierType, and ADR-085, whose migration
blast radius includes every generic surface named here), ADR-001 (the Mapperly mappers the optional
projector reuses as an expression rather than as a method call).
Revision (2026-08-18)
Five changes, four of them widening the contract and one of them fixing a correctness defect. The
decision this record states is unchanged: data access is still repository plus specification, the raw
queryables still live on the composite only, and the fitness rule still fails the build on .Table in
Application code. What the specification can now carry, and what the querier can now be asked, both
grew.
1. A specification can carry more than a predicate
QuerySpecification<TEntity, TIdentifierType>
(MMCA.Common/Source/Core/MMCA.Common.Domain/Specifications/QuerySpecification.cs:38) extends
Specification with the query state the last Trade-offs entry above says a specification deliberately
does not hold: OrderBy as an ordered IReadOnlyList<OrderExpression> (:54), IncludePaths
(:60), Skip / Take (:63, :66), AsTracking (:72, defaulting false) and
IgnoreQueryFilters (:82, defaulting false and dropping only the named SoftDelete filter, not the
ADR-073 tenant filter). Subclasses populate it through protected builders rather than by assignment:
AddOrderBy<TKey>(keySelector, descending = false) (:90), AddInclude(path) (:102, ignoring
blanks and duplicates), ApplyPaging(skip, take) (:117, both floored at zero), WithTracking()
(:127) and WithSoftDeleted() (:133). OrderExpression is a top-level
record (LambdaExpression KeySelector, bool Descending) (:150).
That entry is therefore superseded, not corrected: the smaller predicate-only shape remains
available as Specification, and a caller who wants the richer one derives from QuerySpecification.
ISpecification itself is unchanged, which is what keeps the two shapes interchangeable everywhere a
predicate is all that is consumed.
2. Composition no longer bets on Expression.Invoke
The Trade-offs entry titled "Expression.Invoke composition is a provider bet" is retired. The
combinators now do what CrossSourceSpecification was already doing by hand: an
ExpressionVisitor rebinds one operand's parameter onto the other's.
ParameterReplacer (.../Domain/Specifications/ParameterReplacer.cs:24, static entry Replace at
:34, with a ReferenceEquals short-circuit) is driven by SpecificationComposer
(Specification.cs:146), whose Combine (:155) rebinds right.Parameters[0] onto
left.Parameters[0] and joins the bodies with AndAlso or OrElse, and whose Negate (:181)
wraps the inner body in Expression.Not keeping its own parameter. Expression.Invoke no longer
appears in the file, so a composed specification is now translatable on every provider rather than
only the ones that inline an invocation, and the divergence between the combinators and the
cross-source helper is closed.
The composed criteria is built once per specification instance, through a lazy field
(_criteria ??= ... in AndSpecification at Specification.cs:88,91, OrSpecification at
:112,115, NotSpecification at :134,137). It is not a shared or global cache: two separately
constructed AndSpecifications over the same operands each build their own tree.
Composition also gained a fluent form. SpecificationExtensions
(.../Domain/Specifications/SpecificationExtensions.cs:30) declares And (:48), Or (:68) and
Not (:85) as extension members on ISpecification<TEntity, TIdentifierType>, written with a
C# extension block (extension<TEntity, TIdentifierType>(ISpecification<...> specification) at
:32), not as instance methods on Specification. spec.And(other).Not() therefore reads as a
chain while the abstract base stays untouched, and the existing explicit
new AndSpecification<...>(a, b) construction the Decision above cites keeps working unchanged.
(As of the Revision (2026-08-21) below, the three consumer call sites all use the fluent form; the
explicit construction remains supported but has no caller.)
3. The querier answers specification-first reads
IEntityQuerier (IRepository.cs:80) gains four members that take an ISpecification rather than a
raw predicate or a bag of query parameters: CountAsync (:243, ordering and paging deliberately
ignored), ListAsync (:260), a projecting ListAsync<TResult>(specification, select, ...)
(:279), and AnyAsync (:291), implemented on EFReadRepository and forwarded by
EFReadRepositoryDecorator (:118, :132, :138, :145). Alongside them, IEntityQueryService
widened from the abstract Specification<TEntity, TIdentifierType>? to the interface
ISpecification<TEntity, TIdentifierType>? on all four of the members that take one
(IEntityQueryService.cs:40, :63, :110, :131), which is what lets a QuerySpecification, a
composed one, or an InlineSpecification be passed to the same reads.
The narrow-interface observation in the Trade-offs is unchanged in kind and sharper in consequence:
IUnitOfWork still hands out only the composites, so these members arrive on an interface nothing
depends on by name. What changed is that IEntityQuerier is now the only place several of these
reads exist, so the ISP split has moved from documentation toward being the shape a handler would
actually want. (That prediction landed: the Revision (2026-08-21) below records the first
dependents by name, and the projecting ListAsync is among the members they call.)
4. Projection can be pushed into SQL
An optional IEntityDTOProjector<TEntity, TEntityDTO, TIdentifierType>
(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Mapping/IEntityDTOProjector.cs:51) exposes one
member, IQueryable<TEntityDTO> ProjectTo(IQueryable<TEntity> source) (:62), and is auto-registered
by the Application assembly scan (.../Application/DependencyInjection.cs:209). When one is
registered, the read takes a server-side path, ExecuteProjectedAsync
(.../Application/Services/Query/IEntityQueryPipeline.cs:58, implemented at
EntityQueryPipeline.cs:60), which applies the projection last, after criteria, dynamic filters,
sorting and paging (:101-105), so the database returns only the DTO's columns instead of whole
entities that are mapped in memory afterwards.
The guard has three conditions, not two (EntityQueryService.cs:490): a projector must be
registered, the read must not be tracking, and NavigationMetadata.UnsupportedIncludes must be empty,
that last set being the navigations requiring manual batch loading because they cross a data source
(INavigationMetadata.cs:40). Miss any one and the read falls back to materialize-then-map, which is
exactly today's behavior, so this is a pure opt-in optimization with no change in results. The call
site is EntityQueryService.cs:304, reached by both list overloads (:228 delegates to :249). The
reference implementation is PushNotificationDTOProjector
(.../Application/Notifications/PushNotifications/DTOs/PushNotificationDTOProjector.cs:35, registered
at .../Notifications/DependencyInjection.cs:51), which wraps the existing Mapperly mapper's
projection form (PushNotificationDTOProjection, :22) rather than hand-writing a Select, so ADR-001's
generated mapper is reused as an expression tree instead of as a method call.
5. Keyset pagination, and deterministic ordering
GetPageByCursorAsync is declared on IEntityQuerier alone (IRepository.cs:316, inherited by
IReadRepository at :330, implemented at EFReadRepository.cs:496 and forwarded at
EFReadRepositoryDecorator.cs:151):
Task<Result<KeysetCollectionResult<TEntity>>> GetPageByCursorAsync(
KeysetPageRequest request,
ISpecification<TEntity, TIdentifierType>? specification = null,
CancellationToken cancellationToken = default);
It is deliberately not on IEntityQueryService, so keyset paging is a repository-level capability
today and the ADR-034 HTTP query contract still offers offset paging only. KeysetPageRequest and
KeysetCollectionResult<T> live in
MMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/KeysetPagination.cs (:20, :85); the
request clamps PageSize into [1, 1000] in both the constructor and the init accessor (:51,
:59-62, ceiling at :26) and carries SortColumn / Descending / Cursor (:67, :71, :75),
and the result extends CollectionResult<T> with a single NextCursor (:107) and deliberately no
total count and no page number, which is the point of keyset paging.
The cursor is opaque but versioned: KeysetCursor (:125) encodes base64url over
v1|{hasSortValue}|{sortValue}|{id} with Version at :127 and each of the two value segments
itself base64url-encoded (Encode at :139-153), so the explicit null flag is what distinguishes a
missing sort value from an empty one. TryDecode (:169) rejects bad base64, a wrong version, a
wrong segment count, or a flag that is not 0/1, and failures are Result values, never
exceptions: an unusable cursor returns Error.Validation("Error.InvalidCursor", ...)
(EFReadRepository.cs:522-527) and an unknown sort column returns Error.InvalidEntityField
(:505-511), both ErrorType.Validation and both therefore mapping to a 400 through the ADR-013
edge. One escape remains: KeysetQueryBuilder.Compare throws NotSupportedException for a sort
column whose type has neither a relational operator nor IComparable<T>
(.../Repositories/KeysetQueryBuilder.cs:246), which is a programming error rather than bad input,
but it is an exception on a Result-returning path and is recorded as such.
Paged reads are now deterministically ordered, which was a defect. A page without a total ordering
can repeat or skip rows between pages, and nothing previously guaranteed one.
EntityQueryPipeline declares PaginationTieBreakProperty = "Id" (:36) and passes it on all three
execution paths, the projected one included (:86, :180, :232), but only when the read is
paginated (PageNumber.HasValue && PageSize.HasValue). QueryFieldService.ApplySorting (:155)
then appends ", Id ascending" to a caller-supplied sort unless the caller already sorted by Id
(BuildOrdering at :209, :214-217), and falls back to Id ascending alone when there is no valid
sort column and no default sort (:180-182). An unpaginated read still gets no ordering at all,
deliberately (EntityQueryPipeline.cs:29-35): sorting a full result set the caller did not ask to
sort is a cost with no correctness benefit. The keyset builder enforces the same discipline
independently, ordering by (sortKey, Id) with the tie-break always ascending or by Id alone when
there is no sort key (KeysetQueryBuilder.cs:59, :70, :74).
What this revision costs
- The contract is materially wider.
IEntityQueriergained five members andISpecificationhas a second, richer implementation shape. (As of the Revision (2026-08-31) below, five further members have landed on the same interface, so it carries ten more than the shape the Decision originally described.) Every one of them is public API on a lockstep-released package family (ADR-016), so the surface consumers inherit is larger and the ISP split is correspondingly less narrow than the Rationale above describes. QuerySpecificationreintroduces the coupling the predicate-only shape avoided. Ordering, includes and paging inside a specification means a specification now encodes query intent, not just a domain predicate, so the same object is less obviously reusable in a domain unit test throughIsSatisfiedBy.IgnoreQueryFiltersis a sharp edge.WithSoftDeleted()drops the namedSoftDeletefilter (QuerySpecification.cs:82), which is exactly the invariant ADR-005 relies on. It is scoped to that one named filter rather than being EF's blanketIgnoreQueryFilters, but it is still a specification able to turn off soft-delete for the reads that use it.- Projection pushdown succeeds or falls back silently. Nothing tells a caller which path ran, so a projector that stops being registered, or a read that quietly acquires a cross-source include, loses the optimization with no signal beyond query latency.
- Keyset paging stops at the repository. With no
IEntityQueryServiceor controller surface, the generic HTTP query contract cannot offer it, so a caller wanting stable deep paging today writes a bespoke endpoint, which is the shape ADR-034 exists to avoid.
Revision (2026-08-21)
Nothing in the contract changed; its consumers did. This revision records the first real adoption of the two surfaces this record had honestly flagged as unconsumed: the narrow read interfaces and the composition members.
MMCA.ADC shipped the adoption to production (merged and deployed 2026-08-21). Three kinds of change, none of them behavioral:
- Fluent composition replaced hand-built combinators. The workspace's three composing call sites
now write
a.And(b)(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionsController.cs:129,.../Speakers/SpeakersController.cs:174, and.../MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:148-149), so theSpecificationExtensionsmembers from the Revision (2026-08-18) have production callers andnew AndSpecification<...>(a, b)no longer appears in any consumer. - Two reads pass the specification instead of unwrapping it.
PublicConferenceVisibilitypreviously handedspecification.CriteriatoGetProjectedAsync; both sites now call the projecting specification-firstListAsync(specification, select, ...)(PublicConferenceVisibility.cs:78,:154). For a plain specification the two are equivalent reads (untracked, soft-delete-filtered), which is what made the substitution safe. - Twelve read-only declarations across eight files narrowed from
IRepository<,>toIEntityReader<,>(by-id and existence helpers) orIEntityQuerier<,>(projection and count helpers, and the visibility helper's locals), each still assigned from the unit of work. Helpers that also write kept the composite, correctly. (The count has since grown: the Revision (2026-08-31) below records fifteen declarations across eleven files.)
MMCA.Store ships the counterpart (merged to main on 2026-08-22, after this revision was
written; restated here as of 2026-08-23, and re-anchored 2026-08-31 after the category handler was
renamed): three read-only holders narrowed
(CustomerService.cs:14, CategoryAssignParentUpdateHandler.cs:98, UserRegisteredHandler.cs:96),
and ProductVariantService deliberately left on IReadRepository
(ProductVariantService.cs:15) because it calls ExistsAsync (a reader member, :20, :26) and
GetProjectedAsync (a querier member, :38, :57, :71) from the same field. That refusal is the
split working as designed: a holder that genuinely needs both halves says so by keeping the
composite. Both applications also state the convention in their CLAUDE.md files, so new read-only
code is pointed at the narrow interfaces by guidance as well as by example.
What this revision does not claim
- The registration surface is untouched, deliberately. No DI registration was added for the
narrow interfaces and none should be: a container-resolved
IEntityQuerierwould bypass the unit of work's data-source resolution and its per-scope repository cache (Source/Core/MMCA.Common.Infrastructure/Persistence/UnitOfWork.cs:23,:53-66). The implicit conversion fromGetReadRepository<,>()is the supported acquisition path. - Half the composition surface still has no caller.
OrandNot, the specification-takingCountAsyncandAnyAsync, andGetPageByCursorAsynchave zero application callers in any of the four repositories: the only references are the interface declaration and the profiling decorator's forward. The "untested by usage" caution above still applies to the members nothing calls. (Restated 2026-08-31: the adoption sample is no longer two applications. MMCA.Helpdesk narrows one holder of its own, so three of the four repositories have a narrowed dependent; the members listed here still have none.)
Revision (2026-08-31)
Again nothing in the decision changed. Two things did: the querier grew a second time, and the reference application joined the two production apps as a consumer of the narrow read interfaces.
The querier carries five more members
IEntityQuerier (IRepository.cs:80) now also declares, beyond the collection members the Decision
above names and the four specification-first reads from the Revision (2026-08-18):
FirstOrDefaultAsync(where, includes, asTracking, ignoreQueryFilters, ...)(:133) andFirstOrDefaultAsync(specification, ...)(:148). The point of both is that the database returns one row: without them a caller reaches forGetAllAsyncplus an in-memoryFirstOrDefault, which materializes the whole matching set to keep one entity. The predicate overload applies no ordering, so "first" is whatever the provider returns first; the specification overload honors the specification's ordering, which is what makes the choice among several matches deterministic.CountByAsync<TKey>(keySelector, where, ...)(:167) andSumByAsync<TKey>(keySelector, sumSelector, where, ...)(:184), aGROUP BYand aGROUP BYwithSUM, each returning one entry per key that has at least one row. These exist because the Application layer references no EF Core and therefore has noIQueryableto group: the alternative is projecting every matching row out of the database and folding it client-side, so these members are how only the aggregate crosses the wire.FindIncludingDeletedAsync(...)(:215), which returns the active and the soft-deleted matches as two separate collections from one read, rather than making a caller drop theSoftDeletefilter and re-partition the result itself.
The widening cost recorded in "What this revision costs" above applies unchanged and doubled: these
are public API on a lockstep-released package family (ADR-016), the surface every consumer inherits
whether or not it calls them, and IEntityQuerier is correspondingly less narrow than the Rationale's
"a narrow interface is the enforcement" argument implies. What has not changed is the enforcement
itself: none of the five hands back an IQueryable, so a handler holding the narrow interface still
cannot reach a raw queryable.
MMCA.Helpdesk narrows too
The reference application now declares a narrow holder of its own. GetTicketByIdHandler types its
local to IEntityReader<Ticket, TicketIdentifierType> and assigns it from
unitOfWork.GetReadRepository<Ticket, TicketIdentifierType>()
(MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Application/Tickets/UseCases/GetById/GetTicketByIdHandler.cs:24),
with the reasoning written beside it (:22-23): the read repository rather than the write surface,
and the reader rather than the composite, because a by-id lookup is all the handler does. That is the
same acquisition path both production applications use, so the seed now demonstrates the narrowing
instead of only describing it, which matters more here than in either app: this tree is also the
mmca-app template source, so an adopter's scaffolded query handler starts narrowed.
Three of the four repositories therefore have a narrowed dependent. Coverage of the queryable fitness
rule is unrelated and unchanged: MMCA.Helpdesk still ships no
RawQueryableConventionTests subclass, so the Trade-offs entry above stands as written.
The consumer anchors moved
MMCA.ADC's narrowing grew from twelve declarations across eight Application files to fifteen across
eleven, the new ones being Engagement's vote, upvote, badge, check-in and leaderboard paths plus
Conference's room and session update handlers; SessionRoomScheduling.cs:45 and the four
PublicConferenceVisibility locals are where the Revision (2026-08-21) left them. MMCA.Store still
ships exactly three narrowed holders, but AssignParentCategoryHandler is now
CategoryAssignParentUpdateHandler, so the file this record cited no longer exists under that name.
Neither change touches the contract; both are recorded so the citations above resolve.