Domain Driven Design uses a series of patterns for building applications that are more maintainable,
and allow a complex application to stand the test of time. One of these patterns is Repository,
and a lot of people spend time building their own repositories.
However, using the Specification pattern you can build a generic repository
which can be used for most of your needs, and which is very expressive and powerful.
Especially with .NET Core 3.1.
This is part 1 about a blog series describing how to build repositories with specifications.
Some Definitions
Let's start with a couple of short definitions. If you need a deeper explanation I can
advise reading Domain-Driven Design, by Eric Evans.
Ubiquitous Language
Domain Driven Design (DDD) lets business experts and developers talk with each other
using a Ubiquitous Language, that is each concept from the business is well defined
and clear for both groups. All team members should speak the same language...
The code describes the model in high detail, while the model provides the abstraction
to discuss features of the software with just enough detail to get a good understanding
of what needs to be done.
Entities and Aggregates
The concepts in the domain end up as Entities and Value Objects.
Entities have an identity and a life cycle, while Value Objects describe aspects of an entity.
Aggregates group these entities so all access to this aggregate has to be done through one entity, the Aggregate Root. Entities from outside the aggregate
can only refer to the aggregate root, access to the other entities from the aggregate
can only be done through references from the aggregate root.
Aggregates encapsulate a lot of inner details. For example, a car encapsulates all of its parts,
and you refer to 'your car'. The car is the aggregate root, and behind it are the engine, wheels, etc...
The Repository Pattern
A repository exposes your entities as a collection, hiding how these entities get stored
in some persistent storage, for example, SQL Server. With DDD, the repository gives you access
to an aggregate root and takes care of retrieving the other entities from the aggregate for you.
To implement a repository we can use tools like Entity Framework Core. Some people even go as far as saying that they don't need a separate repository class. I don't agree with this point of view.
As said before, access to entities from the aggregate is all done through the aggregate root.
You should not expose these inner entities from the aggregate directly to data access code,
the repository should abstract this. That is why you need a repository, to abstract away any includes to deeper entities from the aggregate.
An example
For this example, we have an aggregate containing three entities: Student, Session, and Course.
Each student also has a Login, which sits outside the aggregate, maybe forming its own aggregate (we don't care for this example).
The Student
entity is the root of this aggregate.
So let's build a repository for this aggregate.
Assume we have a base class Repository<Entity, DbContext>
for this. Then our aggregate can
be built like this:
public class StudentRepository : Repository<Student, TrainingDb>
{
public StudentRepository(TrainingDb db)
: base(db)
{ }
protected override IQueryable<Student> Includes(IQueryable<Student> q)
=> q.Include(student => student.Sessions)
.ThenInclude(session => session.Session.Course);
}
I want an easy way to specify which entities belong to the aggregate and this can be
done by overriding the Include
method, which works similarly as Entity Framework.
Every time we access a Student
, the Session
and Course
will be fetched from the database,
allowing easy traversal to Session
and Course
from Student
.
The Specification Pattern
Now let us talk about accessing the aggregate root using a repository. A repository gives you access
to the aggregate root with methods such as WithId
, and other methods allowing to search for entities
whose properties match a certain value.
-
One way to build these is by simply coding them, and internally you use any query technology you like. This is easy but does give you a certain amount of work. Every time some logic requires a new query you have to extend the repository with an extra query method.
-
In .NET you can expose the IQueryable<Entity>
interface directly, and developers can specify the query directly using LINQ. However, this can result in a lot of duplication of queries... Why do these queries get duplicated? Because developers don't look at the query as a high-level concept.
-
There is a third way. You can use a Specification. A specification is really a predicate, testing if an entity matches a condition. The name of the specification should reveal its purpose to make it clear and reusable.
public interface ISpecification<Entity> where T: class, IEntity
{
bool Test(T t);
}
Specifications are built as classes implementing the ISpecification<Entity>
interface,
or inherit from a Specification<Entity>
base class (which implements the interface of course).
Please note that these classes can be mentioned in discussions between business experts and developers without explaining the technical details. Each specification becomes part of the Ubiquitous Language.
Specifications can also be used outside of the repository pattern. Maybe we have a
student that is eligible for a nice discount. This can be represented with the
StudentIsEligibleForDiscountSpecification
without revealing the details, and can
be used in an if
statement and for retrieving students with the repository.
Listing all students
Listing all students can be done with the AllStudentsSpecification
:
public class AllStudentsSpecification : Specification<Student>
{
public AllStudentsSpecification()
: base(student => true)
{ }
}
Repository<Entity, DbContext>
has the ListAsync
method to retrieve all entities passing the specification.
StudentRepository repo = new StudentRepository(dbContext);
var allStudents =
await repo.ListAsync(new AllStudentsSpecification());
Including other Entities
If you need access to an entity from outside the aggregate you can include it easily with the Include
method,
provided there is an association from the aggregate root to the other entity.
public interface ISpecification<T> where T: class, IEntity
{
bool Test(T t);
ISpecification<T> Include(Expression<Func<T, object>> include);
}
You can use this to extend a specification. The following will return a Student
with a reference to its Login
:
var allStudents =
await repo.ListAsync(new AllStudentsSpecification()
.Including(st => st.Login));
Or even better, you can build an extended specification, again stating its purpose:
public class StudentWithIdAndLoginSpecification : StudentWithIdSpecification
{
public StudentWithIdAndLoginSpecification(int id)
: base(id)
{
this.Include(student => student.Login);
}
}
Combining Specifications
Specifications can easily be combined with the usual And
, Or
and Not
logical operators;
var specification = new CoursesStartingNextMonth().And(new CoursesAboutSqlServer());
Again our specifications are Intention Revealing.
Caching
Some data does not change fast. In this case, you can use caching to improve performance.
This requires knowledge about the duration the entity can be cached, and the key to distinguish between entries.
Data that can be cached for an extended time is normally accessed in a read-only fashion, such as product prices. In this case, the same repository instance can be used by all, giving a good case of caching.
/// <summary>
/// Enhances an ISpecification with caching information.
/// </summary>
public interface ICachedSpecification<T> : ISpecification<T>
where T: class, IEntity
{
/// <summary>
/// The key used in cache lookup.
/// </summary>
object Key { get; }
/// <summary>
/// The cache duration, obviously...
/// </summary>
TimeSpan CacheDuration { get; }
}
You can turn any specification into a cached specification:
var student =
await repo.SingleAsync(new StudentWithIdSpecification(1)
.AsCached(TimeSpan.FromDays(1), currencyName));
Or you can make it part of the specification:
public class StudentWithIdSpecificationCached : CachedSpecification<Student>
{
public StudentWithIdSpecificationCached(int id)
: base(criteria: student => student.Id == id,
includes: null,
cacheDuration: TimeSpan.FromHours(1),
key: id)
{ }
}