Introduction
This is a continuation of my first post Implementing a Clean Architecture in ASP.NET Core 6, and describes the method I’ve used to achieve clear separation of data models and domain models. In this post, I’ll be expressing my views on the clear separation of persistence related models and business models and look into how we can effectively decouple the data and domain model when using EF Core.
First, domain model. Second, data model.
Over the last few EF Core iterations, there was a great deal of effort made to cater to tactical Domain-Driven Design practices when working with data entities (i.e. private field accessors). However, I personally feel that, even though we are now able to “combine” a data model and a domain model into one EF Core entity, the separation is still not entirely clear, and more often than not, you end up having persistence details “leaking” into your business/domain models (i.e. navigation properties). This can quickly lead to high-coupling of your behavioral model with the database domain.
So my rule is this: Data entities and any persistence-related code should be kept ONLY in the Infrastructure layer and never be allowed to leave! For this reason, domain layer repositories shall never return anything that resembles a data entity. Conceptually, data entities are objects which represent the data in some form of persistence. They have no business – pun intended 🙂 – in the domain layer. Data entities should be anemic POCO objects which are strictly a representation of whatever persistence method you are using. I don’t fancy the idea of having attributes relating to persistence or navigation properties in my domain model.
Additionally, you may need to persist some entities in your project, which are not conceptually part of your business domain layer. For example, application-wide configurations or tenant-specific parameters are very specific to the application itself and are best suited outside the application-independent business layer. However, from a management point-of-view you would still want to be able to add/remove/edit those entities from whatever presentation/UI medium you are using.
For both use-cases above, we’ll be using repositories to cover them.
Best of both worlds
The plan is to have two types of repositories, one that acts upon data entities and another that acts upon domain entities. For that, let first create a basic abstraction which both will depend on.
To achieve this, I have an IEntityRepository which operates on classes implementing the generic IEntity interface. Notice that the IEntityRepository also implements the IReadRepository and IWriteRepository generic interfaces.
Below the code for all aforementioned interfaces:
public interface IEntity<TId>
{
TId Id { get; }
}
public interface IReadRepository<T>
{
bool Exists(Expression<Func<T, bool>> predicate);
Task<bool> ExistsAsync(Expression<Func<T, bool>> predicate);
IQueryable<T> GetAll();
IQueryable<T> GetBy(Expression<Func<T, bool>> predicate);
T GetFirst(Expression<Func<T, bool>> predicate);
Task<T> GetFirstAsync(Expression<Func<T, bool>> predicate);
T GetSingle(Expression<Func<T, bool>> predicate);
Task<T> GetSingleAsync(Expression<Func<T, bool>> predicate);
}
public interface IWriteRepository<T> : IRepository
{
void Add(T entity);
ValueTask AddAsync(T entity);
void AddRange(IEnumerable<T> entities);
Task AddRangeAsync(IEnumerable<T> entities);
void Delete(T entity);
void DeleteRange(IEnumerable<T> entities);
void Update(T entity);
void UpdateRange(IEnumerable<T> entities);
}
public interface IRepository
{
IUnitOfWork UnitOfWork { get; }
}
public interface IEntityRepository<T, TId> :
IReadRepository<T>,
IWriteRepository<T>
where T : class, IEntity<TId>
{
T Find(TId id);
Task<T> FindAsync(TId id);
}
The data model repository
So far we have a pretty basic, albeit rich, abstraction for a repository. Moving forward, what we want to do is make the distinction between data entities and domain entities become clear. Firstly, we’ll create a basic implementation of a repository for retrieving data entities from our persistence store. This repository will operate on classes implementing the generic IDataEntity marker interface.
The code for the interface and concrete implementation of the data entities repository is shown below:
public interface IDataEntity<TId> : IEntity<TId>
{ }
internal class DataEntityRepository<T, TId> : IEntityRepository<T, TId> where T : class, IDataEntity<TId>
{
private readonly DbSet<T> _entities;
private readonly ApplicationDbContext _context;
public IUnitOfWork UnitOfWork => _context;
public DataEntityRepository(ApplicationDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_entities = context.Set<T>();
}
public void Add(T entity)
{
Guard.Against.Null(entity, nameof(entity));
_entities.Add(entity);
}
public async ValueTask AddAsync(T entity)
{
Guard.Against.Null(entity, nameof(entity));
await _entities.AddAsync(entity);
}
public void AddRange(IEnumerable<T> entities)
{
_entities.AddRange(entities);
}
public Task AddRangeAsync(IEnumerable<T> entities)
{
return _entities.AddRangeAsync(entities);
}
public void Delete(T entity)
{
Guard.Against.Null(entity, nameof(entity));
_entities.Remove(entity);
}
public void DeleteRange(IEnumerable<T> entities)
{
Guard.Against.Null(entities, nameof(entities));
_entities.RemoveRange(entities);
}
public bool Exists(Expression<Func<T, bool>> predicate)
{
return _entities.Any(predicate);
}
public Task<bool> ExistsAsync(Expression<Func<T, bool>> predicate)
{
return _entities.AnyAsync(predicate);
}
public IQueryable<T> GetAll()
{
return GetEntities();
}
public IQueryable<T> GetBy(Expression<Func<T, bool>> predicate)
{
return GetEntities().Where(predicate);
}
public T GetFirst(Expression<Func<T, bool>> predicate)
{
return GetEntities().FirstOrDefault(predicate);
}
public async Task<T> GetFirstAsync(Expression<Func<T, bool>> predicate)
{
return await GetEntities().FirstOrDefaultAsync(predicate);
}
public T GetSingle(Expression<Func<T, bool>> predicate)
{
return GetEntities().SingleOrDefault(predicate);
}
public async Task<T> GetSingleAsync(Expression<Func<T, bool>> predicate)
{
return await GetEntities().SingleOrDefaultAsync(predicate);
}
public void Update(T entity)
{
Guard.Against.Null(entity, nameof(entity));
_entities.Update(entity);
}
public void UpdateRange(IEnumerable<T> entities)
{
Guard.Against.Null(entities, nameof(entities));
foreach (var entity in entities)
{
Update(entity);
}
}
public T Find(TId id)
{
return _entities.Find(id);
}
public async Task<T> FindAsync(TId id)
{
return await _entities.FindAsync(id);
}
private IQueryable<T> GetEntities(bool asNoTracking = true)
{
if (asNoTracking)
return _entities.AsNoTracking();
return _entities;
}
}
The domain model repository
To handle retrieval and persistence actions of our domain entities, we’ll create a generic repository marker interface for aggregate root entities, called IAggregateRepository. This operates only on classes implementing the IAggregateRoot generic interface.
Below the code for the aforementioned interfaces:
public interface IAggregateRoot<TId> : IEntity<TId>
{
int Version { get; }
void ApplyEvent(IDomainEvent<TId> @event, int version);
IEnumerable<IDomainEvent<TId>> GetUncommittedEvents();
void ClearUncommittedEvents();
bool IsDeleted { get; }
}
public interface IAggregateRepository<T, TId> :
IEntityRepository<T, TId>
where T : class, IAggregateRoot<TId>
{
}
The IAggregateRoot interface is pretty specific to the overall architecture, as you may notice from the few domain-event-related functions in there. For more information regarding this, take a look at my earlier post Designing the domain model to support multiple persistence methods. Despite that, the point of this is to distinguish between data entities and domain entities so you should adjust this interface properly to your needs.
You’ll notice that the IAggregateRepository interface also implements the IEntityRepository interface. Remember that the purpose of this aggregate repository is to solely act upon, and return, domain entities (specifically aggregate roots). In its generic abstract implementation, the aggregate repository (named EFRepository) uses AutoMapper to map between data entities and domain objects, and can directly operate on domain objects using projections, returning IQueryable. To do that, EFRepository makes use of a generic instance of IEntityRepository, which is registered in the DI container with an open generic type of DataEntityRepository.
The code for the concrete implementation of the abstract aggregate repository is shown below:
internal abstract class EFRepository<T, M, TId> : IAggregateRepository<T, TId>
where T : class, IAggregateRoot<TId>
where M : class, IDataEntity<TId>
{
private readonly IMapper _mapper;
private readonly IEntityRepository<M, TId> _persistenceRepo;
public IUnitOfWork UnitOfWork => _persistenceRepo.UnitOfWork;
public EFRepository(IMapper mapper, IEntityRepository<M, TId> persistenceRepo) {
_mapper = mapper;
_persistenceRepo = persistenceRepo;
}
public virtual void Add(T entity) {
Guard.Against.Null(entity, nameof(entity));
var dataEntity = _mapper.Map<M>(entity);
_persistenceRepo.Add(dataEntity);
}
public virtual async ValueTask AddAsync(T entity) {
Guard.Against.Null(entity, nameof(entity));
var dataEntity = _mapper.Map<M>(entity);
await _persistenceRepo.AddAsync(dataEntity);
}
public virtual void AddRange(IEnumerable<T> entities) {
var dataEntities = _mapper.Map<IEnumerable<M>>(entities);
_persistenceRepo.AddRange(dataEntities);
}
public Task AddRangeAsync(IEnumerable<T> entities) {
var dataEntities = _mapper.Map<IEnumerable<M>>(entities);
return _persistenceRepo.AddRangeAsync(dataEntities);
}
public virtual void Delete(T entity) {
Guard.Against.Null(entity, nameof(entity));
var dataEntity = _mapper.Map<M>(entity);
_persistenceRepo.Delete(dataEntity);
}
public virtual void DeleteRange(IEnumerable<T> entities) {
Guard.Against.Null(entities, nameof(entities));
var dataEntities = _mapper.Map<IEnumerable<M>>(entities);
_persistenceRepo.DeleteRange(dataEntities);
}
public virtual bool Exists(Expression<Func<T, bool>> predicate) {
var expression = _mapper.Map<Expression<Func<M, bool>>>(predicate);
return _persistenceRepo.Exists(expression);
}
public virtual Task<bool> ExistsAsync(Expression<Func<T, bool>> predicate) {
var expression = _mapper.Map<Expression<Func<M, bool>>>(predicate);
return _persistenceRepo.ExistsAsync(expression);
}
public virtual IQueryable<T> GetAll() {
return _mapper.ProjectTo<T>(_persistenceRepo.GetAll());
}
public virtual IQueryable<T> GetBy(Expression<Func<T, bool>> predicate) {
var expression = _mapper.Map<Expression<Func<M, bool>>>(predicate);
return _mapper.ProjectTo<T>(_persistenceRepo.GetBy(expression));
}
public virtual T GetFirst(Expression<Func<T, bool>> predicate) {
var expression = _mapper.Map<Expression<Func<M, bool>>>(predicate);
return _mapper.Map<T>(_persistenceRepo.GetFirst(expression));
}
public virtual async Task<T> GetFirstAsync(Expression<Func<T, bool>> predicate) {
var expression = _mapper.Map<Expression<Func<M, bool>>>(predicate);
return _mapper.Map<T>(await _persistenceRepo.GetFirstAsync(expression));
}
public virtual T GetSingle(Expression<Func<T, bool>> predicate) {
var expression = _mapper.Map<Expression<Func<M, bool>>>(predicate);
return _mapper.Map<T>(_persistenceRepo.GetSingle(expression));
}
public virtual async Task<T> GetSingleAsync(Expression<Func<T, bool>> predicate) {
var expression = _mapper.Map<Expression<Func<M, bool>>>(predicate);
return _mapper.Map<T>(await _persistenceRepo.GetSingleAsync(expression));
}
public virtual void Update(T entity) {
Guard.Against.Null(entity, nameof(entity));
var originalEntity = _persistenceRepo.Find(entity.Id);
var updatedEntity = _mapper.Map(entity, originalEntity);
_persistenceRepo.Update(updatedEntity);
}
public virtual async Task UpdateAsync(T entity) {
Guard.Against.Null(entity, nameof(entity));
var originalEntity = await _persistenceRepo.FindAsync(entity.Id);
var updatedEntity = _mapper.Map(entity, originalEntity);
_persistenceRepo.Update(updatedEntity);
}
public virtual void UpdateRange(IEnumerable<T> entities) {
Guard.Against.Null(entities, nameof(entities));
foreach (var entity in entities) {
Update(entity);
}
}
public T Find(TId id) {
return _mapper.Map<T>(_persistenceRepo.Find(id));
}
public async Task<T> FindAsync(TId id) {
return _mapper.Map<T>(await _persistenceRepo.FindAsync(id));
}
}
Things to remember
As mentioned earlier, all functions here take in a domain entity type and map that to their corresponding data entity type using AutoMapper. This is done for convenience, but keep in mind that the mapping might not always be straightforward between domain and data entities. You could adjust that for your own needs, either by working with AutoMapper custom value resolvers and type converters to do the proper mapping, or override the default implementation in-place to whatever you see fit.
Furthermore, it is a good practice to have a dedicated repository interface/implementation for each aggregate or aggregate root in your domain. Therefore, you can inherit from EFRepository to have a basic implementation of the repository functionality and override and/or add functions to cover your domain-specific needs. This is, after all, the purpose of this abstract repository.
Conclusion
As with everything described in the earlier related posts, this particular approach is not a one-size-fits-all approach. Admittedly, this setup has some complexity in its initial setup, and largely depends on the project whether it justifies the overhead. Personally, it has been the way to go for a couple of my recent projects and has worked quite well for me so far.
Feel free to drop a comment if you have any questions and let me know what you think of this approach.
Top comments (1)
Thanks a lot for writing this article. We are starting a new DDD project from scratch at my company and most of the examples you find online combine EF and Domain Models.
We've been having heated discussions around achieving separation of concerns and avoiding persistence concerns leaking into domain models.
After hours of Googling in search of an out-of-the-box approach, I finally stumbled upon your article and have shared it with my colleagues.
I'm still wondering if combining models is popular solely because of laziness and perceived increased dev costs and slower time-to-market concerns or whether those fears and maintenance overheads are real!
Looking forward to trying out your approach soon!
Thanks again for sharing a high quality article!