Skip to main content

Interface Design

This page covers how to define your custom repository interface following the Specification Pattern — the recommended approach for domain-specific queries in Kista.

The Specification Pattern

Because the base IRepository<TEntity, TKey> contract does not expose generic query capabilities, you define purpose-built query methods on your own repository interface. Each method represents a specification — a named, domain-meaningful query.

Basic Pattern

public interface IProductRepository : IRepository<Product, Guid> {
// Single-entity look-ups
Task<Product?> FindByCodeAsync(string productCode, CancellationToken ct = default);
Task<Product?> FindBySkuAsync(string sku, CancellationToken ct = default);

// Collection queries
Task<IReadOnlyList<Product>> FindByNameAsync(string name, CancellationToken ct = default);
Task<IReadOnlyList<Product>> FindByCategoryAsync(string category, CancellationToken ct = default);

// Existence checks
Task<bool> CodeExistsAsync(string productCode, CancellationToken ct = default);

// Count queries
Task<long> CountByCategoryAsync(string category, CancellationToken ct = default);
}

Why Named Methods?

BenefitExplanation
Intent is clearFindByCodeAsync communicates domain meaning; FindAsync(filter) does not
Encapsulated logicThe query logic lives in one place, tested once, reused everywhere
Versioned contractAdding a new query is a new method — no breaking changes to existing consumers
No IQueryable leakConsumers never see IQueryable<T> or compose arbitrary expressions

Generic vs. Open Interfaces

Concrete Entity Interface

The most common pattern — the interface is tied to a specific entity type:

public interface IOrderRepository : IRepository<Order, Guid> {
Task<IReadOnlyList<Order>> FindByCustomerAsync(Guid customerId, CancellationToken ct = default);
Task<IReadOnlyList<Order>> FindByStatusAsync(OrderStatus status, CancellationToken ct = default);
Task<IReadOnlyList<Order>> FindRecentAsync(int count, CancellationToken ct = default);
}

Open Generic Interface

Useful when you have a common query pattern across multiple entity types:

public interface INamedEntityRepository<TEntity> : IRepository<TEntity, Guid>
where TEntity : class, INamedEntity {

Task<TEntity?> FindByNameAsync(string name, CancellationToken ct = default);
Task<IReadOnlyList<TEntity>> SearchByNameAsync(string query, int maxResults, CancellationToken ct = default);
}

Implementations close the generic:

public class ProductRepository : Repository<Product, Guid>, INamedEntityRepository<Product> {
// ...
}

public class CategoryRepository : Repository<Category, Guid>, INamedEntityRepository<Category> {
// ...
}

Multi-Entity Repository

Sometimes a repository manages more than one entity type (e.g., an aggregate root with child entities):

public interface IOrderManagementRepository : IRepository<Order, Guid> {
// Order queries
Task<Order?> FindWithItemsAsync(Guid orderId, CancellationToken ct = default);

// Line item queries
Task<IReadOnlyList<OrderItem>> FindItemsByProductAsync(Guid productId, CancellationToken ct = default);

// Aggregate operations
Task<decimal> CalculateTotalAsync(Guid orderId, CancellationToken ct = default);
}

Pagination in the Interface

Simple Pagination (from IRepository)

The base interface already provides unsorted pagination:

ValueTask<PageResult<Product>> GetPageAsync(PageRequest request, CancellationToken ct = default);

No need to redeclare this on your custom interface.

Filtered/Sorted Pagination

If your domain needs filtered and sorted pagination, expose it as a domain-specific method:

public interface IProductRepository : IRepository<Product, Guid> {
// Specific method — recommended
Task<PageQueryResult<Product>> FindByCategoryPagedAsync(
string category,
PageRequest request,
CancellationToken ct = default);

// Generic method — when flexibility is needed
Task<PageQueryResult<Product>> FindProductsPagedAsync(
PageQuery<Product> request,
CancellationToken ct = default);
}

See Query Methods for the trade-offs between these approaches.

Anti-Patterns to Avoid

Don't Expose IQueryable

// BAD — leaks IQueryable to consumers
public interface IProductRepository : IRepository<Product, Guid> {
IQueryable<Product> AsQueryable();
}

Don't Use Deprecated Interfaces

// BAD — these interfaces are obsolete
public interface IProductRepository : IQueryableRepository<Product, Guid>,
IPageableRepository<Product, Guid>,
IFilterableRepository<Product, Guid> {
}

Don't Over-Generalize

// BAD — too generic, loses domain meaning
public interface IProductRepository : IRepository<Product, Guid> {
Task<IReadOnlyList<Product>> FindAsync(IQueryFilter filter, CancellationToken ct = default);
}

Instead, name your queries after their domain intent:

// GOOD — clear domain intent
public interface IProductRepository : IRepository<Product, Guid> {
Task<IReadOnlyList<Product>> FindActiveProductsAsync(CancellationToken ct = default);
Task<IReadOnlyList<Product>> FindDiscontinuedProductsAsync(CancellationToken ct = default);
}