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

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). Revised 2026-10-01 (consumer growth and re-anchoring: MMCA.Store narrows a fourth holder and composes fluently at two sites, and the generic pipeline caps unpaginated reads; see the Revision (2026-10-01) at the end). Revised 2026-10-06 (the And/Or/Not combinators now refuse a shaped QuerySpecification with an ArgumentException, so composition is criteria-only by enforcement; ADC's narrowing is restated as forty-four sites across twenty-seven files, one of them an API controller; see the Revision (2026-10-06) at the end). Revised 2026-10-21: ADC's narrowing grew to fifty sites across thirty-one files, its raw-queryable allowlist shrank to seven files with no Identity entry (thirteen across Common and ADC), ADC composes at five fluent sites including the first production .Not(), and the Store specification-taking CountAsync caller the Revision (2026-10-01) recorded no longer exists; see the Revision (2026-10-21) at the end. Revised 2026-10-26: anchors refreshed after line shifts in IRepository.cs, the ADC SessionsController.cs, the MMCA.Store UserRegisteredHandler.cs and stage.ps1; see the Revision (2026-10-26) 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:21-23, 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 two ExistsAsync overloads (:56, :62). IEntityQuerier<TEntity, TIdentifierType> (:80) carries collection work: GetAllAsync (:85), GetProjectedAsync (:105), GetAllForLookupAsync (:224), and CountAsync (:231, :234). It also carries the reads that keep a single row or an aggregate in the database rather than folding it in memory: FirstOrDefaultAsync over a predicate (:134) or over a specification, which honors the specification's ordering so "first" is deterministic (:150); the grouped CountByAsync (:169) and SumByAsync (:186), which are how an Application layer that references no EF Core asks for a GROUP BY instead of projecting every row out and grouping client-side; and FindIncludingDeletedAsync (:217), which returns the active and the soft-deleted matches as two collections from one read. IReadRepository composes exactly those two (:332-333), and IRepository composes read plus write (:485). The interface documentation directs new handlers at the focused sub-interfaces and leaves existing code on the composite (:327-328).
  • The raw queryables live on the composite only. Table, TableNoTracking, TableNoTrackingSingleQuery, and TableNoTrackingSplitQuery are declared on IReadRepository (IRepository.cs:338, :341, :344, :347) and implemented as EF DbSet expressions (EFReadRepository.cs:524, :527, :530, :533). They are deliberately absent from IEntityReader and IEntityQuerier, 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 .cs files 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 Specification expression trees. ISpecification<TEntity, TIdentifierType> (Source/Core/MMCA.Common.Domain/Interfaces/ISpecification.cs:12) exposes a single Criteria expression for LINQ-to-DB translation (:17) plus IsSatisfiedBy for 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 example OrdersByCustomerSpecification (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 overridden Criteria, as in ADC's PublishedEventSpecification (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Specifications/PublishedEventSpecification.cs:11, :14), the only class in that module's Events/Specifications directory. Row-scoping is not hand-written per entity at all: the generic OwnedByUserSpecification<TEntity, TIdentifierType> filters on the CreatedBy audit 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:113, inside the GetExportSpecification override at :109-114, resolved through OwnershipHelper.GetOwnershipSpecification at :110).
  • Specifications compose. AndSpecification (Specification.cs:81), OrSpecification (:107), and NotSpecification (:132) each expose a Criteria that delegates to the internal SpecificationComposer (:151). Combine (:198) rebinds the right operand's parameter onto the left's with an ExpressionVisitor, ParameterReplacer.Replace (ParameterReplacer.cs:34), and joins the two bodies with AndAlso or OrElse (Specification.cs:212-216); Negate (:224) wraps the inner body in Expression.Not and keeps the inner lambda's own parameter (:231-234). No Expression.Invoke is 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 (:90 with ??= at :94, :116 with :120, :139 with :143). Composition is criteria-only, and enforced as such: every combinator constructor passes its operands through SpecificationComposer.RejectShaped (:88-89, :114-115, :138; defined at :165-179), which throws ArgumentException when an operand is a QuerySpecification carrying ordering, includes, paging, tracking or soft-delete scope (HasShape, :181-189), because the composed specification could not forward that shape and would drop it silently. An unshaped QuerySpecification composes like any other, and the check applies equally to the fluent .And() / .Or() / .Not(), which construct the same classes. 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:113, :135, rationale at :108-111). Every composing call site writes the fluent form, five in MMCA.ADC, four .And() calls, for example publicSpecification.And(...) (SessionsController.cs:135), and one .Not() on the Identity user-list read, the first production caller of Not (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UserListEntityQueryService.cs:172), and two in MMCA.Store (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.API/Controllers/ReviewsController.cs:106, MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Application/Reviews/DomainEventHandlers/ProductReviewChangedHandler.cs:111), and the hand-built new AndSpecification<...>(a, b) construction no longer appears in any consumer.
  • The specification enters the same query pipeline, not a parallel one. IEntityQueryService takes an optional ISpecification<TEntity, TIdentifierType> on every DTO or entity read: the two GetAllAsync overloads, GetEntityByIdAsync, and GetByIdAsync (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) and ExistsAsync (:143-146). EntityQueryService passes the specification's Criteria into the query parameters (Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:322 on the list path, :599 in BuildQueryAsync) 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 with GetProjectedAsync (:55-56) and returns an InlineSpecification of FK 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:335 and :591-593) plus five Notifications handlers (RawQueryableConventionTests.cs:26-36), and MMCA.ADC exempts seven, the Engagement live layer and bookmark aggregations and the Notification GDPR export (MMCA.ADC/.../RawQueryableConventionTests.cs:33-49); no Identity file remains on its list.

The interface split is shipped and, since 2026-08-21, consumed. MMCA.ADC declares the narrow interfaces at fifty read-only sites across thirty-one files, thirty of them Application files plus one API controller local (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionSelectionController.cs:142): 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:149, :186), 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 four read-only holders: two by-id/existence service fields (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Customers/CustomerService.cs:14, and MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/AuthenticationService.cs:49, assigned from GetReadRepository at :50), a handler parameter narrowed to IEntityReader (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Application/Categories/UseCases/AssignParentCategory/CategoryAssignParentUpdateHandler.cs:120), and a querier parameter on the customer-provisioning handler's LogCollisionAsync helper (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/DomainEventHandlers/UserRegisteredHandler.cs:130); ProductVariantService (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Application/Products/ProductVariantService.cs:21) 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:670, :1436).

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:128). 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:332-333). 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:327-328).

Rationale

  • A narrow interface is the enforcement, not a style preference. A handler that asks for IEntityReader cannot reach TableNoTracking, 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, and CountAsync each map onto a request/response message; a composed IQueryable only 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. Criteria is translated to SQL in a query and compiled in memory by IsSatisfiedBy (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 single-expression construction (SessionsController.cs:133-135) 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 AllowedFiles makes 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 IUnitOfWork accessors 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 on IEntityReader today 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 thirteen 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.Invoke composition is a provider bet. (Superseded by the Revision (2026-08-18) below: the combinators now rebind parameters via ParameterReplacer and Expression.Invoke no longer appears in Specification.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 with Expression.Invoke in Specification.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: QuerySpecification now also carries ordering, includes, paging and tracking; ISpecification itself is unchanged.) ISpecification exposes Criteria and 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.

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. IEntityQuerier gained five members and ISpecification has 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.
  • QuerySpecification reintroduces 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 through IsSatisfiedBy.
  • IgnoreQueryFilters is a sharp edge. WithSoftDeleted() drops the named SoftDelete filter (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 blanket IgnoreQueryFilters, 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 IEntityQueryService or 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 the SpecificationExtensions members from the Revision (2026-08-18) have production callers and new AndSpecification<...>(a, b) no longer appears in any consumer.
  • Two reads pass the specification instead of unwrapping it. PublicConferenceVisibility previously handed specification.Criteria to GetProjectedAsync; both sites now call the projecting specification-first ListAsync(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<,> to IEntityReader<,> (by-id and existence helpers) or IEntityQuerier<,> (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 IEntityQuerier would 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 from GetReadRepository<,>() is the supported acquisition path.
  • Half the composition surface still has no caller. Or and Not, the specification-taking CountAsync and AnyAsync, and GetPageByCursorAsync have 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) and FirstOrDefaultAsync(specification, ...) (:148). The point of both is that the database returns one row: without them a caller reaches for GetAllAsync plus an in-memory FirstOrDefault, 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) and SumByAsync<TKey>(keySelector, sumSelector, where, ...) (:184), a GROUP BY and a GROUP BY with SUM, 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 no IQueryable to 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 the SoftDelete filter 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.

Revision (2026-10-01)

No decision or rationale changed; the consumers grew again and the anchors moved. MMCA.ADC now declares IEntityReader / IEntityQuerier at forty read-only sites across twenty-three Application files (new ones include the Conference decision-support handlers, SessionAssetAccessService and the Engagement live-poll handlers), and its raw-queryable allowlist holds nine files, adding the Identity UserAdministrationService.cs (MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Cqrs/RawQueryableConventionTests.cs:54), so MMCA.Common and MMCA.ADC together exempt fifteen. MMCA.Store narrows a fourth holder, AuthenticationService.cs:54, and contributes two fluent .And() call sites (ReviewsController.cs:106, ProductReviewChangedHandler.cs:111), so the workspace has five composing call sites and still no hand-built AndSpecification, OrSpecification or NotSpecification construction. The "zero application callers" note in the Revision (2026-08-21) no longer holds for two of its members: MMCA.Store calls the specification-taking CountAsync (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/Administration/UserAdministrationService.cs:67) and AnyAsync (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Application/Reviews/UseCases/Submit/SubmitReviewHandler.cs:59, .../Reviews/UseCases/Eligibility/GetReviewEligibilityHandler.cs:40); Or, Not and GetPageByCursorAsync still have no caller in MMCA.ADC, MMCA.Store or MMCA.Helpdesk Source. The generic query pipeline also caps every unpaginated read at MaxUnboundedResultLimit (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/EntityQueryPipeline.cs:23, applied at :99, :195, :250), which the Revision (2026-08-18) does not describe. Re-anchored in the current-state sections: every IRepository.cs, EFReadRepository.cs, EntityQueryService.cs, DependencyInjection.cs, SessionsController.cs, EventQuestionAnswersController.cs, UserRegisteredHandler.cs (both apps), ProductVariantService.cs and stage.ps1 citation. The anchors inside the earlier Revision sections are left as historical records; the current lines for the ones that moved are IEntityDTOProjector scan at DependencyInjection.ModuleScanning.cs:76, project-last at EntityQueryPipeline.cs:102-106, tie-break passed at :86, :181, :234, QueryFieldService.ApplySorting at :155 forwarding to :184 with BuildOrdering at :259, KeysetQueryBuilder ordering at :62, :77, :80-81 and its NotSupportedException at :305, GetPageByCursorAsync at EFReadRepository.cs:621 with Error.InvalidEntityField at :631 and Error.InvalidCursor at :649, and SpeakersController.cs:165.

Revision (2026-10-06)

No decision changed; one enforcement rule landed and the consumer count moved.

  • Composition is now criteria-only by enforcement. SpecificationComposer.RejectShaped (MMCA.Common/Source/Core/MMCA.Common.Domain/Specifications/Specification.cs:165-179, with HasShape at :181-189) runs in every combinator constructor (:88-89, :114-115, :138) and throws ArgumentException for a QuerySpecification operand that carries ordering, includes, paging, tracking or soft-delete scope. The Revision (2026-08-18) statement that an explicit new AndSpecification<...>(a, b) "keeps working unchanged" therefore holds only for unshaped operands; the same applies to the fluent .And() / .Or() / .Not(). The Decision's composition bullet now states the rule.
  • ADC's narrowing is restated. The Revision (2026-10-01) count of forty sites across twenty-three Application files is now forty-four across twenty-seven files, and one of them is no longer an Application file: an API controller local at SessionSelectionController.cs:142. The Decision paragraph is restated to that.
  • Current locations for anchors recorded only in earlier Revisions: SpecificationComposer at Specification.cs:151, Combine :198, the join :212-216, Negate :224 (parameter kept :231-234), lazy fields :90/:94, :116/:120, :139/:143; SpecificationExtensions class :36, extension block :38, And :54, Or :74, Not :91; QuerySpecification class :43, OrderBy :59, IncludePaths :65, Skip / Take :68 / :71, AsTracking :77, IgnoreQueryFilters :87, AddOrderBy :95, AddInclude :107, ApplyPaging :122, WithTracking :132, WithSoftDeleted :138, OrderExpression :155; the specification-first reads in IRepository.cs at :245 (CountAsync), :262 / :281 (ListAsync), :293 (AnyAsync), :318 (GetPageByCursorAsync), implemented in EFReadRepository.cs at :448, :587, :600, :618 and :632, with Error.InvalidEntityField at :642 and Error.InvalidCursor at :660; the projector guard CanProject at EntityQueryService.cs:541-544, called at :339 with ExecuteProjectedAsync at :343-344, the list overload at :262 delegating to :283; UnitOfWork at UnitOfWork.cs:20, its per-scope _repositories cache :34, GetRepository :48-61 and GetReadRepository :68-81; SpeakersController.cs:186.
  • Every anchor in the current-state sections (Context, Decision, Rationale) was re-verified against current source; EFReadRepository.cs, IRepository.cs, Specification.cs, DependencyInjection.cs, SessionsController.cs, EventQuestionAnswersController.cs, AuthenticationService.cs and CategoryAssignParentUpdateHandler.cs citations moved.

Revision (2026-10-21)

Re-verified against current source. The decision, the read-interface split, the composition rules and the fitness rule are unchanged; the consumer counts, the allowlists and one caller note moved.

  1. ADC's narrowing is restated. IEntityReader< / IEntityQuerier< now appear fifty times across thirty-one files under MMCA.ADC/Source: thirty Application files plus the API controller local, still at SessionSelectionController.cs:142. The Decision paragraph is restated to that; its example anchors (SessionRoomScheduling.cs:45, UserRegisteredHandler.cs:149, :186, PublicConferenceVisibility.cs:40, :75, :126, :151) still hold.
  2. ADC's raw-queryable allowlist shrank to seven files. AllowedFiles (MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Cqrs/RawQueryableConventionTests.cs:33-49, entries :37-48) holds the four Engagement live-layer builders and handlers, the two bookmark aggregations and the Notification GDPR export. The Identity user-list projection and UserAdministrationService.cs are no longer on it. MMCA.Common still exempts six (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Cqrs/RawQueryableConventionTests.cs:28-36), so the two repositories exempt thirteen, not fifteen; the Decision and the Trade-offs entry are restated.
  3. ADC composes at five sites, and Not has its first production caller. Four fluent .And() calls (SessionsController.cs:136, SpeakersController.cs:187, PublicConferenceVisibility.cs:149, UserListEntityQueryService.cs:111) and one .Not() (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UserListEntityQueryService.cs:172). With Store's two (ReviewsController.cs:106, ProductReviewChangedHandler.cs:111) the consumers have seven composing call sites, and consumer Source (MMCA.ADC, MMCA.Store, MMCA.Helpdesk) still holds no hand-built AndSpecification, OrSpecification or NotSpecification construction (the only Source constructions are the fluent extension bodies, MMCA.Common/Source/Core/MMCA.Common.Domain/Specifications/SpecificationExtensions.cs:59, :79, :95). The Revision (2026-10-01) statement that Not has no caller no longer holds; Or and GetPageByCursorAsync still have none in MMCA.ADC, MMCA.Store or MMCA.Helpdesk Source.
  4. The specification-taking CountAsync has no caller again. The Revision (2026-10-01) cited a MMCA.Store call at UserAdministrationService.cs:67; MMCA.Store Source now has no CountAsync call at all, and every CountAsync call in MMCA.ADC Source passes a predicate (for example GetUserBookmarksHandler.cs:58, whose argument is a lambda assigned at :50 and :54). The specification-taking AnyAsync callers are unchanged (SubmitReviewHandler.cs:59, GetReviewEligibilityHandler.cs:40).
  5. Anchors re-verified against current source: raw queryables at EFReadRepository.cs:524, :527, :530, :533; EntityQueryService.cs criteria at :322 and :599, base query at :335 and :591-593; SessionsController.cs method at :114, .And at :136, leak rationale :108-112, Rationale ternary :134-136; stage.ps1 comments at :670 and :1416. Anchors inside the earlier Revision sections are left as historical records.

Revision (2026-10-26)

Re-verified against current source. The decision, the read-interface split, the composition rules, the fitness rule and every consumer count are unchanged; only anchors in the live sections moved.

  1. Anchors re-verified against current source: IRepository composes read plus write at IRepository.cs:485 (IReadRepository still at :332-333, guidance still at :327-328); ADC SessionsController.cs BuildPagedSessionSpecificationAsync at :113, .And at :135, leak rationale :108-111, Rationale ternary :133-135; MMCA.Store UserRegisteredHandler.cs:130 (the other three Store holders, CustomerService.cs:14, AuthenticationService.cs:49 and CategoryAssignParentUpdateHandler.cs:120, still hold); stage.ps1 comments at :670 and :1436. Anchors inside the earlier Revision sections are left as historical records.