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..858c2c5
--- /dev/null
+++ b/HrynCo.DAL.EF.Tests/EfUnitOfWorkTests.cs
@@ -0,0 +1,206 @@
+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_PreservesOriginalExceptionWhenRollbackFails(bool returnsResult)
+ {
+ var original = new InvalidOperationException("Action failed.");
+ var interceptor = new TransactionFailureInterceptor
+ {
+ RollbackException = new InvalidOperationException("Transaction completed.")
+ };
+ await using DbContext context = await CreateContextAsync(interceptor);
+ var unitOfWork = new TestUnitOfWork(context);
+
+ Exception actual = await Assert.ThrowsAsync(() =>
+ ExecuteAsync(unitOfWork, returnsResult, () => Task.FromException(original)));
+
+ Assert.Same(original, actual);
+ Assert.Equal(1, interceptor.RollbackAttempts);
+ Assert.Null(unitOfWork.GetCurrentTransaction());
+ Assert.Null(context.Database.CurrentTransaction);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task CommitFailure_PreservesOriginalExceptionWhenRollbackFails(bool returnsResult)
+ {
+ var original = new InvalidOperationException("Commit failed.");
+ var interceptor = new TransactionFailureInterceptor
+ {
+ CommitException = original,
+ RollbackException = new InvalidOperationException("Transaction completed.")
+ };
+ await using DbContext context = await CreateContextAsync(interceptor);
+ var unitOfWork = new TestUnitOfWork(context);
+
+ Exception actual = await Assert.ThrowsAsync(() =>
+ ExecuteAsync(unitOfWork, returnsResult, () => Task.CompletedTask));
+
+ Assert.Same(original, actual);
+ 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_PreservesActionException(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.");
+
+ Exception actual = await Assert.ThrowsAsync(() =>
+ ExecuteAsync(unitOfWork, returnsResult, async () =>
+ {
+ await context.Database.CurrentTransaction!.CommitAsync();
+ throw original;
+ }));
+
+ Assert.Same(original, actual);
+ 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..cc810f8 100644
--- a/HrynCo.DAL.EF/Core/EfUnitOfWork.cs
+++ b/HrynCo.DAL.EF/Core/EfUnitOfWork.cs
@@ -27,71 +27,60 @@ public class EfUnitOfWork : IUnitOfWork
return _currentTransaction;
}
- public async Task ExecuteInTransactionAsync(Func action)
+ public Task ExecuteInTransactionAsync(Func action)
{
- ITransaction? existing = GetCurrentTransaction();
- bool ownsTransaction = existing is null;
-
- ITransaction tx = existing ?? await BeginTransactionAsync();
-
- try
+ return ExecuteInTransactionAsync(async () =>
{
await action();
- if (ownsTransaction)
- {
- await tx.CommitAsync();
- }
- }
- catch
- {
- if (ownsTransaction)
- {
- await tx.RollbackAsync();
- }
-
- throw;
- }
- finally
- {
- if (ownsTransaction)
- {
- await tx.DisposeAsync();
- }
- }
+ return true;
+ });
}
public async Task ExecuteInTransactionAsync(Func> action)
{
ITransaction? existing = GetCurrentTransaction();
- bool ownsTransaction = existing is null;
+ if (existing is not null)
+ {
+ return await action();
+ }
- ITransaction tx = existing ?? await BeginTransactionAsync();
+ ITransaction tx = await BeginTransactionAsync();
try
{
TResult result = await action();
- if (ownsTransaction)
- {
- await tx.CommitAsync();
- }
+ await tx.CommitAsync();
return result;
}
catch
{
- if (ownsTransaction)
- {
- await tx.RollbackAsync();
- }
-
+ await TryRollbackAsync(tx);
throw;
}
finally
{
- if (ownsTransaction)
+ try
{
await tx.DisposeAsync();
}
+ finally
+ {
+ _currentTransaction = null;
+ }
+ }
+ }
+
+ private static async Task TryRollbackAsync(ITransaction transaction)
+ {
+ try
+ {
+ await transaction.RollbackAsync();
+ }
+ catch
+ {
+ // Rollback can fail when the provider has already completed the transaction.
+ // Preserve the action or commit exception that caused the rollback attempt.
}
}
diff --git a/README.md b/README.md
index 8e5903f..9a541f2 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. A rollback failure (including a provider reporting that the transaction has already completed) does not replace the original exception. 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 @@
+