Infrastructure

Repository extension

Use the optional provider-neutral read repository contract, choose an EF Core implementation or another provider, and preserve a materialized application boundary.

FluentSpecifications.Repositories adds a small generic read boundary around specifications and searches. It standardizes the operations that providers have in common without making EF Core, DbContext, IQueryable, or another storage API part of application code.

The extension is deliberately read-only. Writes, units of work, transactions, includes, projections, and provider options remain application or infrastructure concerns.

Availability

The repository contract is published separately from the starter package, beginning with the coordinated 1.2.0 package suite:

dotnet add package DanMarshall.FluentSpecifications.Repositories --version 1.2.0

DanMarshall.FluentSpecifications.Repositories depends only on DanMarshall.FluentSpecifications; the starter package does not transitively include the repository, expression, or EF Core extensions. This keeps a domain model that only needs Spec<T> free of persistence abstractions.

The dependency supplies the core runtime. Add the starter package directly when the consuming project also needs its source generator; analyzer assets do not flow transitively through an extension package.

The contract uses the root FluentSpecifications namespace. Contributors working inside this repository can use the matching FluentSpecifications.Repositories project reference instead of the package.

Use the generic contract directly or specialize it

Application services can depend directly on IReadRepository<Order>. A domain can also give that dependency a more specific name while inheriting the same read surface:

public interface IOrderRepository : IReadRepository<Order>
{
}

Use a domain-specific interface when it adds useful domain operations or makes an architectural boundary clearer. Do not create one only to copy every generic method by hand.

The generic contract keeps ordinary list operations materialized:

public static Task<IReadOnlyList<Order>> FindReadyOrdersAsync(
    IReadRepository<Order> repository,
    CancellationToken cancellationToken = default) =>
    repository.ListAsync(
        CanShip.And(HighPriority.Or.ManualOverride),
        cancellationToken);

A paged search crosses the same boundary and returns its metadata with the results:

public static Task<Page<Order>> FindPriorityOrdersAsync(
    IReadRepository<Order> repository,
    CancellationToken cancellationToken = default) =>
    repository.PageAsync(
        PriorityShippingPage(),
        cancellationToken);

No application method receives a deferred query or provider expression.

Operations and semantics

Operation Input Materialized result Search shaping
ListAsync Spec<T> IReadOnlyList<T> Filter only
ListAsync Search<T> IReadOnlyList<T> Filter, ordering, and optional paging
PageAsync PagedSearch<T> Page<T> Filter, ordering, paging, and total count
AnyAsync Spec<T> or Search<T> bool Filter only
CountAsync Spec<T> or Search<T> int Filter only

AnyAsync(Search<T>) and CountAsync(Search<T>) use the search specification but do not let ordering or paging change the aggregate. PageAsync counts all filtered results before applying the requested page and returns that count as Page<T>.TotalResults.

Every operation accepts a CancellationToken. Implementations should observe an already-cancelled token before translation or I/O and pass it through to the underlying provider.

EF Core is one implementation

The EF adapter exposes EntityFrameworkRepository<T>, which implements IReadRepository<T> for mapped reference types:

[Fact]
public async Task Repository_example_executes_the_same_rule_in_sqlite()
{
    await using var database = await ExampleDatabase.CreateAsync();
    IReadRepository<Order> repository =
        new EntityFrameworkRepository<Order>(database.Context);
    var ready = CanShip.And(HighPriority.Or.ManualOverride).AndNot.Suspended;

    var orders = await repository.ListAsync(ready);

    Assert.Equal([1, 2], orders.Select(order => order.Id).Order().ToArray());
}

That implementation delegates provider work to the relational executor. It preflights translation, keeps global query filters active, materializes with no tracking, and rejects unsupported filters or sorts instead of silently switching to client evaluation.

The where T : class constraint belongs to the EF implementation, not the generic contract. Read the EF Core guide for null semantics, collations, navigations, provider limits, and database-level testing.

Implement another provider

Any provider can implement IReadRepository<T> without referencing EF Core. The contract test suite proves this with an in-memory provider over a value type:

[Fact]
public async Task A_non_ef_provider_can_implement_the_contract_for_value_types()
{
    IReadRepository<int> repository =
        new InMemoryReadRepository<int>([1, 2, 3, 4]);
    var even = Spec.Define<int>("number.even", "Even", number => number % 2 == 0);
    var search = Search.Matching(even)
        .Sorted.By[SearchField.Define<int, int>("Value", number => number)].Desc
        .Page(1).OfSize(1);

    Assert.Equal([2, 4], await repository.ListAsync(even));
    Assert.Equal([4], (await repository.PageAsync(search)).Results);
    Assert.True(await repository.AnyAsync(even));
    Assert.Equal(2, await repository.CountAsync(even));
}

A database, HTTP API, document store, search service, or test provider should:

  • evaluate or translate the complete Spec<T> filter;
  • apply search ordering in the declared order;
  • apply paging only where the operation requires it;
  • return fully materialized results;
  • preserve cancellation;
  • report unsupported behavior explicitly; and
  • keep connection, query, and provider configuration behind the implementation.

An implementation must not expose IQueryable, fetch an unbounded data set as an implicit fallback, or silently discard a rule or sort that it cannot execute.

Keep writes domain-specific

IReadRepository<T> does not prescribe Add, Update, Delete, Save, or a unit of work. A domain repository can inherit the read contract and add the commands that its aggregate actually supports, or it can keep command handlers separate. Either choice is preferable to pretending every data source has the same generic mutation semantics.

The testing guide covers repository conformance evidence. The reference summarizes package ownership and the broader non-goals.