Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Centeva.DomainModeling.Interfaces;
using Microsoft.Extensions.DependencyInjection;

namespace Centeva.DomainModeling.EFCore;

public static class ServiceCollectionExtensions
{
/// <summary>
/// Add the EF Core implementation of IUnitOfWork to the DI container
/// </summary>
/// <param name="services"></param>
/// <returns></returns>
public static IServiceCollection AddUnitOfWork(this IServiceCollection services) =>
services.AddScoped<IUnitOfWork, UnitOfWork>();
}
80 changes: 80 additions & 0 deletions src/Centeva.DomainModeling.EFCore/UnitOfWork.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using Centeva.DomainModeling.Interfaces;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;

namespace Centeva.DomainModeling.EFCore;

/// <summary>
/// Unit of Work implementation for Entity Framework Core. Register this with your DI container
/// as an implementation of <see cref="IUnitOfWork"/>. If using Microsoft.Extensions.DependencyInjection,
/// use AddScoped to ensure that the same instance is used throughout a single request. You can also
/// use the provided <see cref="ServiceCollectionExtensions.AddUnitOfWork"/> extension method.
/// </summary>
public class UnitOfWork : IUnitOfWork
{
private readonly DbContext _dbContext;
private IDbContextTransaction? _transaction;

public UnitOfWork(DbContext dbContext)
{
_dbContext = dbContext;
}

public void Dispose()
{
if (_transaction == null)
{
return;
}

_transaction.Rollback();
_transaction.Dispose();
_transaction = null;
}

public void BeginTransaction()
{
if (_transaction != null)
{
return;
}

_transaction = _dbContext.Database.BeginTransaction();
}

public void Commit()
{
if (_transaction == null)
{
return;
}

_transaction.Commit();
_transaction.Dispose();
_transaction = null;
}

public void Rollback()
{
if (_transaction == null)
{
return;
}

_transaction.Rollback();
_transaction.Dispose();
_transaction = null;
}

public async Task<int> SaveAndCommitAsync(CancellationToken cancellationToken = default)
{
var result = await SaveChangesAsync(cancellationToken);
Commit();
return result;
}

public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
return _dbContext.SaveChangesAsync(cancellationToken);
}
}
47 changes: 47 additions & 0 deletions src/Centeva.DomainModeling/Interfaces/IUnitOfWork.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
namespace Centeva.DomainModeling.Interfaces;

/// <summary>
/// Represents a Unit of Work for managing multiple repository transactions as a single unit.
/// </summary>
/// <remarks>
/// Any repository modifications that happen between <see cref="BeginTransaction"/> and <see cref="Commit"/> will be
/// committed together. If <see cref="Rollback"/> is called, all changes will be discarded. This is useful when
/// you are working with multiple repositories and want to ensure that all changes are committed together or not at all.
/// </remarks>
public interface IUnitOfWork : IDisposable
{
/// <summary>
/// Begin a new transaction. All changes will be rolled back if not committed.
/// </summary>
void BeginTransaction();

/// <summary>
/// Commit the transaction. All changes will be saved.
/// </summary>
void Commit();

/// <summary>
/// Rollback the transaction. All changes will be discarded.
/// </summary>
void Rollback();

/// <summary>
/// Save all pending changes and commit the transaction.
/// </summary>
/// <param name="cancellationToken">A <see cref="T:System.Threading.CancellationToken" /> to observe while waiting for the task to complete.</param>
/// <returns>
/// A task that represents the asynchronous save operation. The task result contains the
/// number of state entries written.
/// </returns>
Task<int> SaveAndCommitAsync(CancellationToken cancellationToken = default);

/// <summary>
/// Save all pending changes. Does not commit the transaction.
/// </summary>
/// <param name="cancellationToken">A <see cref="T:System.Threading.CancellationToken" /> to observe while waiting for the task to complete.</param>
/// <returns>
/// A task that represents the asynchronous save operation. The task result contains the
/// number of state entries written.
/// </returns>
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ namespace Centeva.DomainModeling.IntegrationTests.Fixtures;

public abstract class IntegrationTestBase : IClassFixture<SharedDatabaseFixture>
{
protected TestDbContext _dbContext;
protected readonly TestDbContext _dbContext;

protected Repository<Person> _personRepository;
protected Repository<Address> _addressRepository;
protected readonly Repository<Person> _personRepository;
protected readonly Repository<Address> _addressRepository;

protected IntegrationTestBase(SharedDatabaseFixture fixture)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace Centeva.DomainModeling.IntegrationTests.Fixtures;

Expand All @@ -7,6 +8,7 @@ public class LoggerFactoryProvider
public static readonly ILoggerFactory LoggerFactoryInstance = LoggerFactory.Create(builder =>
{
builder.AddFilter("Centeva.DomainModeling", LogLevel.Debug);
builder.AddFilter(DbLoggerCategory.Database.Transaction.Name, LogLevel.Debug);
builder.AddConsole();
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using Centeva.DomainModeling.EFCore;
using Centeva.DomainModeling.IntegrationTests.Fixtures;
using Centeva.DomainModeling.UnitTests.Fixtures.Entities;
using Centeva.DomainModeling.UnitTests.Fixtures.Seeds;

namespace Centeva.DomainModeling.IntegrationTests.UnitOfWorkTests;

public class RollbackTests : IntegrationTestBase
{
private readonly UnitOfWork _unitOfWork;

public RollbackTests(SharedDatabaseFixture fixture) : base(fixture)
{
_unitOfWork = new UnitOfWork(_dbContext);
}

[Fact]
public async Task RollbackAfterAdd_DoesNotPersistChanges()
{
var person = new Person(Guid.NewGuid(), "Test");

_unitOfWork.BeginTransaction();
await _personRepository.AddAsync(person);
_unitOfWork.Rollback();

_dbContext.People.FirstOrDefault(x => x.Id == person.Id).Should().BeNull();
}

[Fact]
public async Task RollbackAfterDelete_DoesNotPersist()
{
var person = new Person(Guid.NewGuid(), "Test");
await _personRepository.AddAsync(person);

_unitOfWork.BeginTransaction();
await _personRepository.DeleteAsync(person);
_unitOfWork.Rollback();

_dbContext.People.FirstOrDefault(x => x.Id == person.Id).Should().NotBeNull();
}

[Fact]
public async Task RollbackAfterUpdate_DoesNotPersist()
{
var person = await _personRepository.GetByIdAsync(PersonSeed.ValidPersonId);
var originalName = person!.Name;

_unitOfWork.BeginTransaction();
person.Name = "New Name";
await _personRepository.UpdateAsync(person);
_unitOfWork.Rollback();

_dbContext.People.FirstOrDefault(x => x.Id == PersonSeed.ValidPersonId)!
.Name.Should().Be(originalName);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

public class Person : BaseEntity<Guid>
{
public string Name { get; init; }
public string Name { get; set; }
public List<Address> Addresses { get; init; } = new();

public Person(Guid id, string name)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Ardalis.Specification;
using Centeva.DomainModeling.UnitTests.Fixtures.Entities;

namespace Centeva.DomainModeling.UnitTests.Fixtures.Specs;
public class PersonByNameCaseInsensitiveSpec : Specification<Person>, ISingleResultSpecification<Person>
{
public PersonByNameCaseInsensitiveSpec(string name)
{
Query
.Where(x => x.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase));
}
}