Skip to main content
Version: Next

The Repository Pattern

The IRepository<TEntity> interface is the core contract of the framework. All repositories — whether provided by a driver or implemented by you — implement this interface.

The full, strongly-typed form of the contract is IRepository<TEntity, TKey>, where TKey is the type of the entity's unique identifier. The single-type-parameter shorthand IRepository<TEntity> is a convenience alias where TKey defaults to object.

The interface exposes mutations (AddAsync, UpdateAsync, RemoveAsync), key-based look-up (FindAsync), and unsorted pagination (GetPageAsync). See the Getting Started guide for the full interface definition.

Design Rationale

The base IRepository<TEntity, TKey> contract intentionally exposes only mutations, key-based look-up, and simple unsorted pagination. This is deliberate — in Domain-Driven Design, entities are identified by a unique key, and the base repository contract reflects that.

Generic query capabilities (filtering, sorting, complex pagination) are not part of the public interface. Instead, they are hidden behind a protected hatch in the Repository<TEntity, TKey> abstract class. This prevents the IQueryable<T> leak — where LINQ expressions escape the data layer and throw NotSupportedException at runtime far from the repository.

If you need richer queries, you have two options:

  1. Specification Pattern (recommended): Add domain-specific query methods to your own repository interface. See Customize the Repository.
  2. Extend Repository: Inherit from the abstract base class and use its protected query methods internally.

The Repository<TEntity, TKey> Abstract Class

All driver implementations (EntityRepository, MongoRepository, InMemoryRepository) inherit from Repository<TEntity, TKey>. This base class provides:

  • Ready-made implementations of the IRepository<TEntity, TKey> mutation and look-up methods
  • A protected Queryable() method that returns the underlying IQueryable<TEntity> — accessible only to subclasses
  • Protected query methods that use the Queryable() hatch internally
  • Protected hooks for engine-specific async behavior

Protected Query Hatch

public abstract class Repository<TEntity, TKey> : IRepository<TEntity, TKey> {
// The IQueryable hatch — protected, never exposed to consumers
protected abstract IQueryable<TEntity> Queryable();

// Protected query methods available to subclasses
protected virtual ValueTask<IReadOnlyList<TEntity>> FindAsync(IQuery query, CancellationToken ct = default);
protected virtual ValueTask<PageQueryResult<TEntity>> QueryPageAsync(PageQuery<TEntity> request, CancellationToken ct = default);
protected virtual ValueTask<bool> ExistsAsync(IQueryFilter filter, CancellationToken ct = default);
protected virtual ValueTask<long> CountAsync(IQueryFilter filter, CancellationToken ct = default);
protected virtual ValueTask<TEntity?> FindFirstAsync(IQuery query, CancellationToken ct = default);
protected virtual ValueTask<IReadOnlyList<TEntity>> FindAllAsync(IQuery query, CancellationToken ct = default);

// Protected hooks for engine-specific behavior
protected virtual bool IsQueryable => false;
protected virtual IQueryable<TEntity> NormalizeQuery(IQueryable<TEntity> queryable) => queryable;
protected virtual ValueTask<long> CountAsync(IQueryable<TEntity> queryable, CancellationToken ct = default);
protected virtual ValueTask<IReadOnlyList<TEntity>> ToListAsync(IQueryable<TEntity> queryable, CancellationToken ct = default);

// Protected factory for a repository-bound query builder
protected virtual QueryBuilder<TEntity> CreateQuery();
}

The Queryable() method is the single entry point into the data layer. Subclasses return the engine-native queryable (e.g., DbSet<TEntity>.AsQueryable() for EF Core, IMongoCollection<TEntity>.AsQueryable() for MongoDB). All query translation — filter application, sorting, pagination — happens inside the data layer through the protected methods above.

Public Surface

The only query methods exposed to consumer code by Repository are:

MethodDescription
FindAsync(TKey key)Single-entity look-up by unique identifier
GetPageAsync(PageRequest request)Simple unsorted pagination (no filter, no sort)

All other query capabilities are protected and available only to subclasses.

The CreateQuery() Factory

The Repository<TEntity, TKey> base class provides a protected CreateQuery() factory method that returns a QueryBuilder<TEntity> instance bound to the repository. The bound builder exposes terminal methods that dispatch through the repository's protected pipeline:

protected virtual QueryBuilder<TEntity> CreateQuery();

The returned builder inherits from QueryBuilder<TEntity> and overrides the terminal methods to call:

Builder TerminalRepository Pipeline
FirstOrDefaultAsync()FindFirstAsync(IQuery, ...)
ToListAsync()FindAllAsync(IQuery, ...)
CountAsync()CountAsync(IQueryFilter, ...)
AnyAsync()ExistsAsync(IQueryFilter, ...)
GetPageAsync(page, size)GetPageAsync(PageQuery<TEntity>, ...)

Subclasses can override CreateQuery() to return a custom query builder that adds cross-cutting concerns (logging, caching, authorization) before query execution.

Thread-safety: The builder is not thread-safe. Create a new instance per logical operation.

The Specification Pattern

Because the base repository contract does not expose generic query capabilities, the recommended approach for domain-specific queries is the Specification Pattern: define purpose-built query methods on your own repository interface.

See Interface Design for a full guide on defining custom repository interfaces, and Implementation for how to implement them using the protected Queryable() hatch.

Trade-offs: Specific vs. Generic Methods

ApproachRiskFlexibilityExample
Specific methodLow — query is encapsulated, tested, and versionedLow — each new query needs a new methodFindByCodeAsync(string)
Generic page queryHigher — consumer composes arbitrary filters/sortsHigh — one method covers many scenariosFindProductsPageAsync(PageQuery<Product>)

See Query Methods for a detailed decision guide.

Query Types

The IQuery Interface

Queries defined in this library are built around the IQuery interface, which encapsulates a filter and an optional sort order:

public interface IQuery {
IQueryFilter? Filter { get; }
IQueryOrder? Order { get; }
}

The library provides helper types to construct queries:

TypeDescription
QueryA simple immutable query struct wrapping a filter and an optional sort.
PageQuery<TEntity>A paginated query with page number, page size, and optional filter / sort via a fluent builder.
QueryBuilder<TEntity>A fluent builder to compose filters and sort rules for a specific entity type. Implements IQueryBuilder<TEntity> and provides terminal methods (FirstOrDefaultAsync, ToListAsync, CountAsync, AnyAsync, GetPageAsync). When obtained via Repository.CreateQuery(), the terminal methods are bound to the repository and execute the built query.

The IQueryFilter Interface

IQueryFilter is a marker interface. The library ships the following built-in filter types:

FilterDescription
ExpressionQueryFilter<TEntity>Backed by a lambda Expression<Func<TEntity, bool>>.
CombinedQueryFilterCombines two or more filters with a logical AND.
QueryFilter.EmptyA no-op filter (returns all items). Useful for composition or coalescing.

Driver packages may provide their own filter types — for example, Kista.MongoFramework provides a MongoGeoDistanceFilter.

The IQueryOrder Interface

IQueryOrder is a marker interface for sort rules. Built-in sort types:

SortDescription
ExpressionSort<TEntity>Sorts by a lambda expression Expression<Func<TEntity, object?>>, with optional SortDirection.
CombinedOrderCombines two or more sort rules.
FieldOrderSorts by a field name string, with a SortDirection. Requires a field-mapper when used with IQueryable.

Pagination

PageRequest

PageRequest is a simple request object for unsorted pagination:

public class PageRequest {
public int Page { get; } // 1-based page number
public int Size { get; } // Maximum items per page
public int Offset { get; } // Computed: (Page - 1) * Size
}

PageResult<TEntity>

The result returned by GetPageAsync includes pagination metadata:

public class PageResult<TEntity> where TEntity : class {
public PageRequest Request { get; }
public int TotalItems { get; }
public IReadOnlyList<TEntity>? Items { get; }
public int TotalPages { get; }
public bool IsFirstPage { get; }
public bool IsLastPage { get; }
public bool HasNextPage { get; }
public bool HasPreviousPage { get; }
public int? NextPage { get; }
public int? PreviousPage { get; }
}

PageQuery<TEntity> and PageQueryResult<TEntity>

For filtered and sorted pagination, use PageQuery<TEntity> internally within your repository:

var query = new PageQuery<Product>(page: 1, size: 20)
.Where(p => p.IsActive)
.OrderBy(p => p.Name);

The result is PageQueryResult<TEntity>:

public class PageQueryResult<TEntity> where TEntity : class {
public PageQuery<TEntity> Request { get; }
public int TotalItems { get; }
public IReadOnlyList<TEntity> Items { get; }
}

See the driver-specific documentation for pagination behavior per data source.

ITrackingRepository<TEntity>

Marks a repository as capable of tracking changes on entities returned by queries. This is used internally by the EntityManager<TEntity> to detect whether the underlying repository can observe mutations without explicit UpdateAsync calls.

Driver implementations (e.g., EF Core, MongoFramework) that support change-tracking implement this interface automatically.

Deprecated Interfaces

The following interfaces are marked [Obsolete] and should not be used in new code:

  • IQueryableRepository<TEntity, TKey> — exposed AsQueryable(), leaking IQueryable<T> to consumers
  • IPageableRepository<TEntity, TKey> — exposed GetPageAsync(PageQuery<TEntity>), leaking query composition
  • IFilterableRepository<TEntity, TKey> — exposed filter-based queries as a public contract

All their functionality is now provided through the protected members of Repository<TEntity, TKey>. Existing code using these interfaces will continue to compile (the obsolete attribute is non-breaking), but new code should inherit from Repository and implement domain-specific query methods instead.