to navigate Enter to open "…" exact phrase ANDOR to combine

Architecture Decision Record

ADR-055: Repository plus Specification as the Data-Access Contract

Status

Accepted (2026-07-25).

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/IRepository.cs and the EF implementation in Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepository.cs:15-17, 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:14) carries single-entity lookups: GetByIdAsync (IRepository.cs:19, :24), GetByIdsAsync (:37), and the two ExistsAsync overloads (:45, :51). IEntityQuerier<TEntity, TIdentifierType> (:64) carries collection work: GetAllAsync (:69), GetProjectedAsync (:80), GetAllForLookupAsync (:87), and CountAsync (:94, :97). IReadRepository composes exactly those two (:110-111), and IRepository composes read plus write (:238). The interface documentation directs new handlers at the focused sub-interfaces and leaves existing code on the composite (:105-106).
  • The raw queryables live on the composite only. Table, TableNoTracking, TableNoTrackingSingleQuery, and TableNoTrackingSplitQuery are declared on IReadRepository (IRepository.cs:116, :119, :122, :125) and implemented as EF DbSet expressions (EFReadRepository.cs:245, :248, :251, :254). 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/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, :18) and OwnEventQuestionAnswerSpecification (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Specifications/OwnEventQuestionAnswerSpecification.cs:11, :15-16).
  • Specifications compose. AndSpecification (Specification.cs:62), OrSpecification (:88), and NotSpecification (:114) build a new lambda over a fresh parameter with Expression.Invoke so the result stays an expression tree EF can translate (:74-78, :100-104, :125-128); 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 declined sessions (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionsController.cs:95, :117, rationale at :90-92).
  • The specification enters the same query pipeline, not a parallel one. IEntityQueryService takes an optional Specification<TEntity, TIdentifierType> on every read (Source/Core/MMCA.Common.Application/Interfaces/IEntityQueryService.cs:40, :63, :106, :127), and EntityQueryService passes its Criteria into the query parameters (Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:427) 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 two repositories opt in. MMCA.Common scans its own framework Application project (Tests/Architecture/MMCA.Common.Architecture.Tests/RawQueryableConventionTests.cs:18-22) and 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/RawQueryableConventionTests.cs:21-30). MMCA.Store and MMCA.Helpdesk have no subclass, so their 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:419-421) 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, but nothing depends on the narrow interfaces yet. IUnitOfWork hands out only the composites (IRepository at Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IUnitOfWork.cs:19, IReadRepository at :29), and the container registers only the open generic IRepository<,> (Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:99). IEntityReader and IEntityQuerier are referenced nowhere outside their own declaration file across all four repositories. So the ISP split is today the declared target that new handlers are pointed at, not the dependency shape any handler currently has.

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 two-line construction (SessionsController.cs:117) 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. 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. Two of the four repositories run the rule; MMCA.Store and MMCA.Helpdesk currently do not, so their Application layers rely 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.Invoke composition is a provider bet. The And/Or/Not combinators embed each operand with Expression.Invoke (Specification.cs:75-77), 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. 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).