Skip to content

Repository Interface

Should every Repository repeatedly define Create, GetById, Update, Delete? Copy-pasting the same CRUD methods for every domain causes code duplication to grow exponentially. IRepository<TAggregate, TId> is the common interface that solves this problem. Through generic constraints, it enforces at compile time that only Aggregate Roots can be Repository targets, and all methods return FinT<IO, T> to handle side effects and error handling in a composable form.


After completing this chapter, you will be able to:

  1. Explain the 13 methods of the IRepository<TAggregate, TId> interface (8 CRUD + 5 Specification).
  2. Explain why the FinT<IO, T> return type is more advantageous for composition than Task<T>.
  3. Explain how generic constraints (AggregateRoot<TId>, IEntityId<TId>) prevent incorrect usage.
  4. Define domain-specific Repository interfaces yourself.

Imagine defining separate Repository interfaces for Product, Order, and Customer domains.

// Product Repository
FinT<IO, Product> Create(Product product);
FinT<IO, Product> GetById(ProductId id);
FinT<IO, Product> Update(Product product);
FinT<IO, int> Delete(ProductId id);
// Order Repository - repeating the same pattern
FinT<IO, Order> Create(Order order);
FinT<IO, Order> GetById(OrderId id);
FinT<IO, Order> Update(Order order);
FinT<IO, int> Delete(OrderId id);
// Customer Repository - repeating again...

The same signatures are copied every time an Aggregate is added. If the return type of a single method changes, all interfaces must be modified. IRepository<TAggregate, TId> extracts this common pattern as generics, defining it in one place for all domains to reuse.

IRepository provides CRUD, Specification-based queries, and Specification-based bulk operations. Single and batch versions are symmetrical, and Specification-based reads follow Evans’ selectSatisfying pattern.

CategoryMethodReturn Type
Single CreateCreate(TAggregate)FinT<IO, TAggregate>
Single ReadGetById(TId)FinT<IO, TAggregate>
Single UpdateUpdate(TAggregate)FinT<IO, TAggregate>
Single Delete ⚠️Delete(TId)FinT<IO, int>
Batch CreateCreateRange(IReadOnlyList<TAggregate>)FinT<IO, int>
Batch ReadGetByIds(IReadOnlyList<TId>)FinT<IO, Seq<TAggregate>>
Batch UpdateUpdateRange(IReadOnlyList<TAggregate>)FinT<IO, int>
Batch Delete ⚠️DeleteRange(IReadOnlyList<TId>)FinT<IO, int>
Spec ExistsExists(Specification<TAggregate>)FinT<IO, bool>
Spec CountCount(Specification<TAggregate>)FinT<IO, int>
Spec Find AllFindAllSatisfying(Specification<TAggregate>)FinT<IO, Seq<TAggregate>>
Spec Find FirstFindFirstSatisfying(Specification<TAggregate>)FinT<IO, Option<TAggregate>>
Spec Delete ⚠️DeleteBy(Specification<TAggregate>)FinT<IO, int>

⚠️ Hard delete methods do not emit domain events. For business deletion that requires a DeletedEvent, use the Soft Delete pattern:

Load (GetById/GetByIds) → aggregate.Delete(...) → Update(aggregate)/UpdateRange(aggregates)

Delete, DeleteRange, and DeleteBy are intended for administrative, migration, or test-fixture cleanup.

Optimistic concurrency: Update returns AdapterErrorKind.ConcurrencyConflict when the Aggregate was modified after load (distinguished from NotFound). RowVersion/Timestamp column configuration is the subclass Model’s responsibility.

Why return FinT<IO, T> instead of Task<T>? Look at the following structure.

FinT<IO, T> = IO<Fin<T>>
= IO<Succ(T) | Fail(Error)>
  • Fin<T> is a Result type that represents success (Succ) or failure (Fail). It handles failures as values without throwing exceptions.
  • IO is a monad that tracks side effects. It makes side effects like DB access explicit in the type system.
  • FinT is the composition of two monads (Monad Transformer). Multiple Repository calls can be chained with the | operator.

The following constraints block incorrect Repository usage at compile time.

public interface IRepository<TAggregate, TId>
where TAggregate : AggregateRoot<TId> // Only Aggregate Roots allowed
where TId : struct, IEntityId<TId> // Only value-type IDs allowed
  • AggregateRoot<TId> constraint: Attempting to directly persist an Entity or Value Object causes a compile error.
  • IEntityId<TId> constraint: Enforces Ulid-based identifiers to unify the ID generation strategy.

IRepository inherits from IObservablePort. IObservablePort has a single RequestCategory property, used by the Observability pipeline to distinguish Command/Query for metric and log collection. Repository implementations return RequestCategory => "Command".


01-Repository-Interface/
├── RepositoryInterface/
│ ├── RepositoryInterface.csproj
│ ├── Program.cs # Console demo
│ ├── ProductId.cs # Ulid-based identifier
│ ├── Product.cs # Aggregate Root
│ └── IProductRepository.cs # Domain-specific Repository interface
├── RepositoryInterface.Tests.Unit/
│ ├── RepositoryInterface.Tests.Unit.csproj
│ ├── Using.cs
│ ├── xunit.runner.json
│ └── ProductTests.cs
└── README.md

Now that we’ve defined the common interface, how do we add domain-specific methods? Just inherit from IRepository.

IProductRepository — A domain-specific interface extending IRepository:

public interface IProductRepository : IRepository<Product, ProductId>
{
FinT<IO, Product> GetByIdIncludingDeleted(ProductId id);
}

It inherits all 13 methods (8 CRUD + 5 Specification) from IRepository while adding GetByIdIncludingDeleted, a method specific to the Product domain (e.g., for soft-deleted record retrieval). Even when new domains are added, there’s no need to redefine the standard signatures.


The following table summarizes the key items covered in this chapter.

ItemDescription
InterfaceIRepository<TAggregate, TId>
CRUD methods4 single + 4 batch = 8
Return typeFinT<IO, T> (composable monad)
ConstraintsAggregate Root + Ulid-based ID
Extension methodInherit as domain-specific interface

Q1: Why does the Repository only handle Aggregate Roots?

Section titled “Q1: Why does the Repository only handle Aggregate Roots?”

A: In DDD, the Aggregate Root is the consistency boundary. Internal Entities must only be accessed through the Aggregate Root, so the Repository also operates at the Aggregate Root level.

Q2: What advantage does FinT<IO, T> have over Task<T>?

Section titled “Q2: What advantage does FinT<IO, T> have over Task<T>?”

A: Task<T> throws exceptions, while FinT<IO, T> represents failures as values. This allows error handling to be composed, avoiding exception-based control flow.

Q3: Why use IReadOnlyList as the parameter type?

Section titled “Q3: Why use IReadOnlyList as the parameter type?”

A: IReadOnlyList<T> provides index access and Count while disallowing modifications, making it a safe and flexible collection interface. It can accept various types such as List<T>, arrays, and Seq<T>.


We’ve defined the common Repository interface. But how do you test this interface without a DB? In the next chapter, we’ll implement a ConcurrentDictionary-based InMemory Repository to verify behavior quickly without an actual DB connection.

-> Chapter 2: InMemory Repository