diff --git a/src/Centeva.DomainModeling.EFCore/ServiceCollectionExtensions.cs b/src/Centeva.DomainModeling.EFCore/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..649f93e
--- /dev/null
+++ b/src/Centeva.DomainModeling.EFCore/ServiceCollectionExtensions.cs
@@ -0,0 +1,15 @@
+using Centeva.DomainModeling.Interfaces;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Centeva.DomainModeling.EFCore;
+
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// Add the EF Core implementation of IUnitOfWork to the DI container
+ ///
+ ///
+ ///
+ public static IServiceCollection AddUnitOfWork(this IServiceCollection services) =>
+ services.AddScoped();
+}
\ No newline at end of file
diff --git a/src/Centeva.DomainModeling.EFCore/UnitOfWork.cs b/src/Centeva.DomainModeling.EFCore/UnitOfWork.cs
new file mode 100644
index 0000000..cbbce09
--- /dev/null
+++ b/src/Centeva.DomainModeling.EFCore/UnitOfWork.cs
@@ -0,0 +1,80 @@
+using Centeva.DomainModeling.Interfaces;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Storage;
+
+namespace Centeva.DomainModeling.EFCore;
+
+///
+/// Unit of Work implementation for Entity Framework Core. Register this with your DI container
+/// as an implementation of . 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 extension method.
+///
+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 SaveAndCommitAsync(CancellationToken cancellationToken = default)
+ {
+ var result = await SaveChangesAsync(cancellationToken);
+ Commit();
+ return result;
+ }
+
+ public Task SaveChangesAsync(CancellationToken cancellationToken = default)
+ {
+ return _dbContext.SaveChangesAsync(cancellationToken);
+ }
+}
\ No newline at end of file
diff --git a/src/Centeva.DomainModeling/Interfaces/IUnitOfWork.cs b/src/Centeva.DomainModeling/Interfaces/IUnitOfWork.cs
new file mode 100644
index 0000000..61b1c8f
--- /dev/null
+++ b/src/Centeva.DomainModeling/Interfaces/IUnitOfWork.cs
@@ -0,0 +1,47 @@
+namespace Centeva.DomainModeling.Interfaces;
+
+///
+/// Represents a Unit of Work for managing multiple repository transactions as a single unit.
+///
+///
+/// Any repository modifications that happen between and will be
+/// committed together. If 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.
+///
+public interface IUnitOfWork : IDisposable
+{
+ ///
+ /// Begin a new transaction. All changes will be rolled back if not committed.
+ ///
+ void BeginTransaction();
+
+ ///
+ /// Commit the transaction. All changes will be saved.
+ ///
+ void Commit();
+
+ ///
+ /// Rollback the transaction. All changes will be discarded.
+ ///
+ void Rollback();
+
+ ///
+ /// Save all pending changes and commit the transaction.
+ ///
+ /// A to observe while waiting for the task to complete.
+ ///
+ /// A task that represents the asynchronous save operation. The task result contains the
+ /// number of state entries written.
+ ///
+ Task SaveAndCommitAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Save all pending changes. Does not commit the transaction.
+ ///
+ /// A to observe while waiting for the task to complete.
+ ///
+ /// A task that represents the asynchronous save operation. The task result contains the
+ /// number of state entries written.
+ ///
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+}
\ No newline at end of file
diff --git a/test/Centeva.DomainModeling.IntegrationTests/Fixtures/IntegrationTestBase.cs b/test/Centeva.DomainModeling.IntegrationTests/Fixtures/IntegrationTestBase.cs
index c6e5478..1137e04 100644
--- a/test/Centeva.DomainModeling.IntegrationTests/Fixtures/IntegrationTestBase.cs
+++ b/test/Centeva.DomainModeling.IntegrationTests/Fixtures/IntegrationTestBase.cs
@@ -5,10 +5,10 @@ namespace Centeva.DomainModeling.IntegrationTests.Fixtures;
public abstract class IntegrationTestBase : IClassFixture
{
- protected TestDbContext _dbContext;
+ protected readonly TestDbContext _dbContext;
- protected Repository _personRepository;
- protected Repository _addressRepository;
+ protected readonly Repository _personRepository;
+ protected readonly Repository _addressRepository;
protected IntegrationTestBase(SharedDatabaseFixture fixture)
{
diff --git a/test/Centeva.DomainModeling.IntegrationTests/Fixtures/LoggerFactoryProvider.cs b/test/Centeva.DomainModeling.IntegrationTests/Fixtures/LoggerFactoryProvider.cs
index 021cd43..4e8092b 100644
--- a/test/Centeva.DomainModeling.IntegrationTests/Fixtures/LoggerFactoryProvider.cs
+++ b/test/Centeva.DomainModeling.IntegrationTests/Fixtures/LoggerFactoryProvider.cs
@@ -1,4 +1,5 @@
-using Microsoft.Extensions.Logging;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
namespace Centeva.DomainModeling.IntegrationTests.Fixtures;
@@ -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();
});
}
\ No newline at end of file
diff --git a/test/Centeva.DomainModeling.IntegrationTests/UnitOfWorkTests/RollbackTests.cs b/test/Centeva.DomainModeling.IntegrationTests/UnitOfWorkTests/RollbackTests.cs
new file mode 100644
index 0000000..2aa96aa
--- /dev/null
+++ b/test/Centeva.DomainModeling.IntegrationTests/UnitOfWorkTests/RollbackTests.cs
@@ -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);
+ }
+}
\ No newline at end of file
diff --git a/test/Centeva.DomainModeling.UnitTests/Fixtures/Entities/Person.cs b/test/Centeva.DomainModeling.UnitTests/Fixtures/Entities/Person.cs
index b165172..68f8941 100644
--- a/test/Centeva.DomainModeling.UnitTests/Fixtures/Entities/Person.cs
+++ b/test/Centeva.DomainModeling.UnitTests/Fixtures/Entities/Person.cs
@@ -2,7 +2,7 @@
public class Person : BaseEntity
{
- public string Name { get; init; }
+ public string Name { get; set; }
public List Addresses { get; init; } = new();
public Person(Guid id, string name)
diff --git a/test/Centeva.DomainModeling.UnitTests/Fixtures/Specs/PersonByNameCaseInsensitiveSpec.cs b/test/Centeva.DomainModeling.UnitTests/Fixtures/Specs/PersonByNameCaseInsensitiveSpec.cs
new file mode 100644
index 0000000..fa9c929
--- /dev/null
+++ b/test/Centeva.DomainModeling.UnitTests/Fixtures/Specs/PersonByNameCaseInsensitiveSpec.cs
@@ -0,0 +1,12 @@
+using Ardalis.Specification;
+using Centeva.DomainModeling.UnitTests.Fixtures.Entities;
+
+namespace Centeva.DomainModeling.UnitTests.Fixtures.Specs;
+public class PersonByNameCaseInsensitiveSpec : Specification, ISingleResultSpecification
+{
+ public PersonByNameCaseInsensitiveSpec(string name)
+ {
+ Query
+ .Where(x => x.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase));
+ }
+}