I'd like to share my solution. I was experimenting with UnitOfWork implementation for multiple ORMs, including Dapper. Here's full project: https://github.com/pkirilin/UnitOfWorkExample
Base unit of work and repository abstractions:
public interface IUnitOfWork
{
Task SaveChangesAsync(CancellationToken cancellationToken);
}
public interface IRepository<TEntity, in TId> where TEntity : EntityBase<TId> where TId : IComparable<TId>
{
Task<TEntity> GetByIdAsync(TId id, CancellationToken cancellationToken);
TEntity Add(TEntity entity);
void Update(TEntity entity);
void Remove(TEntity entity);
}
Domain model:
public abstract class EntityBase<TId> where TId : IComparable<TId>
{
public TId Id { get; }
protected EntityBase()
{
}
protected EntityBase(TId id)
{
Id = id;
}
}
public class WeatherForecast : EntityBase<int>
{
// ...
}
Specific repository interface:
public interface IWeatherForecastsRepository : IRepository<WeatherForecast, int>
{
Task<List<WeatherForecast>> GetForecastsAsync(CancellationToken cancellationToken);
}
Specific unit of work interface:
public interface IAppUnitOfWork : IUnitOfWork
{
IWeatherForecastsRepository WeatherForecasts { get; }
}
You can have multiple data contexts in your application, so creating specific unit of works with strong boundary seems reasonable to me.
The implementation of unit of work will look like this:
internal class AppUnitOfWork : IAppUnitOfWork, IDisposable
{
private readonly IDbConnection _connection;
private IDbTransaction _transaction;
public IWeatherForecastsRepository WeatherForecasts { get; private set; }
// Example for using in ASP.NET Core
// IAppUnitOfWork should be registered as scoped in DI container
public AppUnitOfWork(IConfiguration configuration)
{
// I was using MySql in my project, the connection will be different for different DBMS
_connection = new MySqlConnection(configuration["ConnectionStrings:MySql"]);
_connection.Open();
_transaction = _connection.BeginTransaction();
WeatherForecasts = new WeatherForecastsRepository(_connection, _transaction);
}
public Task SaveChangesAsync(CancellationToken cancellationToken)
{
try
{
_transaction.Commit();
}
catch
{
_transaction.Rollback();
throw;
}
finally
{
_transaction.Dispose();
_transaction = _connection.BeginTransaction();
WeatherForecasts = new WeatherForecastsRepository(_connection, _transaction);
}
return Task.CompletedTask;
}
public void Dispose()
{
_transaction.Dispose();
_connection.Dispose();
}
}
Quite simple. But when I tried to implement specific repository interface, I faced a problem. My domain model was rich (no public setters, some properties were wrapped in value objects etc.). Dapper is unable to handle such classes as-is. It doesn't know how to map value objects to db columns and when you try to select some value from db, it throws error and says it can't instantiate entity object. One option is to create private constructor with parameters matching your db column names and types, but it's very bad decision, because your domain layer shouldn't know anything about your database.
So I've splitted entities into different types:
- Domain entity: contains your domain logic, is used by other parts of application. You can use everything you want here, including private setters and value objects
- Persistent entity: contains all properties matching your database columns, is used only in repository implementation. All properties are public
The idea is that repository works with Dapper only via persistent entity and, when nessesary, maps persistent entity to or from domain entity.
There is also an official library called Dapper.Contrib
, which can construct basic (CRUD) SQL queries for you, and I'm using it in my implementation, because it really makes life easier.
So, my final repository implementation:
// Dapper.Contrib annotations for SQL query generation
[Table("WeatherForecasts")]
public class WeatherForecastPersistentEntity
{
[Key]
public int Id { get; set; }
public DateTime Date { get; set; }
public int TemperatureC { get; set; }
public string? Summary { get; set; }
}
internal abstract class Repository<TDomainEntity, TPersistentEntity, TId> : IRepository<TDomainEntity, TId>
where TDomainEntity : EntityBase<TId>
where TPersistentEntity : class
where TId : IComparable<TId>
{
protected readonly IDbConnection Connection;
protected readonly IDbTransaction Transaction;
// Helper that looks for [Table(...)] annotation in persistent entity and gets table name to use it in custom SQL queries
protected static readonly string TableName = ReflectionHelper.GetTableName<TPersistentEntity>();
protected Repository(IDbConnection connection, IDbTransaction transaction)
{
Connection = connection;
Transaction = transaction;
}
public async Task<TDomainEntity> GetByIdAsync(TId id, CancellationToken cancellationToken)
{
var persistentEntity = await Connection.GetAsync<TPersistentEntity>(id, transaction: Transaction);
return (persistentEntity == null ? null : MapToDomainEntity(persistentEntity))!;
}
public TDomainEntity Add(TDomainEntity entity)
{
var persistentEntity = MapToPersistentEntity(entity);
Connection.Insert(persistentEntity, transaction: Transaction);
var id = Connection.ExecuteScalar<TId>("select LAST_INSERT_ID()", transaction: Transaction);
SetPersistentEntityId(persistentEntity, id);
return MapToDomainEntity(persistentEntity);
}
public void Update(TDomainEntity entity)
{
var persistentEntity = MapToPersistentEntity(entity);
Connection.Update(persistentEntity, transaction: Transaction);
}
public void Remove(TDomainEntity entity)
{
var persistentEntity = MapToPersistentEntity(entity);
Connection.Delete(persistentEntity, transaction: Transaction);
}
protected abstract TPersistentEntity MapToPersistentEntity(TDomainEntity entity);
protected abstract TDomainEntity MapToDomainEntity(TPersistentEntity entity);
protected abstract void SetPersistentEntityId(TPersistentEntity entity, TId id);
}
internal class WeatherForecastsRepository : Repository<WeatherForecast, WeatherForecastPersistentEntity, int>, IWeatherForecastsRepository
{
public WeatherForecastsRepository(IDbConnection connection, IDbTransaction transaction)
: base(connection, transaction)
{
}
public async Task<List<WeatherForecast>> GetForecastsAsync(CancellationToken cancellationToken)
{
var cmd = new CommandDefinition($"select * from {TableName} limit 100",
transaction: Transaction,
cancellationToken: cancellationToken);
var forecasts = await Connection.QueryAsync<WeatherForecastPersistentEntity>(cmd);
return forecasts
.Select(MapToDomainEntity)
.ToList();
}
protected override WeatherForecastPersistentEntity MapToPersistentEntity(WeatherForecast entity)
{
return new WeatherForecastPersistentEntity
{
Id = entity.Id,
Date = entity.Date,
Summary = entity.Summary.Text,
TemperatureC = entity.TemperatureC
};
}
protected override WeatherForecast MapToDomainEntity(WeatherForecastPersistentEntity entity)
{
return new WeatherForecast(entity.Id)
.SetDate(entity.Date)
.SetSummary(entity.Summary)
.SetCelciusTemperature(entity.TemperatureC);
}
protected override void SetPersistentEntityId(WeatherForecastPersistentEntity entity, int id)
{
entity.Id = id;
}
}
internal static class ReflectionHelper
{
public static string GetTableName<TPersistentEntity>()
{
var persistentEntityType = typeof(TPersistentEntity);
var tableAttributeType = typeof(TableAttribute);
var tableAttribute = persistentEntityType.CustomAttributes
.FirstOrDefault(a => a.AttributeType == tableAttributeType);
if (tableAttribute == null)
{
throw new InvalidOperationException(
$"Could not find attribute '{tableAttributeType.FullName}' " +
$"with table name for entity type '{persistentEntityType.FullName}'. " +
"Table attribute is required for all entity types");
}
return tableAttribute.ConstructorArguments
.First()
.Value
.ToString();
}
}
Example usage:
class SomeService
{
private readonly IAppUnitOfWork _unitOfWork;
public SomeService(IAppUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
public async Task DoSomethingAsync(CancellationToken cancellationToken)
{
var entity = await _unitOfWork.WeatherForecasts.GetByIdAsync(..., cancellationToken);
_unitOfWork.WeatherForecasts.Delete(entity);
var newEntity = new WeatherForecast(...);
_unitOfWork.WeatherForecasts.Add(newEntity);
await _unitOfWork.SaveChangesAsync(cancellationToken);
}
}