Use this library to implement the Repository pattern. Designed with Clean Architecture in mind. Hereafter the terminology from a Clean Architecture will be used. It consists of 3 packages:
- Skova.Repositoty.Abstractions: This package contains base repository abstractions. Use it in your Application layer. Has no dependencies.
dotnet add package Skova.Repositoty.Abstractions
- Skova.Repository.Impl: This package depends on Skova.Repository.Abstractions, Automapper and EF Core. Use this package in your Persistent layer to implement specifications logic.
dotnet add package Skova.Repository.Impl
- Skova.Repository.DependencyInjection: Provides API for dependencies registrations through IServiceCollection. Use this package to register dependencies. This package
dotnet add package Skova.Repository.DependencyInjection
In your Application layer, use the following abstractions:
-
IUnitOfWork
- represents unit-of-work that will be used to manage entities. UnitOfWork is usually registered in DI containers as scoped associated with some business transaction. Skova.Repository.Imp Where and how to leave feedback such as link to the project issues, Twitter, bug trry for a domain layer's entity of the specifiedTDomain
type. Supports operations for creating updating and deleting entities from a unit of work usingAddAsync
,Update
andDelete
methods. To load entities from storage to unit-of-work or get them from storage without attaching them to the unit-of-work, you should call methodWith
and provide a specification of the entities you want to receive. Also, it supports the updation and deletion of entities that fit provided specification directly in storage like EF Core do (See "ExecuteUpdate and ExecuteDelete" article in the MSDN documentation for details). You should use the methodWith
first and provide specifications that will be used to define entities you want to update or delete. -
ISpecification<TDomain>
- represents a specification for a domain layer's entity of the specifiedTDomain
type. Specifications are used to customize queries when you want to load data from storage. This library doesn't strict developers to use some API for specifications and leaves it up to you how to design specifications API.
Sample specification may look like this:
public interface IPersonSpecification : ISpecification<Person>
{
void GetById(Guid id);
void ByName(string name, bool exactMatch = false);
void MinimalAge(int age);
void OrderByAge(bool descending = false);
}
Also, you may organize some of specifications into a hierarchy:
public interface IPersonSpecification : ISpecification<Person>, IEntitySpecification
{
void ByName(string name, bool exactMatch = false);
void MinimalAge(int age);
void OrderByAge(bool descending = false);
void IncludeJobs();
}
public interface IEntitySpecification : ISpecification<Entity>
{
void GetById(Guid id);
}
Add Skova.Repository.Impl package to your project(s) of the Persistence layer. This package contains classes that are ready to use:
-
UnitOfWork<TDbContext>
- default implementation of IUnitOfWork. Requires to specify db context type asTDbContext
. In your Persistence layer, you should implement at least your specifications. -
GenericRepository<TDomain, TDb, TDbContext>
- default generic implementation of theIRepository
interface. You should specify the type of a domain entity (TDomain
), type of the corresponding db entity (TDb
) and it requires specifying db context type (TDbContext
) -
QueryTransformationsContainer<TDb>
- tool for containing query transformations. This class is useful in specification implementation classes (see the following examples). De factoQueryTransformationsContainer<>
just wrapsList<Func<IQueryable<TDb>, IQueryable<TDb>>>
and implementsIQueryTranformation<>
.
Skova.Repository.DependencyInjection package contains extension methods that make DI registrations of these classes much easier, in fluent style:
services.AddUnitOfWorkAsScoped<CompanyDbContext>()
.AddRepositoryAsScoped<Person, DbPerson>(config => config.AddKeyRecognizer(p => new object[] { p.Id }))
.AddSpecificationAsTransient<IPersonSpecification, PersonSpecification>();
Which is equivalent to:
services.AddScoped<IUnitOfWork, UnitOfWork<CompanyDbContext>>();
services.AddScoped<IRepository<Person>, GenericRepository<Person, DbPerson, CompanyDbContext>>();
services.AddTransient<PersonSpecification>();
services.AddTransient<ISpecification<Person>, PersonSpecification>();
services.AddTransient<SpecificationFactory<IPersonSpecification>>(
sp => () => (IPersonSpecification)sp.GetRequiredService(typeof(PersonSpecification)));
AddUnitOfWorkAsScoped
is used to register UnitOfWork
for a db context. Then you can chain registrations of repositories associated with this unit-of-work through AddRepositoryAsScoped
method. During repository registration, you should register specifications for TDomain
type in one of two ways:
- chain one or more
AddSpecification*
methods right afterAddRepositoryAsScoped
- using
config
argument of theAddRepositoryAsScoped
method.
Important! that you should register KeyRecognizer<TDomain>
delegate for any domain type used by the registered GenericRepository<>
. That can be done through AddKeyRecognizer
method by chaining it after AddRepositoryAsScoped
or calling it in the scope of the configuration lambda argument of AddRepositoryAsScoped
.
Typical specification implementation is working in two phases:
- Specify criteria needed to determine a set of domain entities for further processing (like retrieving from storage, updation or deletion)
- Apply transformation for
IQuery<TDb>
(DbSet usually) which will be used by EF Core for further processing of target db entities
Inside your specification use QueryTransformationsContainer<>
to accumulate query transformations in phase (1) and apply transformations in phase (2)
Your specification implementation class should
- Implement
ISpecification<TDomain>
interface for target domain entity ofTDomain
type, which will be used in phase (1) - Implement
IQueryTransformer<TDb>
to apply transformation in phase (2)
Example:
You may inherit your specifications from QueryTransformationsContainer<TDb>
which contains query transformations and implements IQueryTransformer<TDb>
by applying collected transformations in the order, they were added:
public class EntitySpecification : QueryTransformationsContainer<TDb>, IEntitySpecification
{
public void GetById(Guid id)
{
AddTranformation(q => q.Where(p => p.Id == id));
}
}
In the case of hierarchies of entities, specification implementation became more tricky. We collect query transformations in QueryTransformationsContainer<TDb>
But we can't just re-use QueryTransformationsContainer<DbEntity>
in children's classes, because we can't pass transformations for inherited db entities like _container.AddTranformation((IQueryable<DbPerson> q) => q.Where(Id = id))
- compilator cannot convert that lambda expression to Func<IQueryable<DbEntity>, IQueryable<DbEntity>>
. But you may do this in this way:
public class EntitySpecification<TDomain, TDb> : QueryTransformationsContainer<TDb>, IEntitySpecification
where TDomain : Entity
where TDb : DbEntity
{
public void GetById(Guid id)
{
AddTranformation(q => q.Where(p => p.Id == id));
}
}
public class PersonSpecification : EntitySpecification<Person, DbPerson>, IPersonSpecification
{
public void ByName(string name, bool exactMatch = false)
{
AddTranformation(q => q.Where(p => p.Name == name));
}
public void MinimalAge(int age)
{
AddTranformation(q => q.Where(p => p.Age >= age));
}
public void OrderByAge(bool descending = false)
{
AddTranformation(q => descending ? q.OrderByDescending(p => p.Age) : q.OrderBy(p => p.Age));
}
public void IncludeJobs()
{
AddTranformation(q => q.Include(p => p.Jobs));
}
}
Here EntitySpecification
is a base class for specifications as well as Entity is the root base class for entities like Person. Note, that we use parametrization with constraint for type parameters to Entities-derived classes for both layers domain and db. This is required to make all IQuery<TDb>
transformations be typed with the most specific entity type.
When you need to add other specification classes that inherits PersonSpecification
, you should change the PersonSpecification
signature to make it generic as follows:
public class PersonSpecification<TPerson, TDbPerson> : EntitySpecification<TPerson, TDbPerson>, IPersonSpecification
where TPerson : Person
where TDbPerson : DbPerson
So inherited from PersonSpecification<>
classes will specify specific domain and db entity types they need. I.e.:
public class EmployeeSpecification : PersonSpecification<TEmployee, TDbEmployee>, IEmployeeSpecification
Also, it is not necessary to use QueryTransformationsContainer<>
I.e. in some performance-critical cases you may decide to optimize memory consumption and implement it in a not such convenient way:
public class JobSpecification : IJobSpecification, IQueryTransformer<DbJob>
{
Guid? _id = null;
string _title = null;
string _description = null;
DateTimeOffset? _startDate = null;
DateTimeOffset? _endDate = null;
bool? _orderByStartDateDescending = null;
bool? _orderByEndDateDescending = null;
public void GetById(Guid id) => _id = id;
public void ByTitle(string title) => _title = title;
public void ByDescription(string description) => _description = description;
public void ByStartDate(DateTimeOffset startDate) => _startDate = startDate;
public void ByEndDate(DateTimeOffset endDate) => _endDate = endDate;
public void OrderByStartDate(bool descending = false) => _orderByStartDateDescending = descending;
public void OrderByEndDate(bool descending = false) => _orderByEndDateDescending = descending;
public IQueryable<DbJob> Apply(IQueryable<DbJob> query)
{
if (_id != null)
query = query.Where(p => p.Id == _id);
if (_title != null)
query = query.Where(p => p.Title == _title);
if (_description != null)
query = query.Where(p => p.Description == _description);
if (_startDate != null)
query = query.Where(p => p.StartDate == _startDate);
if (_endDate != null)
query = query.Where(p => p.EndDate == _endDate);
if (_orderByStartDateDescending != null)
query = _orderByStartDateDescending.Value
? query.OrderByDescending(p => p.StartDate)
: query.OrderBy(p => p.StartDate);
if (_orderByEndDateDescending != null)
query = _orderByEndDateDescending.Value
? query.OrderByDescending(p => p.EndDate)
: query.OrderBy(p => p.EndDate);
return query;
}
}