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.
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.
Category
Method
Return Type
Single Create
Create(TAggregate)
FinT<IO, TAggregate>
Single Read
GetById(TId)
FinT<IO, TAggregate>
Single Update
Update(TAggregate)
FinT<IO, TAggregate>
Single Delete ⚠️
Delete(TId)
FinT<IO, int>
Batch Create
CreateRange(IReadOnlyList<TAggregate>)
FinT<IO, int>
Batch Read
GetByIds(IReadOnlyList<TId>)
FinT<IO, Seq<TAggregate>>
Batch Update
UpdateRange(IReadOnlyList<TAggregate>)
FinT<IO, int>
Batch Delete ⚠️
DeleteRange(IReadOnlyList<TId>)
FinT<IO, int>
Spec Exists
Exists(Specification<TAggregate>)
FinT<IO, bool>
Spec Count
Count(Specification<TAggregate>)
FinT<IO, int>
Spec Find All
FindAllSatisfying(Specification<TAggregate>)
FinT<IO, Seq<TAggregate>>
Spec Find First
FindFirstSatisfying(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:
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.
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".
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.
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>?
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.
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.