diff --git a/Directory.Packages.props b/Directory.Packages.props
index 4664a2e..7fd7d12 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -9,5 +9,11 @@
+
+
+
+
+
+
diff --git a/HrynCo.DAL.EF.Tests/EfUnitOfWorkTests.cs b/HrynCo.DAL.EF.Tests/EfUnitOfWorkTests.cs
new file mode 100644
index 0000000..9e392ce
--- /dev/null
+++ b/HrynCo.DAL.EF.Tests/EfUnitOfWorkTests.cs
@@ -0,0 +1,213 @@
+namespace HrynCo.DAL.EF.Tests;
+
+using HrynCo.DAL.Abstract;
+using Microsoft.EntityFrameworkCore;
+using Xunit;
+
+public sealed class EfUnitOfWorkTests
+{
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task ActionFailure_ReportsBothExceptionsWhenRollbackFails(bool returnsResult)
+ {
+ var original = new InvalidOperationException("Action failed.");
+ var rollbackFailure = new InvalidOperationException("Transaction completed.");
+ var interceptor = new TransactionFailureInterceptor
+ {
+ RollbackException = rollbackFailure
+ };
+ await using DbContext context = await CreateContextAsync(interceptor);
+ var unitOfWork = new TestUnitOfWork(context);
+
+ AggregateException actual = await Assert.ThrowsAsync(() =>
+ ExecuteAsync(unitOfWork, returnsResult, () => Task.FromException(original)));
+
+ Assert.Collection(actual.InnerExceptions,
+ error => Assert.Same(original, error),
+ error => Assert.Same(rollbackFailure, error));
+ Assert.Equal(1, interceptor.RollbackAttempts);
+ Assert.Null(unitOfWork.GetCurrentTransaction());
+ Assert.Null(context.Database.CurrentTransaction);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task CommitFailure_ReportsBothExceptionsWhenRollbackFails(bool returnsResult)
+ {
+ var original = new InvalidOperationException("Commit failed.");
+ var rollbackFailure = new InvalidOperationException("Transaction completed.");
+ var interceptor = new TransactionFailureInterceptor
+ {
+ CommitException = original,
+ RollbackException = rollbackFailure
+ };
+ await using DbContext context = await CreateContextAsync(interceptor);
+ var unitOfWork = new TestUnitOfWork(context);
+
+ AggregateException actual = await Assert.ThrowsAsync(() =>
+ ExecuteAsync(unitOfWork, returnsResult, () => Task.CompletedTask));
+
+ Assert.Collection(actual.InnerExceptions,
+ error => Assert.Same(original, error),
+ error => Assert.Same(rollbackFailure, error));
+ Assert.Equal(1, interceptor.CommitAttempts);
+ Assert.Equal(1, interceptor.RollbackAttempts);
+ Assert.Null(unitOfWork.GetCurrentTransaction());
+ Assert.Null(context.Database.CurrentTransaction);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task CompletedTransaction_ReportsActionAndRollbackExceptions(bool returnsResult)
+ {
+ await using DbContext context = await CreateContextAsync();
+ var unitOfWork = new TestUnitOfWork(context);
+ var original = new InvalidOperationException("Action failed after the provider completed the transaction.");
+
+ AggregateException actual = await Assert.ThrowsAsync(() =>
+ ExecuteAsync(unitOfWork, returnsResult, async () =>
+ {
+ await context.Database.CurrentTransaction!.CommitAsync();
+ throw original;
+ }));
+
+ Assert.Same(original, actual.InnerExceptions[0]);
+ Assert.IsType(actual.InnerExceptions[1]);
+ Assert.Null(unitOfWork.GetCurrentTransaction());
+ Assert.Null(context.Database.CurrentTransaction);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task ActionFailure_RollsBackWritesAndAllowsNextTransaction(bool returnsResult)
+ {
+ await using DbContext context = await CreateContextAsync();
+ var unitOfWork = new TestUnitOfWork(context);
+ var original = new InvalidOperationException("Action failed.");
+
+ Exception actual = await Assert.ThrowsAsync(() =>
+ ExecuteAsync(unitOfWork, returnsResult, async () =>
+ {
+ await context.Database.ExecuteSqlRawAsync("INSERT INTO records VALUES (1)");
+ throw original;
+ }));
+
+ Assert.Same(original, actual);
+ Assert.Equal(0, await CountRecordsAsync(context));
+ Assert.Null(unitOfWork.GetCurrentTransaction());
+ await ExecuteAsync(unitOfWork, returnsResult,
+ () => context.Database.ExecuteSqlRawAsync("INSERT INTO records VALUES (2)"));
+ Assert.Equal(1, await CountRecordsAsync(context));
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Success_CommitsWritesAndCreatesFreshTransactionForNextCall(bool returnsResult)
+ {
+ var interceptor = new TransactionFailureInterceptor();
+ await using DbContext context = await CreateContextAsync(interceptor);
+ var unitOfWork = new TestUnitOfWork(context);
+ ITransaction? first = null;
+
+ await ExecuteAsync(unitOfWork, returnsResult, async () =>
+ {
+ first = unitOfWork.GetCurrentTransaction();
+ await context.Database.ExecuteSqlRawAsync("INSERT INTO records VALUES (1)");
+ });
+ Assert.Null(unitOfWork.GetCurrentTransaction());
+ Assert.Null(context.Database.CurrentTransaction);
+ await ExecuteAsync(unitOfWork, returnsResult, async () =>
+ {
+ Assert.NotSame(first, unitOfWork.GetCurrentTransaction());
+ await context.Database.ExecuteSqlRawAsync("INSERT INTO records VALUES (2)");
+ });
+
+ Assert.Equal(2, await CountRecordsAsync(context));
+ Assert.Equal(2, interceptor.CommitAttempts);
+ Assert.Equal(0, interceptor.RollbackAttempts);
+ Assert.Null(unitOfWork.GetCurrentTransaction());
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task CallerOwnedTransaction_SuccessLeavesLifecycleToCaller(bool returnsResult)
+ {
+ var interceptor = new TransactionFailureInterceptor();
+ await using DbContext context = await CreateContextAsync(interceptor);
+ var unitOfWork = new TestUnitOfWork(context);
+ await using ITransaction transaction = await unitOfWork.BeginTransactionAsync();
+
+ await ExecuteAsync(unitOfWork, returnsResult,
+ () => context.Database.ExecuteSqlRawAsync("INSERT INTO records VALUES (1)"));
+
+ Assert.Same(transaction, unitOfWork.GetCurrentTransaction());
+ Assert.NotNull(context.Database.CurrentTransaction);
+ Assert.Equal(0, interceptor.CommitAttempts);
+ Assert.Equal(0, interceptor.RollbackAttempts);
+ await transaction.RollbackAsync();
+ Assert.Equal(0, await CountRecordsAsync(context));
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task CallerOwnedTransaction_FailureLeavesLifecycleToCaller(bool returnsResult)
+ {
+ var interceptor = new TransactionFailureInterceptor();
+ await using DbContext context = await CreateContextAsync(interceptor);
+ var unitOfWork = new TestUnitOfWork(context);
+ await using ITransaction transaction = await unitOfWork.BeginTransactionAsync();
+ var original = new InvalidOperationException("Action failed.");
+
+ Exception actual = await Assert.ThrowsAsync(() =>
+ ExecuteAsync(unitOfWork, returnsResult, () => Task.FromException(original)));
+
+ Assert.Same(original, actual);
+ Assert.Same(transaction, unitOfWork.GetCurrentTransaction());
+ Assert.NotNull(context.Database.CurrentTransaction);
+ Assert.Equal(0, interceptor.CommitAttempts);
+ Assert.Equal(0, interceptor.RollbackAttempts);
+ await transaction.RollbackAsync();
+ }
+
+ private static async Task ExecuteAsync(TestUnitOfWork unitOfWork, bool returnsResult, Func action)
+ {
+ if (returnsResult)
+ {
+ int result = await unitOfWork.ExecuteInTransactionAsync(async () =>
+ {
+ await action();
+ return 42;
+ });
+ Assert.Equal(42, result);
+ return;
+ }
+
+ await unitOfWork.ExecuteInTransactionAsync(action);
+ }
+
+ private static async Task CreateContextAsync(TransactionFailureInterceptor? interceptor = null)
+ {
+ var options = new DbContextOptionsBuilder().UseSqlite("Data Source=:memory:");
+ if (interceptor is not null)
+ {
+ options.AddInterceptors(interceptor);
+ }
+
+ var context = new DbContext(options.Options);
+ await context.Database.OpenConnectionAsync();
+ await context.Database.ExecuteSqlRawAsync("CREATE TABLE records (id INTEGER NOT NULL)");
+ return context;
+ }
+
+ private static Task CountRecordsAsync(DbContext context)
+ {
+ return context.Database.SqlQueryRaw("SELECT COUNT(*) AS Value FROM records").SingleAsync();
+ }
+}
diff --git a/HrynCo.DAL.EF.Tests/HrynCo.DAL.EF.Tests.csproj b/HrynCo.DAL.EF.Tests/HrynCo.DAL.EF.Tests.csproj
new file mode 100644
index 0000000..d852dd9
--- /dev/null
+++ b/HrynCo.DAL.EF.Tests/HrynCo.DAL.EF.Tests.csproj
@@ -0,0 +1,20 @@
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
diff --git a/HrynCo.DAL.EF.Tests/TestUnitOfWork.cs b/HrynCo.DAL.EF.Tests/TestUnitOfWork.cs
new file mode 100644
index 0000000..bc77e68
--- /dev/null
+++ b/HrynCo.DAL.EF.Tests/TestUnitOfWork.cs
@@ -0,0 +1,6 @@
+namespace HrynCo.DAL.EF.Tests;
+
+using HrynCo.DAL.EF.Core;
+using Microsoft.EntityFrameworkCore;
+
+internal sealed class TestUnitOfWork(DbContext context) : EfUnitOfWork(context);
diff --git a/HrynCo.DAL.EF.Tests/TransactionFailureInterceptor.cs b/HrynCo.DAL.EF.Tests/TransactionFailureInterceptor.cs
new file mode 100644
index 0000000..19c3b51
--- /dev/null
+++ b/HrynCo.DAL.EF.Tests/TransactionFailureInterceptor.cs
@@ -0,0 +1,42 @@
+namespace HrynCo.DAL.EF.Tests;
+
+using System.Data.Common;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+
+internal sealed class TransactionFailureInterceptor : DbTransactionInterceptor
+{
+ public Exception? CommitException { get; init; }
+ public Exception? RollbackException { get; init; }
+ public int CommitAttempts { get; private set; }
+ public int RollbackAttempts { get; private set; }
+
+ public override ValueTask TransactionCommittingAsync(
+ DbTransaction transaction,
+ TransactionEventData eventData,
+ InterceptionResult result,
+ CancellationToken cancellationToken = default)
+ {
+ CommitAttempts++;
+ if (CommitException is not null)
+ {
+ throw CommitException;
+ }
+
+ return ValueTask.FromResult(result);
+ }
+
+ public override ValueTask TransactionRollingBackAsync(
+ DbTransaction transaction,
+ TransactionEventData eventData,
+ InterceptionResult result,
+ CancellationToken cancellationToken = default)
+ {
+ RollbackAttempts++;
+ if (RollbackException is not null)
+ {
+ throw RollbackException;
+ }
+
+ return ValueTask.FromResult(result);
+ }
+}
diff --git a/HrynCo.DAL.EF/Core/EfUnitOfWork.cs b/HrynCo.DAL.EF/Core/EfUnitOfWork.cs
index 3aa272c..43aae4e 100644
--- a/HrynCo.DAL.EF/Core/EfUnitOfWork.cs
+++ b/HrynCo.DAL.EF/Core/EfUnitOfWork.cs
@@ -42,11 +42,11 @@ public class EfUnitOfWork : IUnitOfWork
await tx.CommitAsync();
}
}
- catch
+ catch (Exception original)
{
if (ownsTransaction)
{
- await tx.RollbackAsync();
+ await RollbackPreservingFailureAsync(tx, original);
}
throw;
@@ -55,7 +55,14 @@ public class EfUnitOfWork : IUnitOfWork
{
if (ownsTransaction)
{
- await tx.DisposeAsync();
+ try
+ {
+ await tx.DisposeAsync();
+ }
+ finally
+ {
+ _currentTransaction = null;
+ }
}
}
}
@@ -77,11 +84,11 @@ public class EfUnitOfWork : IUnitOfWork
return result;
}
- catch
+ catch (Exception original)
{
if (ownsTransaction)
{
- await tx.RollbackAsync();
+ await RollbackPreservingFailureAsync(tx, original);
}
throw;
@@ -90,11 +97,30 @@ public class EfUnitOfWork : IUnitOfWork
{
if (ownsTransaction)
{
- await tx.DisposeAsync();
+ try
+ {
+ await tx.DisposeAsync();
+ }
+ finally
+ {
+ _currentTransaction = null;
+ }
}
}
}
+ private static async Task RollbackPreservingFailureAsync(ITransaction transaction, Exception original)
+ {
+ try
+ {
+ await transaction.RollbackAsync();
+ }
+ catch (Exception rollbackFailure)
+ {
+ throw new AggregateException("The transaction and its rollback both failed.", original, rollbackFailure);
+ }
+ }
+
public ITransaction? GetCurrentTransaction()
{
return _currentTransaction;
diff --git a/README.md b/README.md
index 8e5903f..1aa05ee 100644
--- a/README.md
+++ b/README.md
@@ -4,12 +4,13 @@ Reusable Entity Framework Core base library for HrynCo applications.
## Solution
-The solution (`hrynco-ef.slnx`) contains two projects:
+The solution (`hrynco-ef.slnx`) contains the following projects:
| Project | Description |
|---|---|
| `HrynCo.DAL.Abstract` | Infrastructure-agnostic contracts: entities, repository interfaces, unit of work, transactions, pagination. No EF Core dependency. |
| `HrynCo.DAL.EF` | Entity Framework Core implementations of the abstract contracts. Depends on `HrynCo.DAL.Abstract` and EF Core. |
+| `HrynCo.DAL.EF.Tests` | Transaction lifecycle regression tests using SQLite in memory. |
The split allows consuming projects to reference only `HrynCo.DAL.Abstract` in domain/application layers, keeping those layers free of EF Core.
@@ -181,6 +182,18 @@ Entity Framework Core implementations of the abstract contracts.
## Usage
+### Transaction ownership and failures
+
+Both `ExecuteInTransactionAsync` overloads create, commit, and dispose a transaction when no transaction is already cached by the unit of work. After disposal, the cached reference is cleared, so a later call starts a fresh transaction.
+
+If the action or commit fails, the owned transaction is rolled back. If rollback also fails (including when the provider reports a completed transaction), an `AggregateException` contains the original failure first and the rollback failure second. Disposal still runs.
+
+When `BeginTransactionAsync` has already supplied a transaction to the caller, `ExecuteInTransactionAsync` runs the action inside that transaction and leaves commit, rollback, and disposal to the caller. Its cached reference is retained as before; the automatic cleanup above applies only to transactions owned by `ExecuteInTransactionAsync`.
+
+Run the regression suite with `dotnet test hrynco-ef.slnx`.
+
+### Package references
+
Reference `HrynCo.DAL.Abstract` for contracts only (e.g. in domain/application layers).
Reference `HrynCo.DAL.EF` for the full EF Core implementation (infrastructure layer).
diff --git a/hrynco-ef.slnx b/hrynco-ef.slnx
index ef03f8d..c9a3b3f 100644
--- a/hrynco-ef.slnx
+++ b/hrynco-ef.slnx
@@ -1,4 +1,5 @@
+