From 53b247a5c591921f7751b99f1f8a78a7125ab544 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sat, 17 Oct 2026 10:49:40 +0400 Subject: [PATCH 1/8] feat: add concurrency exception behavior --- README.md | 32 +++- .../Abstractions/IExceptionRequestBehavior.cs | 35 ++++ .../Abstractions/IFinallyRequestBehavior.cs | 5 + .../Behaviors/ConcurrencyExceptionBehavior.cs | 47 +++++ .../Persistence/ConcurrencyException.cs | 43 +++++ .../ConcurrencyExceptionBehaviorTests.cs | 171 ++++++++++++++++++ .../Persistence/ConcurrencyExceptionTests.cs | 81 +++++++++ version.json | 2 +- 8 files changed, 411 insertions(+), 5 deletions(-) create mode 100644 src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IExceptionRequestBehavior.cs create mode 100644 src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehavior.cs create mode 100644 src/PANiXiDA.Core.Application/Persistence/ConcurrencyException.cs create mode 100644 tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehaviorTests.cs create mode 100644 tests/PANiXiDA.Core.Application.UnitTests/Persistence/ConcurrencyExceptionTests.cs diff --git a/README.md b/README.md index 0e53bab..3e1858d 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,14 @@ It defines contracts and small reusable building blocks for commands, queries, h - CQRS request contracts: `ICommand`, `IQuery`, and `IRequest`. - Mediator contracts for command/query dispatch and handler implementation. -- Pipeline behavior contracts for before, after, and finally request stages, including before-stage success/failure results. +- Pipeline behavior contracts for before, after, finally, and exception request stages, including failure-result proposals after successful cleanup. - Built-in behaviors for permission-based authorization, FluentValidation request validation, transaction start, commit, cleanup, and domain event publishing. - Transport-independent current caller information and handler-level authentication and permission requirements. - FluentValidation extensions for converting single-property and complex domain factory `Result` errors into validation failures. - Event bus and event handler abstractions for `DomainEvent` integration. - A shared scheduler contract with typed command and domain event overloads for delayed delivery or delivery at a specified time. - Unit of work, read repository, and aggregate tracker abstractions for application persistence boundaries. +- Provider-independent optimistic concurrency exceptions and a behavior that maps them to conflict results. - File storage abstraction for uploading, downloading, and deleting content by caller-supplied keys, including signed URLs for direct transfers. - `IReadModel` marker interface for immutable read-side result models. - Read-side helper models for page-based pagination, cursor pagination, multi-field sorting, filtering, and validated result limits. @@ -37,7 +38,7 @@ It defines contracts and small reusable building blocks for commands, queries, h ```xml - + ``` @@ -269,7 +270,7 @@ there is no criterion count limit. Errors retain paths such as `Sorting.Fields[0 ## Request Behaviors -The package includes reusable mediator behavior implementations for authorization, request validation, command transaction orchestration, and domain event publication: +The package includes reusable mediator behavior implementations for authorization, request validation, command transaction orchestration, domain event publication, and optimistic concurrency handling: - `AuthorizationBehavior` checks authentication and permissions declared by handlers implementing `IRequireAuthorization`. - `ValidationBehavior` validates requests with registered FluentValidation `IValidator` implementations and returns a failed `Result` before the handler runs when validation fails. @@ -277,8 +278,9 @@ The package includes reusable mediator behavior implementations for authorizatio - `PublishDomainEventsBehavior` publishes domain events collected from tracked aggregate roots after a successful command result and clears tracked events after a failed result or completed successful publication. - `CommitTransactionBehavior` commits the active transaction after a successful command result. - `CleanupTransactionBehavior` rolls back failed command transactions and disposes transaction resources. +- `ConcurrencyExceptionBehavior` offers a conflict failure for a direct `ConcurrencyException` after successful cleanup, unless the request already produced a failed result. -Transaction cleanup always attempts disposal, even if rollback fails. A single failure retains its original exception instance and stack trace. When cleanup adds failures to the command exception, or both rollback and disposal fail, the behavior throws an `AggregateException` containing the failures in execution order, with the command exception first. Existing aggregates are preserved; use `Flatten()` to inspect nested failures. If cleanup succeeds, the mediator remains responsible for propagating the original command exception. A single cancellation remains cancellation; cancellation combined with another failure is reported as an aggregate failure. +Transaction cleanup always attempts disposal, even if rollback fails. A single failure retains its original exception instance and stack trace. When cleanup adds failures to the command exception, or both rollback and disposal fail, the behavior throws an `AggregateException` containing the failures in execution order, with the command exception first. Existing aggregates are preserved; use `Flatten()` to inspect nested failures. If cleanup succeeds, the mediator remains responsible for propagating the original command exception unless an explicitly configured exception behavior handles it. A single cancellation remains cancellation; cancellation combined with another failure is reported as an aggregate failure. Domain event publication and transaction behaviors require `ICommand` and do not apply to `IQuery` or plain `IRequest` implementations. Query pipelines retain request validation and exclude these command-only behaviors. Directly closing `PublishDomainEventsBehavior` for a query or plain request is no longer supported. @@ -298,6 +300,25 @@ The exact registration mechanism depends on the mediator or composition root use A consuming mediator should continue to the handler when a before behavior returns `Result.Success()`. When a before behavior returns a failed `Result`, the mediator should stop the pipeline and return a failed request `TResult` with the same errors. +### Optimistic Concurrency + +`ConcurrencyException` in `PANiXiDA.Core.Application.Persistence` is the provider-independent signal that a persistence operation could not match the expected entity version. It also covers deletion after the entity was read. Adapters can supply a diagnostic message and preserve the original provider exception as `InnerException` with `new ConcurrencyException(message, providerException)`. + +An adapter that detects the conflict from an affected-row or matched-document count can use `new ConcurrencyException()` without an inner exception. Connection failures, timeouts, and arbitrary database errors must not be classified as optimistic concurrency conflicts. + +`IExceptionRequestBehavior.HandleExceptionAsync` receives the request, its last result (which may be null), and the non-null processing exception. It returns a non-generic failed `Result` to handle the exception, or null to decline. `ConcurrencyExceptionBehavior` returns `Error.Conflict` with a stable, client-safe message; diagnostic exception messages and provider details are not included. It declines existing failed results, unrelated exceptions, cancellation, and aggregates without inspecting their inner exceptions. + +Mediator adapters that support this stage must: + +1. Complete every finally behavior first, preserving the original result and processing exception for cleanup. +2. Propagate any cleanup failure without running exception behaviors, even when the cleanup failure itself is a `ConcurrencyException`. +3. Preserve the current result when no processing exception exists. If a failed result and a processing exception both exist, propagate the exception without replacing that failed result. +4. For an unhandled processing exception with a null or successful result, run exception behaviors in registration order until one returns a non-null result. A returned success is a contract violation and must be rejected; the original processing exception must remain available in the reported failure. +5. Return the offered failure directly for `Result`, or construct `Result.Failure(failure.Errors)` for `Result`. A failed `Result` cannot be cast to `Result`. +6. When all exception behaviors decline, propagate the original exception with its original instance and stack trace. + +The normal before, after, and finally contracts remain unchanged. This package provides the exception-stage contract and concurrency behavior only: upgrading Application does not enable the stage in an existing mediator, translate provider exceptions, or automatically produce HTTP 409. The Wolverine adapter needs separate registration and code-generation support. Background message delivery needs an explicit policy because handling an exception as a result changes retry behavior. + ### Handler Authorization Implement `IRequireAuthorization` on a command or query handler and provide @@ -476,6 +497,8 @@ The aggregate repository contract is intentionally not defined by this package; - `IBeforeRequestBehavior` runs before a handler and returns `Result.Success()` to continue request processing, or a failed `Result` to stop it. - `IAfterRequestBehavior` runs after a handler returns a result and is defined in the mediator behavior abstractions namespace. - `IFinallyRequestBehavior` runs after request processing completes or fails and is defined in the mediator behavior abstractions namespace. +- `IExceptionRequestBehavior` offers a failure result for an expected processing exception after successful cleanup; null leaves the exception unhandled. +- `ConcurrencyExceptionBehavior` maps direct optimistic concurrency exceptions to `Error.Conflict` without replacing existing failure results. ### Validation @@ -492,6 +515,7 @@ The aggregate repository contract is intentionally not defined by this package; - `IUnitOfWork` defines persistence and transaction operations. - `IReadRepository` defines read-only `ExistsByIdAsync` and `AnyAsync` checks. +- `ConcurrencyException` reports an optimistic concurrency conflict independently of a database provider. - Aggregate persistence uses `IRepository` from `PANiXiDA.Core.Domain`. ### Querying Models diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IExceptionRequestBehavior.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IExceptionRequestBehavior.cs new file mode 100644 index 0000000..906ef37 --- /dev/null +++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IExceptionRequestBehavior.cs @@ -0,0 +1,35 @@ +using PANiXiDA.Core.Application.Messaging.Mediator.Contracts; + +namespace PANiXiDA.Core.Application.Messaging.Mediator.Behaviors.Abstractions; + +/// +/// Offers a failure result for an exception after request cleanup has completed successfully. +/// +/// The request type processed by the behavior. +/// The result type returned by the request. +/// +/// A consuming mediator invokes this stage only for a processing exception after all finally behaviors succeed. +/// Cleanup failures must propagate without invoking this stage. A failed request result must not be replaced. +/// Behaviors run in registration order until one returns a non-null failure result. The mediator converts its errors +/// to the declared request result type, preserving support for both Result and Result<T>. +/// A successful returned result is a contract violation. When all behaviors decline, the original exception +/// must propagate with its original instance and stack trace. Background delivery requires an explicit handling policy. +/// +public interface IExceptionRequestBehavior + where TRequest : IRequest + where TResult : Result +{ + /// + /// Attempts to convert an expected processing exception to a failure result. + /// + /// The request that was processed. + /// The last request result, or null when no result was produced. + /// The non-null processing exception. + /// The cancellation token for the operation. + /// A failure result when the exception is handled, or null to leave it unhandled. + Task HandleExceptionAsync( + TRequest request, + TResult? result, + Exception exception, + CancellationToken cancellationToken); +} diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IFinallyRequestBehavior.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IFinallyRequestBehavior.cs index ba89519..82e4a85 100644 --- a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IFinallyRequestBehavior.cs +++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IFinallyRequestBehavior.cs @@ -7,6 +7,11 @@ namespace PANiXiDA.Core.Application.Messaging.Mediator.Behaviors.Abstractions; /// /// The request type processed by the behavior. /// The result type returned by the request. +/// +/// Finally behaviors perform cleanup without replacing the request result or handling the processing exception. +/// A consuming mediator completes all finally behaviors before invoking any exception behavior. +/// Cleanup failures must propagate without being converted to failure results by exception behaviors. +/// public interface IFinallyRequestBehavior where TRequest : IRequest where TResult : Result diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehavior.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehavior.cs new file mode 100644 index 0000000..888e837 --- /dev/null +++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehavior.cs @@ -0,0 +1,47 @@ +using PANiXiDA.Core.Application.Messaging.Mediator.Behaviors.Abstractions; +using PANiXiDA.Core.Application.Messaging.Mediator.Contracts; +using PANiXiDA.Core.Application.Persistence; + +namespace PANiXiDA.Core.Application.Messaging.Mediator.Behaviors; + +/// +/// Converts a direct optimistic concurrency exception to a conflict failure result. +/// +/// The request type processed by the behavior. +/// The result type returned by the request. +/// +/// Existing failure results are preserved. Other exception types, including aggregates and cancellation, +/// are not handled. Diagnostic exception messages are not exposed in the returned error. +/// The mediator must invoke this behavior only after all request cleanup succeeds. +/// +public sealed class ConcurrencyExceptionBehavior : IExceptionRequestBehavior + where TRequest : IRequest + where TResult : Result +{ + /// + /// Offers a conflict result for a concurrency exception when there is no existing failure result. + /// + /// The request that was processed. + /// The last request result, or null when no result was produced. + /// The processing exception to inspect. + /// The cancellation token for the operation. + /// A conflict failure result, or null when the exception should propagate. + /// The request or exception is null. + public Task HandleExceptionAsync( + TRequest request, + TResult? result, + Exception exception, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(exception); + + if (result is { IsFailure: true } || exception is not ConcurrencyException) + { + return Task.FromResult(null); + } + + return Task.FromResult(Result.Failure( + Error.Conflict("The entity was modified or deleted. Reload it and try again."))); + } +} diff --git a/src/PANiXiDA.Core.Application/Persistence/ConcurrencyException.cs b/src/PANiXiDA.Core.Application/Persistence/ConcurrencyException.cs new file mode 100644 index 0000000..fbd98ea --- /dev/null +++ b/src/PANiXiDA.Core.Application/Persistence/ConcurrencyException.cs @@ -0,0 +1,43 @@ +namespace PANiXiDA.Core.Application.Persistence; + +/// +/// Indicates that a persistence operation could not match the expected entity version. +/// +/// +/// Persistence adapters use this exception for optimistic concurrency conflicts, including concurrent deletion. +/// Provider-specific exceptions can be retained as the inner exception. +/// +public sealed class ConcurrencyException : Exception +{ + /// + /// Initializes a concurrency exception with a default message. + /// + public ConcurrencyException() + : this("The entity was modified or deleted by another operation.") + { + } + + /// + /// Initializes a concurrency exception with a diagnostic message. + /// + /// A nonblank diagnostic message. + /// The message is null. + /// The message is empty or whitespace. + public ConcurrencyException(string message) + : this(message, null) + { + } + + /// + /// Initializes a concurrency exception with a diagnostic message and the original failure. + /// + /// A nonblank diagnostic message. + /// The original failure, or null when the adapter detected the conflict directly. + /// The message is null. + /// The message is empty or whitespace. + public ConcurrencyException(string message, Exception? innerException) + : base(message, innerException) + { + ArgumentException.ThrowIfNullOrWhiteSpace(message); + } +} diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehaviorTests.cs b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehaviorTests.cs new file mode 100644 index 0000000..60a2a9c --- /dev/null +++ b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehaviorTests.cs @@ -0,0 +1,171 @@ +using PANiXiDA.Core.Application.Messaging.Mediator.Behaviors; +using PANiXiDA.Core.Application.Messaging.Mediator.Behaviors.Abstractions; +using PANiXiDA.Core.Application.Messaging.Mediator.Contracts; +using PANiXiDA.Core.Application.Persistence; +using PANiXiDA.Core.Application.UnitTests.Messaging.Mediator.Behaviors.Fakes; +using PANiXiDA.Core.ResultPattern; + +namespace PANiXiDA.Core.Application.UnitTests.Messaging.Mediator.Behaviors; + +public sealed class ConcurrencyExceptionBehaviorTests +{ + [Theory(DisplayName = "ConcurrencyExceptionBehavior offers a conflict for requests returning Result")] + [InlineData(false)] + [InlineData(true)] + public async Task HandleExceptionAsync_WhenResultIsMissingOrSuccessful_ReturnsConflict(bool hasResult) + { + IExceptionRequestBehavior behavior = new ConcurrencyExceptionBehavior(); + var exception = new ConcurrencyException("Private provider diagnostics."); + var originalResult = hasResult ? Result.Success() : null; + + var result = await behavior.HandleExceptionAsync( + new TestCommand(), originalResult, exception, TestContext.Current.CancellationToken); + + result.ShouldNotBeNull(); + result.IsFailure.ShouldBeTrue(); + var error = result.Errors.ShouldHaveSingleItem(); + error.Type.ShouldBe(ErrorType.Conflict); + error.Message.ShouldBe("The entity was modified or deleted. Reload it and try again."); + error.Metadata.ShouldBeEmpty(); + originalResult?.IsSuccess.ShouldBeTrue(); + } + + [Theory(DisplayName = "ConcurrencyExceptionBehavior supports requests returning Result of T")] + [InlineData(false)] + [InlineData(true)] + public async Task HandleExceptionAsync_WhenRequestReturnsGenericResult_ReturnsConflict(bool hasResult) + { + IExceptionRequestBehavior> behavior = new ConcurrencyExceptionBehavior>(); + var originalResult = hasResult ? Result.Success("saved value") : null; + + var result = await behavior.HandleExceptionAsync( + new ValueCommand(), originalResult, new ConcurrencyException(), TestContext.Current.CancellationToken); + + result.ShouldNotBeNull(); + result.IsFailure.ShouldBeTrue(); + result.Errors.ShouldHaveSingleItem().Type.ShouldBe(ErrorType.Conflict); + originalResult?.Value.ShouldBe("saved value"); + } + + [Fact(DisplayName = "ConcurrencyExceptionBehavior leaves existing failure results unchanged")] + public async Task HandleExceptionAsync_WhenResultAlreadyFailed_DeclinesHandling() + { + var behavior = new ConcurrencyExceptionBehavior(); + var error = Error.Validation("The order name is required."); + var originalResult = Result.Failure(error); + + var result = await behavior.HandleExceptionAsync( + new TestCommand(), originalResult, new ConcurrencyException(), TestContext.Current.CancellationToken); + + result.ShouldBeNull(); + originalResult.Errors.ShouldHaveSingleItem().ShouldBeSameAs(error); + } + + [Fact(DisplayName = "ConcurrencyExceptionBehavior leaves generic failure results unchanged")] + public async Task HandleExceptionAsync_WhenGenericResultAlreadyFailed_DeclinesHandling() + { + var behavior = new ConcurrencyExceptionBehavior>(); + var error = Error.NotFound("The order was not found."); + var originalResult = Result.Failure(error); + + var result = await behavior.HandleExceptionAsync( + new ValueCommand(), originalResult, new ConcurrencyException(), TestContext.Current.CancellationToken); + + result.ShouldBeNull(); + originalResult.Errors.ShouldHaveSingleItem().ShouldBeSameAs(error); + } + + [Theory(DisplayName = "ConcurrencyExceptionBehavior declines unrelated, wrapped, aggregate and cancellation failures")] + [MemberData(nameof(UnhandledExceptions))] + public async Task HandleExceptionAsync_WhenExceptionIsNotDirectConcurrency_DeclinesHandling(Exception exception) + { + var behavior = new ConcurrencyExceptionBehavior(); + + var result = await behavior.HandleExceptionAsync( + new TestCommand(), null, exception, TestContext.Current.CancellationToken); + + result.ShouldBeNull(); + } + + public static TheoryData UnhandledExceptions => + [ + new InvalidOperationException("Unexpected failure."), + new OperationCanceledException(), + new TaskCanceledException(), + new InvalidOperationException("Wrapper failure.", new ConcurrencyException()), + new AggregateException(new ConcurrencyException()), + new AggregateException(new ConcurrencyException(), new InvalidOperationException("Rollback failed.")) + ]; + + [Fact(DisplayName = "ConcurrencyExceptionBehavior rejects a null request")] + public async Task HandleExceptionAsync_WhenRequestIsNull_ThrowsArgumentNullException() + { + var behavior = new ConcurrencyExceptionBehavior(); + + var exception = await Should.ThrowAsync(() => behavior.HandleExceptionAsync( + null!, null, new ConcurrencyException(), TestContext.Current.CancellationToken)); + + exception.ParamName.ShouldBe("request"); + } + + [Fact(DisplayName = "ConcurrencyExceptionBehavior rejects a null exception")] + public async Task HandleExceptionAsync_WhenExceptionIsNull_ThrowsArgumentNullException() + { + var behavior = new ConcurrencyExceptionBehavior(); + + var exception = await Should.ThrowAsync(() => behavior.HandleExceptionAsync( + new TestCommand(), null, null!, TestContext.Current.CancellationToken)); + + exception.ParamName.ShouldBe("exception"); + } + + [Fact(DisplayName = "ConcurrencyExceptionBehavior maps a conflict after successful transaction cleanup")] + public async Task HandleExceptionAsync_WhenTransactionCleanupSucceeds_ReturnsConflict() + { + var unitOfWork = new TestUnitOfWork { HasActiveTransaction = true }; + var cleanup = new CleanupTransactionBehavior(unitOfWork); + var behavior = new ConcurrencyExceptionBehavior(); + var request = new TestCommand(); + var exception = new ConcurrencyException(); + + await cleanup.FinallyAsync(request, null, exception, TestContext.Current.CancellationToken); + var result = await behavior.HandleExceptionAsync(request, null, exception, TestContext.Current.CancellationToken); + + unitOfWork.RollbackTransactionCalls.ShouldBe(1); + unitOfWork.DisposeTransactionCalls.ShouldBe(1); + result.ShouldNotBeNull().FirstError.Type.ShouldBe(ErrorType.Conflict); + } + + [Theory(DisplayName = "ConcurrencyExceptionBehavior does not hide transaction cleanup failures")] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task HandleExceptionAsync_WhenCleanupAggregatesFailures_DeclinesHandling(bool failRollback, bool failDispose) + { + var unitOfWork = new TestUnitOfWork + { + HasActiveTransaction = true, + RollbackException = failRollback ? new InvalidOperationException("Rollback failed.") : null, + DisposeException = failDispose ? new InvalidOperationException("Dispose failed.") : null + }; + var cleanup = new CleanupTransactionBehavior(unitOfWork); + var behavior = new ConcurrencyExceptionBehavior(); + var request = new TestCommand(); + var originalException = new ConcurrencyException(); + + var exception = await Should.ThrowAsync(() => cleanup.FinallyAsync( + request, null, originalException, TestContext.Current.CancellationToken)); + var result = await behavior.HandleExceptionAsync(request, null, exception, TestContext.Current.CancellationToken); + + result.ShouldBeNull(); + unitOfWork.DisposeTransactionCalls.ShouldBe(1); + exception.InnerExceptions.ShouldBe(new[] + { + originalException, + unitOfWork.RollbackException, + unitOfWork.DisposeException + }.OfType()); + } + + private sealed record ValueCommand : ICommand>; +} diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Persistence/ConcurrencyExceptionTests.cs b/tests/PANiXiDA.Core.Application.UnitTests/Persistence/ConcurrencyExceptionTests.cs new file mode 100644 index 0000000..df1fdc7 --- /dev/null +++ b/tests/PANiXiDA.Core.Application.UnitTests/Persistence/ConcurrencyExceptionTests.cs @@ -0,0 +1,81 @@ +using PANiXiDA.Core.Application.Persistence; + +namespace PANiXiDA.Core.Application.UnitTests.Persistence; + +public sealed class ConcurrencyExceptionTests +{ + [Fact(DisplayName = "ConcurrencyException provides a stable default message")] + public void Constructor_WhenCalledWithoutArguments_UsesDefaultMessage() + { + var exception = new ConcurrencyException(); + + exception.Message.ShouldBe("The entity was modified or deleted by another operation."); + exception.InnerException.ShouldBeNull(); + } + + [Fact(DisplayName = "ConcurrencyException preserves a custom diagnostic message")] + public void Constructor_WhenGivenMessage_PreservesMessage() + { + const string message = "The expected order version no longer exists."; + + var exception = new ConcurrencyException(message); + + exception.Message.ShouldBe(message); + exception.InnerException.ShouldBeNull(); + } + + [Fact(DisplayName = "ConcurrencyException preserves the original provider exception")] + public void Constructor_WhenGivenInnerException_PreservesInstance() + { + const string message = "The order could not be updated."; + var providerException = new InvalidOperationException("Provider diagnostics."); + + var exception = new ConcurrencyException(message, providerException); + + exception.Message.ShouldBe(message); + exception.InnerException.ShouldBeSameAs(providerException); + } + + [Fact(DisplayName = "ConcurrencyException accepts a null inner exception")] + public void Constructor_WhenInnerExceptionIsNull_PreservesMessage() + { + var exception = new ConcurrencyException("Version mismatch.", null); + + exception.Message.ShouldBe("Version mismatch."); + exception.InnerException.ShouldBeNull(); + } + + [Theory(DisplayName = "ConcurrencyException rejects a null message in both message constructors")] + [InlineData(false)] + [InlineData(true)] + public void Constructor_WhenMessageIsNull_ThrowsArgumentNullException(bool includeInnerException) + { + var innerException = new InvalidOperationException("Provider diagnostics."); + + var exception = Should.Throw(() => + includeInnerException + ? new ConcurrencyException(null!, innerException) + : new ConcurrencyException(null!)); + + exception.ParamName.ShouldBe("message"); + } + + [Theory(DisplayName = "ConcurrencyException rejects blank messages in both message constructors")] + [InlineData("", false)] + [InlineData(" ", false)] + [InlineData("\t\r\n", false)] + [InlineData("", true)] + [InlineData(" ", true)] + [InlineData("\t\r\n", true)] + public void Constructor_WhenMessageIsBlank_ThrowsArgumentException(string message, bool includeInnerException) + { + var innerException = new InvalidOperationException("Provider diagnostics."); + + var exception = Should.Throw(() => + includeInnerException + ? new ConcurrencyException(message, innerException) + : new ConcurrencyException(message)); + + exception.ParamName.ShouldBe("message"); + } +} diff --git a/version.json b/version.json index e608736..572db95 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "5.0", + "version": "5.1", "publicReleaseRefSpec": [ "^refs/heads/main$", "^refs/heads/master$" From c55970abd80af93fbb17f56d534a5956492e62e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sat, 17 Oct 2026 10:57:02 +0400 Subject: [PATCH 2/8] test: resolve concurrency behavior analysis findings --- .../ConcurrencyExceptionBehaviorTests.cs | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehaviorTests.cs b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehaviorTests.cs index 60a2a9c..a819959 100644 --- a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehaviorTests.cs +++ b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehaviorTests.cs @@ -1,5 +1,4 @@ using PANiXiDA.Core.Application.Messaging.Mediator.Behaviors; -using PANiXiDA.Core.Application.Messaging.Mediator.Behaviors.Abstractions; using PANiXiDA.Core.Application.Messaging.Mediator.Contracts; using PANiXiDA.Core.Application.Persistence; using PANiXiDA.Core.Application.UnitTests.Messaging.Mediator.Behaviors.Fakes; @@ -14,7 +13,7 @@ public sealed class ConcurrencyExceptionBehaviorTests [InlineData(true)] public async Task HandleExceptionAsync_WhenResultIsMissingOrSuccessful_ReturnsConflict(bool hasResult) { - IExceptionRequestBehavior behavior = new ConcurrencyExceptionBehavior(); + var behavior = new ConcurrencyExceptionBehavior(); var exception = new ConcurrencyException("Private provider diagnostics."); var originalResult = hasResult ? Result.Success() : null; @@ -35,7 +34,7 @@ public async Task HandleExceptionAsync_WhenResultIsMissingOrSuccessful_ReturnsCo [InlineData(true)] public async Task HandleExceptionAsync_WhenRequestReturnsGenericResult_ReturnsConflict(bool hasResult) { - IExceptionRequestBehavior> behavior = new ConcurrencyExceptionBehavior>(); + var behavior = new ConcurrencyExceptionBehavior>(); var originalResult = hasResult ? Result.Success("saved value") : null; var result = await behavior.HandleExceptionAsync( @@ -76,10 +75,25 @@ public async Task HandleExceptionAsync_WhenGenericResultAlreadyFailed_DeclinesHa } [Theory(DisplayName = "ConcurrencyExceptionBehavior declines unrelated, wrapped, aggregate and cancellation failures")] - [MemberData(nameof(UnhandledExceptions))] - public async Task HandleExceptionAsync_WhenExceptionIsNotDirectConcurrency_DeclinesHandling(Exception exception) + [InlineData("unexpected")] + [InlineData("canceled")] + [InlineData("task-canceled")] + [InlineData("wrapped")] + [InlineData("aggregate")] + [InlineData("mixed-aggregate")] + public async Task HandleExceptionAsync_WhenExceptionIsNotDirectConcurrency_DeclinesHandling(string scenario) { var behavior = new ConcurrencyExceptionBehavior(); + Exception exception = scenario switch + { + "unexpected" => new InvalidOperationException("Unexpected failure."), + "canceled" => new OperationCanceledException(), + "task-canceled" => new TaskCanceledException(), + "wrapped" => new InvalidOperationException("Wrapper failure.", new ConcurrencyException()), + "aggregate" => new AggregateException(new ConcurrencyException()), + "mixed-aggregate" => new AggregateException(new ConcurrencyException(), new InvalidOperationException("Rollback failed.")), + _ => throw new ArgumentOutOfRangeException(nameof(scenario), scenario, "Unknown exception scenario.") + }; var result = await behavior.HandleExceptionAsync( new TestCommand(), null, exception, TestContext.Current.CancellationToken); @@ -87,16 +101,6 @@ public async Task HandleExceptionAsync_WhenExceptionIsNotDirectConcurrency_Decli result.ShouldBeNull(); } - public static TheoryData UnhandledExceptions => - [ - new InvalidOperationException("Unexpected failure."), - new OperationCanceledException(), - new TaskCanceledException(), - new InvalidOperationException("Wrapper failure.", new ConcurrencyException()), - new AggregateException(new ConcurrencyException()), - new AggregateException(new ConcurrencyException(), new InvalidOperationException("Rollback failed.")) - ]; - [Fact(DisplayName = "ConcurrencyExceptionBehavior rejects a null request")] public async Task HandleExceptionAsync_WhenRequestIsNull_ThrowsArgumentNullException() { From bdc803ac33d0923f238a0b19ae8f32e804c3f220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sat, 17 Oct 2026 11:11:48 +0400 Subject: [PATCH 3/8] refactor: move concurrency exception into errors namespace --- README.md | 2 +- .../Mediator/Behaviors/ConcurrencyExceptionBehavior.cs | 2 +- .../Persistence/{ => Errors}/ConcurrencyException.cs | 2 +- .../Mediator/Behaviors/ConcurrencyExceptionBehaviorTests.cs | 2 +- .../Persistence/{ => Errors}/ConcurrencyExceptionTests.cs | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) rename src/PANiXiDA.Core.Application/Persistence/{ => Errors}/ConcurrencyException.cs (96%) rename tests/PANiXiDA.Core.Application.UnitTests/Persistence/{ => Errors}/ConcurrencyExceptionTests.cs (96%) diff --git a/README.md b/README.md index 3e1858d..dd55b05 100644 --- a/README.md +++ b/README.md @@ -302,7 +302,7 @@ When a before behavior returns a failed `Result`, the mediator should stop the p ### Optimistic Concurrency -`ConcurrencyException` in `PANiXiDA.Core.Application.Persistence` is the provider-independent signal that a persistence operation could not match the expected entity version. It also covers deletion after the entity was read. Adapters can supply a diagnostic message and preserve the original provider exception as `InnerException` with `new ConcurrencyException(message, providerException)`. +`ConcurrencyException` in `PANiXiDA.Core.Application.Persistence.Errors` is the provider-independent signal that a persistence operation could not match the expected entity version. It also covers deletion after the entity was read. Adapters can supply a diagnostic message and preserve the original provider exception as `InnerException` with `new ConcurrencyException(message, providerException)`. An adapter that detects the conflict from an affected-row or matched-document count can use `new ConcurrencyException()` without an inner exception. Connection failures, timeouts, and arbitrary database errors must not be classified as optimistic concurrency conflicts. diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehavior.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehavior.cs index 888e837..0918963 100644 --- a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehavior.cs +++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehavior.cs @@ -1,6 +1,6 @@ using PANiXiDA.Core.Application.Messaging.Mediator.Behaviors.Abstractions; using PANiXiDA.Core.Application.Messaging.Mediator.Contracts; -using PANiXiDA.Core.Application.Persistence; +using PANiXiDA.Core.Application.Persistence.Errors; namespace PANiXiDA.Core.Application.Messaging.Mediator.Behaviors; diff --git a/src/PANiXiDA.Core.Application/Persistence/ConcurrencyException.cs b/src/PANiXiDA.Core.Application/Persistence/Errors/ConcurrencyException.cs similarity index 96% rename from src/PANiXiDA.Core.Application/Persistence/ConcurrencyException.cs rename to src/PANiXiDA.Core.Application/Persistence/Errors/ConcurrencyException.cs index fbd98ea..b1fb58e 100644 --- a/src/PANiXiDA.Core.Application/Persistence/ConcurrencyException.cs +++ b/src/PANiXiDA.Core.Application/Persistence/Errors/ConcurrencyException.cs @@ -1,4 +1,4 @@ -namespace PANiXiDA.Core.Application.Persistence; +namespace PANiXiDA.Core.Application.Persistence.Errors; /// /// Indicates that a persistence operation could not match the expected entity version. diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehaviorTests.cs b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehaviorTests.cs index a819959..8c3350e 100644 --- a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehaviorTests.cs +++ b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehaviorTests.cs @@ -1,6 +1,6 @@ using PANiXiDA.Core.Application.Messaging.Mediator.Behaviors; using PANiXiDA.Core.Application.Messaging.Mediator.Contracts; -using PANiXiDA.Core.Application.Persistence; +using PANiXiDA.Core.Application.Persistence.Errors; using PANiXiDA.Core.Application.UnitTests.Messaging.Mediator.Behaviors.Fakes; using PANiXiDA.Core.ResultPattern; diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Persistence/ConcurrencyExceptionTests.cs b/tests/PANiXiDA.Core.Application.UnitTests/Persistence/Errors/ConcurrencyExceptionTests.cs similarity index 96% rename from tests/PANiXiDA.Core.Application.UnitTests/Persistence/ConcurrencyExceptionTests.cs rename to tests/PANiXiDA.Core.Application.UnitTests/Persistence/Errors/ConcurrencyExceptionTests.cs index df1fdc7..b3d7d98 100644 --- a/tests/PANiXiDA.Core.Application.UnitTests/Persistence/ConcurrencyExceptionTests.cs +++ b/tests/PANiXiDA.Core.Application.UnitTests/Persistence/Errors/ConcurrencyExceptionTests.cs @@ -1,6 +1,6 @@ -using PANiXiDA.Core.Application.Persistence; +using PANiXiDA.Core.Application.Persistence.Errors; -namespace PANiXiDA.Core.Application.UnitTests.Persistence; +namespace PANiXiDA.Core.Application.UnitTests.Persistence.Errors; public sealed class ConcurrencyExceptionTests { From 3f9f6cea542a9bdffef6eb7468e5b78376e77aac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sat, 17 Oct 2026 21:58:29 +0400 Subject: [PATCH 4/8] feat!: propagate results through finally behaviors --- README.md | 32 ++-- .../Abstractions/IExceptionRequestBehavior.cs | 35 ----- .../Abstractions/IFinallyRequestBehavior.cs | 19 ++- .../Behaviors/CleanupTransactionBehavior.cs | 30 ++-- .../Behaviors/ConcurrencyExceptionBehavior.cs | 19 ++- .../CleanupTransactionBehaviorTests.cs | 34 +++++ .../ConcurrencyExceptionBehaviorTests.cs | 142 ++++++++++++++---- .../Behaviors/RequestBehaviorsTests.cs | 21 ++- version.json | 2 +- 9 files changed, 214 insertions(+), 120 deletions(-) delete mode 100644 src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IExceptionRequestBehavior.cs diff --git a/README.md b/README.md index dd55b05..a858685 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ It defines contracts and small reusable building blocks for commands, queries, h ```xml - + ``` @@ -277,10 +277,10 @@ The package includes reusable mediator behavior implementations for authorizatio - `BeginTransactionBehavior` starts a transaction before a command handler runs. - `PublishDomainEventsBehavior` publishes domain events collected from tracked aggregate roots after a successful command result and clears tracked events after a failed result or completed successful publication. - `CommitTransactionBehavior` commits the active transaction after a successful command result. -- `CleanupTransactionBehavior` rolls back failed command transactions and disposes transaction resources. -- `ConcurrencyExceptionBehavior` offers a conflict failure for a direct `ConcurrencyException` after successful cleanup, unless the request already produced a failed result. +- `CleanupTransactionBehavior` rolls back failed command transactions, disposes transaction resources, and returns the supplied result unchanged. +- `ConcurrencyExceptionBehavior` is a finally behavior that replaces a null or successful result with a conflict failure for a direct `ConcurrencyException`. Otherwise, it returns the supplied result unchanged. -Transaction cleanup always attempts disposal, even if rollback fails. A single failure retains its original exception instance and stack trace. When cleanup adds failures to the command exception, or both rollback and disposal fail, the behavior throws an `AggregateException` containing the failures in execution order, with the command exception first. Existing aggregates are preserved; use `Flatten()` to inspect nested failures. If cleanup succeeds, the mediator remains responsible for propagating the original command exception unless an explicitly configured exception behavior handles it. A single cancellation remains cancellation; cancellation combined with another failure is reported as an aggregate failure. +Transaction cleanup always attempts disposal, even if rollback fails. A single failure retains its original exception instance and stack trace. When cleanup adds failures to the command exception, or both rollback and disposal fail, the behavior throws an `AggregateException` containing the failures in execution order, with the command exception first. Existing aggregates are preserved; use `Flatten()` to inspect nested failures. If cleanup succeeds, the mediator remains responsible for propagating the original command exception unless a configured finally behavior handles it by returning a new failure result. A single cancellation remains cancellation; cancellation combined with another failure is reported as an aggregate failure. Domain event publication and transaction behaviors require `ICommand` and do not apply to `IQuery` or plain `IRequest` implementations. Query pipelines retain request validation and exclude these command-only behaviors. Directly closing `PublishDomainEventsBehavior` for a query or plain request is no longer supported. @@ -294,6 +294,7 @@ handler: ICommandHandler after: PublishDomainEventsBehavior after: CommitTransactionBehavior finally: CleanupTransactionBehavior +finally: ConcurrencyExceptionBehavior ``` The exact registration mechanism depends on the mediator or composition root used by the consuming application. @@ -306,18 +307,20 @@ When a before behavior returns a failed `Result`, the mediator should stop the p An adapter that detects the conflict from an affected-row or matched-document count can use `new ConcurrencyException()` without an inner exception. Connection failures, timeouts, and arbitrary database errors must not be classified as optimistic concurrency conflicts. -`IExceptionRequestBehavior.HandleExceptionAsync` receives the request, its last result (which may be null), and the non-null processing exception. It returns a non-generic failed `Result` to handle the exception, or null to decline. `ConcurrencyExceptionBehavior` returns `Error.Conflict` with a stable, client-safe message; diagnostic exception messages and provider details are not included. It declines existing failed results, unrelated exceptions, cancellation, and aggregates without inspecting their inner exceptions. +`IFinallyRequestBehavior.FinallyAsync` returns `Task`. Every behavior receives the current result and returns it unchanged or supplies a replacement for the next behavior. Null means that no result exists; it is not a signal to discard an existing result. Cleanup returns the supplied object, including a successful `Result` and its value. -Mediator adapters that support this stage must: +`ConcurrencyExceptionBehavior` returns a new failure containing `Error.Conflict` with a stable, client-safe message for a direct `ConcurrencyException` when the current result is null or successful. Diagnostic exception messages and provider details are not included. It passes the result through unchanged when there is no exception, when the result has already failed, or when the exception is unrelated, canceled, wrapped, or aggregated. -1. Complete every finally behavior first, preserving the original result and processing exception for cleanup. -2. Propagate any cleanup failure without running exception behaviors, even when the cleanup failure itself is a `ConcurrencyException`. -3. Preserve the current result when no processing exception exists. If a failed result and a processing exception both exist, propagate the exception without replacing that failed result. -4. For an unhandled processing exception with a null or successful result, run exception behaviors in registration order until one returns a non-null result. A returned success is a contract violation and must be rejected; the original processing exception must remain available in the reported failure. -5. Return the offered failure directly for `Result`, or construct `Result.Failure(failure.Errors)` for `Result`. A failed `Result` cannot be cast to `Result`. -6. When all exception behaviors decline, propagate the original exception with its original instance and stack trace. +Mediator adapters must implement the following finally semantics: -The normal before, after, and finally contracts remain unchanged. This package provides the exception-stage contract and concurrency behavior only: upgrading Application does not enable the stage in an existing mediator, translate provider exceptions, or automatically produce HTTP 409. The Wolverine adapter needs separate registration and code-generation support. Background message delivery needs an explicit policy because handling an exception as a result changes retry behavior. +1. Run all finally behaviors in registration order, passing the current result to each one. Keep the original processing exception available to every behavior, even after another behavior returns a conflict. +2. Preserve returned instances that already match `TResult`. For a non-generic failure returned to a `Result` request, construct `Result.Failure(failure.Errors)` before calling the next behavior. Do not cast an untyped failure to `Result`. +3. Treat a new failure returned for a processing exception as a proposal to handle that exception. Passing through the same result, including an existing failed result, does not handle an exception. A successful result never handles an exception. +4. Preserve existing failure results. Replacing a non-null result with null or returning a successful result incompatible with `TResult` is a contract violation. +5. Collect finally failures separately and continue running the remaining behaviors. If any finally behavior fails, propagate the failures together with the original processing exception, regardless of result replacements. This also applies when a cleanup failure itself is a `ConcurrencyException`. Preserve a single failure unchanged and aggregate multiple failures without duplicating the original exception. +6. Accept the proposed exception handling only after all finally behaviors succeed. Otherwise, propagate an unhandled processing exception with its original instance and stack trace. + +Version 6.0 changes the public finally contract from `Task` to `Task`. Existing cleanup implementations must return the supplied result; the before and after contracts are unchanged. There is no separate exception-behavior interface. The Wolverine adapter requires corresponding code-generation changes and finally registration for the concurrency behavior. This Application package alone does not translate provider exceptions or automatically produce HTTP 409. Background message delivery needs an explicit policy because handling an exception as a result changes retry behavior. ### Handler Authorization @@ -496,8 +499,7 @@ The aggregate repository contract is intentionally not defined by this package; - `IReadModel` identifies query and read repository result payloads. - `IBeforeRequestBehavior` runs before a handler and returns `Result.Success()` to continue request processing, or a failed `Result` to stop it. - `IAfterRequestBehavior` runs after a handler returns a result and is defined in the mediator behavior abstractions namespace. -- `IFinallyRequestBehavior` runs after request processing completes or fails and is defined in the mediator behavior abstractions namespace. -- `IExceptionRequestBehavior` offers a failure result for an expected processing exception after successful cleanup; null leaves the exception unhandled. +- `IFinallyRequestBehavior` runs after request processing completes or fails and returns the current result or a replacement for the next finally behavior. - `ConcurrencyExceptionBehavior` maps direct optimistic concurrency exceptions to `Error.Conflict` without replacing existing failure results. ### Validation diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IExceptionRequestBehavior.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IExceptionRequestBehavior.cs deleted file mode 100644 index 906ef37..0000000 --- a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IExceptionRequestBehavior.cs +++ /dev/null @@ -1,35 +0,0 @@ -using PANiXiDA.Core.Application.Messaging.Mediator.Contracts; - -namespace PANiXiDA.Core.Application.Messaging.Mediator.Behaviors.Abstractions; - -/// -/// Offers a failure result for an exception after request cleanup has completed successfully. -/// -/// The request type processed by the behavior. -/// The result type returned by the request. -/// -/// A consuming mediator invokes this stage only for a processing exception after all finally behaviors succeed. -/// Cleanup failures must propagate without invoking this stage. A failed request result must not be replaced. -/// Behaviors run in registration order until one returns a non-null failure result. The mediator converts its errors -/// to the declared request result type, preserving support for both Result and Result<T>. -/// A successful returned result is a contract violation. When all behaviors decline, the original exception -/// must propagate with its original instance and stack trace. Background delivery requires an explicit handling policy. -/// -public interface IExceptionRequestBehavior - where TRequest : IRequest - where TResult : Result -{ - /// - /// Attempts to convert an expected processing exception to a failure result. - /// - /// The request that was processed. - /// The last request result, or null when no result was produced. - /// The non-null processing exception. - /// The cancellation token for the operation. - /// A failure result when the exception is handled, or null to leave it unhandled. - Task HandleExceptionAsync( - TRequest request, - TResult? result, - Exception exception, - CancellationToken cancellationToken); -} diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IFinallyRequestBehavior.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IFinallyRequestBehavior.cs index 82e4a85..af27c91 100644 --- a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IFinallyRequestBehavior.cs +++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IFinallyRequestBehavior.cs @@ -8,9 +8,16 @@ namespace PANiXiDA.Core.Application.Messaging.Mediator.Behaviors.Abstractions; /// The request type processed by the behavior. /// The result type returned by the request. /// -/// Finally behaviors perform cleanup without replacing the request result or handling the processing exception. -/// A consuming mediator completes all finally behaviors before invoking any exception behavior. -/// Cleanup failures must propagate without being converted to failure results by exception behaviors. +/// Behaviors run in registration order, passing each returned result to the next behavior. +/// Return the supplied result unchanged when no replacement is needed; null means no result exists. +/// The mediator converts a non-generic failure to the declared result type before calling the next behavior. +/// Returning a new failure for a processing exception proposes handling that exception. Passing through an +/// existing result does not handle it. Existing failure results must be preserved, and a non-null result +/// must not be replaced with null. Successful replacements must be assignable to the declared result type. +/// The original processing exception remains available to all behaviors. The mediator must run every behavior, +/// collect cleanup failures separately, and accept exception handling only if all finally behaviors succeed. +/// Cleanup failures must propagate together with the original exception, even if a result was replaced. +/// Unhandled exceptions retain their original instance and stack trace. Background delivery requires an explicit policy. /// public interface IFinallyRequestBehavior where TRequest : IRequest @@ -20,11 +27,11 @@ public interface IFinallyRequestBehavior /// Executes behavior after request processing completes, including failure paths. /// /// The request that was processed. - /// The result returned by the request handler, if any. + /// The current result returned by the handler or a preceding finally behavior, if any. /// The exception thrown during request processing, if any. /// The token used to cancel the operation. - /// A task that represents the asynchronous operation. - Task FinallyAsync( + /// The supplied result or its replacement, or null when no result exists. + Task FinallyAsync( TRequest request, TResult? result, Exception? exception, diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/CleanupTransactionBehavior.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/CleanupTransactionBehavior.cs index 50a31e3..d84cb86 100644 --- a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/CleanupTransactionBehavior.cs +++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/CleanupTransactionBehavior.cs @@ -21,16 +21,16 @@ public sealed class CleanupTransactionBehavior(IUnitOfWork un /// Rolls back the active transaction on failure and releases transaction resources. /// /// The command that was processed. - /// The command execution result, if any. + /// The current command result, if any. /// The exception thrown during command processing, if any. /// The token used to cancel the operation. - /// A task that represents the asynchronous operation. + /// The supplied result unchanged after successful cleanup. /// /// Disposal is attempted even when rollback fails. A single cleanup failure is rethrown unchanged. /// Multiple failures, including the supplied exception, are reported in an . /// If cleanup succeeds, propagating the supplied exception remains the caller's responsibility. /// - public async Task FinallyAsync( + public async Task FinallyAsync( TCommand request, TResult? result, Exception? exception, @@ -38,7 +38,7 @@ public async Task FinallyAsync( { if (!unitOfWork.HasActiveTransaction) { - return; + return result; } List? cleanupExceptions = null; @@ -64,21 +64,21 @@ public async Task FinallyAsync( (cleanupExceptions ??= []).Add(disposeException); } - if (cleanupExceptions is null) + if (cleanupExceptions is not null) { - return; - } + if (exception is not null) + { + cleanupExceptions.Insert(0, exception); + } - if (exception is not null) - { - cleanupExceptions.Insert(0, exception); - } + if (cleanupExceptions.Count > 1) + { + throw new AggregateException("Multiple errors occurred while cleaning up the transaction.", cleanupExceptions); + } - if (cleanupExceptions.Count > 1) - { - throw new AggregateException("Multiple errors occurred while cleaning up the transaction.", cleanupExceptions); + ExceptionDispatchInfo.Throw(cleanupExceptions[0]); } - ExceptionDispatchInfo.Throw(cleanupExceptions[0]); + return result; } } diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehavior.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehavior.cs index 0918963..b966146 100644 --- a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehavior.cs +++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehavior.cs @@ -12,9 +12,9 @@ namespace PANiXiDA.Core.Application.Messaging.Mediator.Behaviors; /// /// Existing failure results are preserved. Other exception types, including aggregates and cancellation, /// are not handled. Diagnostic exception messages are not exposed in the returned error. -/// The mediator must invoke this behavior only after all request cleanup succeeds. +/// The mediator must accept exception handling only after all finally behaviors succeed. /// -public sealed class ConcurrencyExceptionBehavior : IExceptionRequestBehavior +public sealed class ConcurrencyExceptionBehavior : IFinallyRequestBehavior where TRequest : IRequest where TResult : Result { @@ -22,23 +22,22 @@ public sealed class ConcurrencyExceptionBehavior : IException /// Offers a conflict result for a concurrency exception when there is no existing failure result. /// /// The request that was processed. - /// The last request result, or null when no result was produced. - /// The processing exception to inspect. + /// The current request result, or null when no result was produced. + /// The processing exception to inspect, if any. /// The cancellation token for the operation. - /// A conflict failure result, or null when the exception should propagate. - /// The request or exception is null. - public Task HandleExceptionAsync( + /// A conflict failure result for a handled exception, or the supplied result unchanged. + /// The request is null. + public Task FinallyAsync( TRequest request, TResult? result, - Exception exception, + Exception? exception, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(exception); if (result is { IsFailure: true } || exception is not ConcurrencyException) { - return Task.FromResult(null); + return Task.FromResult(result); } return Task.FromResult(Result.Failure( diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/CleanupTransactionBehaviorTests.cs b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/CleanupTransactionBehaviorTests.cs index ed7e0aa..697404d 100644 --- a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/CleanupTransactionBehaviorTests.cs +++ b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/CleanupTransactionBehaviorTests.cs @@ -1,4 +1,5 @@ using PANiXiDA.Core.Application.Messaging.Mediator.Behaviors; +using PANiXiDA.Core.Application.Messaging.Mediator.Contracts; using PANiXiDA.Core.Application.UnitTests.Messaging.Mediator.Behaviors.Fakes; using PANiXiDA.Core.ResultPattern; @@ -6,6 +7,37 @@ namespace PANiXiDA.Core.Application.UnitTests.Messaging.Mediator.Behaviors; public sealed class CleanupTransactionBehaviorTests { + [Theory(DisplayName = "Cleanup preserves generic results with and without an active transaction")] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task FinallyAsync_WhenGenericResultExists_ReturnsOriginalResult(bool hasActiveTransaction, bool isFailure) + { + var unitOfWork = new TestUnitOfWork { HasActiveTransaction = hasActiveTransaction }; + var behavior = new CleanupTransactionBehavior>(unitOfWork); + var originalResult = isFailure ? Result.Failure(Error.Validation("Invalid value.")) : Result.Success("saved value"); + + var result = await behavior.FinallyAsync(new ValueCommand(), originalResult, null, TestContext.Current.CancellationToken); + + result.ShouldBeSameAs(originalResult); + unitOfWork.RollbackTransactionCalls.ShouldBe(hasActiveTransaction && isFailure ? 1 : 0); + unitOfWork.DisposeTransactionCalls.ShouldBe(hasActiveTransaction ? 1 : 0); + } + + [Fact(DisplayName = "Cleanup preserves a missing result when there is no active transaction")] + public async Task FinallyAsync_WhenResultAndTransactionAreMissing_ReturnsNull() + { + var unitOfWork = new TestUnitOfWork(); + var behavior = new CleanupTransactionBehavior(unitOfWork); + + var result = await behavior.FinallyAsync(new TestCommand(), null, null, TestContext.Current.CancellationToken); + + result.ShouldBeNull(); + unitOfWork.RollbackTransactionCalls.ShouldBe(0); + unitOfWork.DisposeTransactionCalls.ShouldBe(0); + } + [Theory(DisplayName = "Cleanup preserves single failures and aggregates multiple failures")] [InlineData(false, false, false)] [InlineData(true, false, false)] @@ -192,4 +224,6 @@ public async Task FinallyAsync_WhenSuccessfulCommandDisposalFails_PreservesFailu unitOfWork.RollbackTransactionCalls.ShouldBe(0); unitOfWork.DisposeTransactionCalls.ShouldBe(1); } + + private sealed record ValueCommand : ICommand>; } diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehaviorTests.cs b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehaviorTests.cs index 8c3350e..2cbec72 100644 --- a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehaviorTests.cs +++ b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehaviorTests.cs @@ -11,13 +11,13 @@ public sealed class ConcurrencyExceptionBehaviorTests [Theory(DisplayName = "ConcurrencyExceptionBehavior offers a conflict for requests returning Result")] [InlineData(false)] [InlineData(true)] - public async Task HandleExceptionAsync_WhenResultIsMissingOrSuccessful_ReturnsConflict(bool hasResult) + public async Task FinallyAsync_WhenResultIsMissingOrSuccessful_ReturnsConflict(bool hasResult) { var behavior = new ConcurrencyExceptionBehavior(); var exception = new ConcurrencyException("Private provider diagnostics."); var originalResult = hasResult ? Result.Success() : null; - var result = await behavior.HandleExceptionAsync( + var result = await behavior.FinallyAsync( new TestCommand(), originalResult, exception, TestContext.Current.CancellationToken); result.ShouldNotBeNull(); @@ -32,12 +32,12 @@ public async Task HandleExceptionAsync_WhenResultIsMissingOrSuccessful_ReturnsCo [Theory(DisplayName = "ConcurrencyExceptionBehavior supports requests returning Result of T")] [InlineData(false)] [InlineData(true)] - public async Task HandleExceptionAsync_WhenRequestReturnsGenericResult_ReturnsConflict(bool hasResult) + public async Task FinallyAsync_WhenRequestReturnsGenericResult_ReturnsConflict(bool hasResult) { var behavior = new ConcurrencyExceptionBehavior>(); var originalResult = hasResult ? Result.Success("saved value") : null; - var result = await behavior.HandleExceptionAsync( + var result = await behavior.FinallyAsync( new ValueCommand(), originalResult, new ConcurrencyException(), TestContext.Current.CancellationToken); result.ShouldNotBeNull(); @@ -47,43 +47,50 @@ public async Task HandleExceptionAsync_WhenRequestReturnsGenericResult_ReturnsCo } [Fact(DisplayName = "ConcurrencyExceptionBehavior leaves existing failure results unchanged")] - public async Task HandleExceptionAsync_WhenResultAlreadyFailed_DeclinesHandling() + public async Task FinallyAsync_WhenResultAlreadyFailed_ReturnsOriginalResult() { var behavior = new ConcurrencyExceptionBehavior(); var error = Error.Validation("The order name is required."); var originalResult = Result.Failure(error); - var result = await behavior.HandleExceptionAsync( + var result = await behavior.FinallyAsync( new TestCommand(), originalResult, new ConcurrencyException(), TestContext.Current.CancellationToken); - result.ShouldBeNull(); + result.ShouldBeSameAs(originalResult); originalResult.Errors.ShouldHaveSingleItem().ShouldBeSameAs(error); } [Fact(DisplayName = "ConcurrencyExceptionBehavior leaves generic failure results unchanged")] - public async Task HandleExceptionAsync_WhenGenericResultAlreadyFailed_DeclinesHandling() + public async Task FinallyAsync_WhenGenericResultAlreadyFailed_ReturnsOriginalResult() { var behavior = new ConcurrencyExceptionBehavior>(); var error = Error.NotFound("The order was not found."); var originalResult = Result.Failure(error); - var result = await behavior.HandleExceptionAsync( + var result = await behavior.FinallyAsync( new ValueCommand(), originalResult, new ConcurrencyException(), TestContext.Current.CancellationToken); - result.ShouldBeNull(); + result.ShouldBeSameAs(originalResult); originalResult.Errors.ShouldHaveSingleItem().ShouldBeSameAs(error); } [Theory(DisplayName = "ConcurrencyExceptionBehavior declines unrelated, wrapped, aggregate and cancellation failures")] - [InlineData("unexpected")] - [InlineData("canceled")] - [InlineData("task-canceled")] - [InlineData("wrapped")] - [InlineData("aggregate")] - [InlineData("mixed-aggregate")] - public async Task HandleExceptionAsync_WhenExceptionIsNotDirectConcurrency_DeclinesHandling(string scenario) + [InlineData("unexpected", false)] + [InlineData("unexpected", true)] + [InlineData("canceled", false)] + [InlineData("canceled", true)] + [InlineData("task-canceled", false)] + [InlineData("task-canceled", true)] + [InlineData("wrapped", false)] + [InlineData("wrapped", true)] + [InlineData("aggregate", false)] + [InlineData("aggregate", true)] + [InlineData("mixed-aggregate", false)] + [InlineData("mixed-aggregate", true)] + public async Task FinallyAsync_WhenExceptionIsNotDirectConcurrency_ReturnsOriginalResult(string scenario, bool hasResult) { var behavior = new ConcurrencyExceptionBehavior(); + var originalResult = hasResult ? Result.Success() : null; Exception exception = scenario switch { "unexpected" => new InvalidOperationException("Unexpected failure."), @@ -95,36 +102,59 @@ public async Task HandleExceptionAsync_WhenExceptionIsNotDirectConcurrency_Decli _ => throw new ArgumentOutOfRangeException(nameof(scenario), scenario, "Unknown exception scenario.") }; - var result = await behavior.HandleExceptionAsync( - new TestCommand(), null, exception, TestContext.Current.CancellationToken); + var result = await behavior.FinallyAsync( + new TestCommand(), originalResult, exception, TestContext.Current.CancellationToken); - result.ShouldBeNull(); + result.ShouldBeSameAs(originalResult); } [Fact(DisplayName = "ConcurrencyExceptionBehavior rejects a null request")] - public async Task HandleExceptionAsync_WhenRequestIsNull_ThrowsArgumentNullException() + public async Task FinallyAsync_WhenRequestIsNull_ThrowsArgumentNullException() { var behavior = new ConcurrencyExceptionBehavior(); - var exception = await Should.ThrowAsync(() => behavior.HandleExceptionAsync( + var exception = await Should.ThrowAsync(() => behavior.FinallyAsync( null!, null, new ConcurrencyException(), TestContext.Current.CancellationToken)); exception.ParamName.ShouldBe("request"); } - [Fact(DisplayName = "ConcurrencyExceptionBehavior rejects a null exception")] - public async Task HandleExceptionAsync_WhenExceptionIsNull_ThrowsArgumentNullException() + [Theory(DisplayName = "ConcurrencyExceptionBehavior passes through results when no exception occurred")] + [InlineData("missing")] + [InlineData("success")] + [InlineData("failure")] + public async Task FinallyAsync_WhenExceptionIsNull_ReturnsOriginalResult(string scenario) { var behavior = new ConcurrencyExceptionBehavior(); + var originalResult = scenario switch + { + "missing" => null, + "success" => Result.Success(), + "failure" => Result.Failure(Error.Validation("The order name is required.")), + _ => throw new ArgumentOutOfRangeException(nameof(scenario), scenario, "Unknown result scenario.") + }; + + var result = await behavior.FinallyAsync( + new TestCommand(), originalResult, null, TestContext.Current.CancellationToken); + + result.ShouldBeSameAs(originalResult); + } + + [Fact(DisplayName = "ConcurrencyExceptionBehavior preserves a successful generic result and its value")] + public async Task FinallyAsync_WhenGenericRequestSucceeds_ReturnsOriginalResult() + { + var behavior = new ConcurrencyExceptionBehavior>(); + var originalResult = Result.Success("saved value"); - var exception = await Should.ThrowAsync(() => behavior.HandleExceptionAsync( - new TestCommand(), null, null!, TestContext.Current.CancellationToken)); + var result = await behavior.FinallyAsync( + new ValueCommand(), originalResult, null, TestContext.Current.CancellationToken); - exception.ParamName.ShouldBe("exception"); + result.ShouldBeSameAs(originalResult); + originalResult.Value.ShouldBe("saved value"); } [Fact(DisplayName = "ConcurrencyExceptionBehavior maps a conflict after successful transaction cleanup")] - public async Task HandleExceptionAsync_WhenTransactionCleanupSucceeds_ReturnsConflict() + public async Task FinallyAsync_WhenTransactionCleanupSucceeds_ReturnsConflict() { var unitOfWork = new TestUnitOfWork { HasActiveTransaction = true }; var cleanup = new CleanupTransactionBehavior(unitOfWork); @@ -132,19 +162,67 @@ public async Task HandleExceptionAsync_WhenTransactionCleanupSucceeds_ReturnsCon var request = new TestCommand(); var exception = new ConcurrencyException(); - await cleanup.FinallyAsync(request, null, exception, TestContext.Current.CancellationToken); - var result = await behavior.HandleExceptionAsync(request, null, exception, TestContext.Current.CancellationToken); + var currentResult = await cleanup.FinallyAsync(request, null, exception, TestContext.Current.CancellationToken); + var result = await behavior.FinallyAsync(request, currentResult, exception, TestContext.Current.CancellationToken); unitOfWork.RollbackTransactionCalls.ShouldBe(1); unitOfWork.DisposeTransactionCalls.ShouldBe(1); result.ShouldNotBeNull().FirstError.Type.ShouldBe(ErrorType.Conflict); } + [Fact(DisplayName = "Transaction cleanup passes through a conflict returned by a preceding finally behavior")] + public async Task FinallyAsync_WhenConcurrencyIsHandledBeforeCleanup_PreservesConflict() + { + var unitOfWork = new TestUnitOfWork { HasActiveTransaction = true }; + var cleanup = new CleanupTransactionBehavior(unitOfWork); + var behavior = new ConcurrencyExceptionBehavior(); + var request = new TestCommand(); + var exception = new ConcurrencyException(); + + var conflict = await behavior.FinallyAsync(request, null, exception, TestContext.Current.CancellationToken); + var result = await cleanup.FinallyAsync(request, conflict, exception, TestContext.Current.CancellationToken); + + result.ShouldBeSameAs(conflict); + result.ShouldNotBeNull().FirstError.Type.ShouldBe(ErrorType.Conflict); + unitOfWork.RollbackTransactionCalls.ShouldBe(1); + unitOfWork.DisposeTransactionCalls.ShouldBe(1); + } + + [Theory(DisplayName = "A conflict result does not hide later transaction cleanup failures")] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task FinallyAsync_WhenCleanupFailsAfterConcurrencyHandling_PreservesExceptions(bool failRollback, bool failDispose) + { + var unitOfWork = new TestUnitOfWork + { + HasActiveTransaction = true, + RollbackException = failRollback ? new InvalidOperationException("Rollback failed.") : null, + DisposeException = failDispose ? new InvalidOperationException("Dispose failed.") : null + }; + var cleanup = new CleanupTransactionBehavior(unitOfWork); + var behavior = new ConcurrencyExceptionBehavior(); + var request = new TestCommand(); + var originalException = new ConcurrencyException(); + + var conflict = await behavior.FinallyAsync(request, null, originalException, TestContext.Current.CancellationToken); + var exception = await Should.ThrowAsync(() => cleanup.FinallyAsync( + request, conflict, originalException, TestContext.Current.CancellationToken)); + + unitOfWork.DisposeTransactionCalls.ShouldBe(1); + exception.InnerExceptions.ShouldBe(new[] + { + originalException, + unitOfWork.RollbackException, + unitOfWork.DisposeException + }.OfType()); + } + [Theory(DisplayName = "ConcurrencyExceptionBehavior does not hide transaction cleanup failures")] [InlineData(true, false)] [InlineData(false, true)] [InlineData(true, true)] - public async Task HandleExceptionAsync_WhenCleanupAggregatesFailures_DeclinesHandling(bool failRollback, bool failDispose) + public async Task FinallyAsync_WhenCleanupAggregatesFailures_DeclinesHandling(bool failRollback, bool failDispose) { var unitOfWork = new TestUnitOfWork { @@ -159,7 +237,7 @@ public async Task HandleExceptionAsync_WhenCleanupAggregatesFailures_DeclinesHan var exception = await Should.ThrowAsync(() => cleanup.FinallyAsync( request, null, originalException, TestContext.Current.CancellationToken)); - var result = await behavior.HandleExceptionAsync(request, null, exception, TestContext.Current.CancellationToken); + var result = await behavior.FinallyAsync(request, null, exception, TestContext.Current.CancellationToken); result.ShouldBeNull(); unitOfWork.DisposeTransactionCalls.ShouldBe(1); diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/RequestBehaviorsTests.cs b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/RequestBehaviorsTests.cs index f76b47f..e8eac34 100644 --- a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/RequestBehaviorsTests.cs +++ b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/RequestBehaviorsTests.cs @@ -145,9 +145,11 @@ public async Task FinallyAsync_WhenTransactionIsNotActive_DoesNotRollbackOrDispo { var unitOfWork = new TestUnitOfWork(); var behavior = new CleanupTransactionBehavior(unitOfWork); + var originalResult = Result.Success(); - await behavior.FinallyAsync(new TestCommand(), Result.Success(), null, CancellationToken.None); + var result = await behavior.FinallyAsync(new TestCommand(), originalResult, null, CancellationToken.None); + result.ShouldBeSameAs(originalResult); unitOfWork.RollbackTransactionCalls.ShouldBe(0); unitOfWork.DisposeTransactionCalls.ShouldBe(0); } @@ -160,9 +162,11 @@ public async Task FinallyAsync_WhenCommandSucceededInActiveTransaction_DisposesT HasActiveTransaction = true }; var behavior = new CleanupTransactionBehavior(unitOfWork); + var originalResult = Result.Success(); - await behavior.FinallyAsync(new TestCommand(), Result.Success(), null, CancellationToken.None); + var result = await behavior.FinallyAsync(new TestCommand(), originalResult, null, CancellationToken.None); + result.ShouldBeSameAs(originalResult); unitOfWork.RollbackTransactionCalls.ShouldBe(0); unitOfWork.DisposeTransactionCalls.ShouldBe(1); } @@ -175,9 +179,11 @@ public async Task FinallyAsync_WhenCommandFailedInActiveTransaction_RollsBackAnd HasActiveTransaction = true }; var behavior = new CleanupTransactionBehavior(unitOfWork); + var originalResult = CreateFailureResult(); - await behavior.FinallyAsync(new TestCommand(), CreateFailureResult(), null, CancellationToken.None); + var result = await behavior.FinallyAsync(new TestCommand(), originalResult, null, CancellationToken.None); + result.ShouldBeSameAs(originalResult); unitOfWork.RollbackTransactionCalls.ShouldBe(1); unitOfWork.DisposeTransactionCalls.ShouldBe(1); } @@ -191,8 +197,9 @@ public async Task FinallyAsync_WhenResultIsNullInActiveTransaction_RollsBackAndD }; var behavior = new CleanupTransactionBehavior(unitOfWork); - await behavior.FinallyAsync(new TestCommand(), null, null, CancellationToken.None); + var result = await behavior.FinallyAsync(new TestCommand(), null, null, CancellationToken.None); + result.ShouldBeNull(); unitOfWork.RollbackTransactionCalls.ShouldBe(1); unitOfWork.DisposeTransactionCalls.ShouldBe(1); } @@ -205,13 +212,15 @@ public async Task FinallyAsync_WhenExceptionIsProvidedInActiveTransaction_RollsB HasActiveTransaction = true }; var behavior = new CleanupTransactionBehavior(unitOfWork); + var originalResult = Result.Success(); - await behavior.FinallyAsync( + var result = await behavior.FinallyAsync( new TestCommand(), - Result.Success(), + originalResult, new InvalidOperationException(), CancellationToken.None); + result.ShouldBeSameAs(originalResult); unitOfWork.RollbackTransactionCalls.ShouldBe(1); unitOfWork.DisposeTransactionCalls.ShouldBe(1); } diff --git a/version.json b/version.json index 572db95..60ef2ad 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "5.1", + "version": "6.0", "publicReleaseRefSpec": [ "^refs/heads/main$", "^refs/heads/master$" From 7de35e3fcfc8629e6067750f324f432f52deb85b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sat, 17 Oct 2026 22:06:59 +0400 Subject: [PATCH 5/8] refactor: separate cleanup from result propagation --- .../Behaviors/CleanupTransactionBehavior.cs | 30 +++++++++++-------- .../CleanupTransactionBehaviorTests.cs | 15 ++++++++++ 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/CleanupTransactionBehavior.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/CleanupTransactionBehavior.cs index d84cb86..6fd2fd1 100644 --- a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/CleanupTransactionBehavior.cs +++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/CleanupTransactionBehavior.cs @@ -35,10 +35,16 @@ public sealed class CleanupTransactionBehavior(IUnitOfWork un TResult? result, Exception? exception, CancellationToken cancellationToken) + { + await CleanupAsync(result, exception, cancellationToken); + return result; + } + + private async Task CleanupAsync(TResult? result, Exception? exception, CancellationToken cancellationToken) { if (!unitOfWork.HasActiveTransaction) { - return result; + return; } List? cleanupExceptions = null; @@ -64,21 +70,21 @@ public sealed class CleanupTransactionBehavior(IUnitOfWork un (cleanupExceptions ??= []).Add(disposeException); } - if (cleanupExceptions is not null) + if (cleanupExceptions is null) { - if (exception is not null) - { - cleanupExceptions.Insert(0, exception); - } + return; + } - if (cleanupExceptions.Count > 1) - { - throw new AggregateException("Multiple errors occurred while cleaning up the transaction.", cleanupExceptions); - } + if (exception is not null) + { + cleanupExceptions.Insert(0, exception); + } - ExceptionDispatchInfo.Throw(cleanupExceptions[0]); + if (cleanupExceptions.Count > 1) + { + throw new AggregateException("Multiple errors occurred while cleaning up the transaction.", cleanupExceptions); } - return result; + ExceptionDispatchInfo.Throw(cleanupExceptions[0]); } } diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/CleanupTransactionBehaviorTests.cs b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/CleanupTransactionBehaviorTests.cs index 697404d..b038f78 100644 --- a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/CleanupTransactionBehaviorTests.cs +++ b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/CleanupTransactionBehaviorTests.cs @@ -38,6 +38,21 @@ public async Task FinallyAsync_WhenResultAndTransactionAreMissing_ReturnsNull() unitOfWork.DisposeTransactionCalls.ShouldBe(0); } + [Theory(DisplayName = "Cleanup preserves a missing generic result and rolls back an active transaction")] + [InlineData(false)] + [InlineData(true)] + public async Task FinallyAsync_WhenGenericResultIsMissing_ReturnsNull(bool hasActiveTransaction) + { + var unitOfWork = new TestUnitOfWork { HasActiveTransaction = hasActiveTransaction }; + var behavior = new CleanupTransactionBehavior>(unitOfWork); + + var result = await behavior.FinallyAsync(new ValueCommand(), null, null, TestContext.Current.CancellationToken); + + result.ShouldBeNull(); + unitOfWork.RollbackTransactionCalls.ShouldBe(hasActiveTransaction ? 1 : 0); + unitOfWork.DisposeTransactionCalls.ShouldBe(hasActiveTransaction ? 1 : 0); + } + [Theory(DisplayName = "Cleanup preserves single failures and aggregates multiple failures")] [InlineData(false, false, false)] [InlineData(true, false, false)] From fa903a67837778530181424888bd11942878d199 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sat, 17 Oct 2026 22:14:28 +0400 Subject: [PATCH 6/8] refactor: remove redundant concurrency request guard --- .../Behaviors/ConcurrencyExceptionBehavior.cs | 3 --- .../Behaviors/ConcurrencyExceptionBehaviorTests.cs | 11 ----------- 2 files changed, 14 deletions(-) diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehavior.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehavior.cs index b966146..694529f 100644 --- a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehavior.cs +++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehavior.cs @@ -26,15 +26,12 @@ public sealed class ConcurrencyExceptionBehavior : IFinallyRe /// The processing exception to inspect, if any. /// The cancellation token for the operation. /// A conflict failure result for a handled exception, or the supplied result unchanged. - /// The request is null. public Task FinallyAsync( TRequest request, TResult? result, Exception? exception, CancellationToken cancellationToken) { - ArgumentNullException.ThrowIfNull(request); - if (result is { IsFailure: true } || exception is not ConcurrencyException) { return Task.FromResult(result); diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehaviorTests.cs b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehaviorTests.cs index 2cbec72..5868299 100644 --- a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehaviorTests.cs +++ b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/ConcurrencyExceptionBehaviorTests.cs @@ -108,17 +108,6 @@ public async Task FinallyAsync_WhenExceptionIsNotDirectConcurrency_ReturnsOrigin result.ShouldBeSameAs(originalResult); } - [Fact(DisplayName = "ConcurrencyExceptionBehavior rejects a null request")] - public async Task FinallyAsync_WhenRequestIsNull_ThrowsArgumentNullException() - { - var behavior = new ConcurrencyExceptionBehavior(); - - var exception = await Should.ThrowAsync(() => behavior.FinallyAsync( - null!, null, new ConcurrencyException(), TestContext.Current.CancellationToken)); - - exception.ParamName.ShouldBe("request"); - } - [Theory(DisplayName = "ConcurrencyExceptionBehavior passes through results when no exception occurred")] [InlineData("missing")] [InlineData("success")] From 1a660ec41f6a7e2e7128b47da98a70acd0a94cff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sat, 17 Oct 2026 22:18:40 +0400 Subject: [PATCH 7/8] refactor: inline transaction cleanup into finally behavior --- .../Behaviors/CleanupTransactionBehavior.cs | 32 ++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/CleanupTransactionBehavior.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/CleanupTransactionBehavior.cs index 6fd2fd1..af81716 100644 --- a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/CleanupTransactionBehavior.cs +++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/CleanupTransactionBehavior.cs @@ -35,16 +35,10 @@ public sealed class CleanupTransactionBehavior(IUnitOfWork un TResult? result, Exception? exception, CancellationToken cancellationToken) - { - await CleanupAsync(result, exception, cancellationToken); - return result; - } - - private async Task CleanupAsync(TResult? result, Exception? exception, CancellationToken cancellationToken) { if (!unitOfWork.HasActiveTransaction) { - return; + return result; } List? cleanupExceptions = null; @@ -70,21 +64,23 @@ private async Task CleanupAsync(TResult? result, Exception? exception, Cancellat (cleanupExceptions ??= []).Add(disposeException); } - if (cleanupExceptions is null) + ExceptionDispatchInfo? cleanupFailure = null; + if (cleanupExceptions is not null) { - return; - } + if (exception is not null) + { + cleanupExceptions.Insert(0, exception); + } - if (exception is not null) - { - cleanupExceptions.Insert(0, exception); - } + if (cleanupExceptions.Count > 1) + { + throw new AggregateException("Multiple errors occurred while cleaning up the transaction.", cleanupExceptions); + } - if (cleanupExceptions.Count > 1) - { - throw new AggregateException("Multiple errors occurred while cleaning up the transaction.", cleanupExceptions); + cleanupFailure = ExceptionDispatchInfo.Capture(cleanupExceptions[0]); } - ExceptionDispatchInfo.Throw(cleanupExceptions[0]); + cleanupFailure?.Throw(); + return result; } } From 4831c5a6d662a4790511a7d9bff39331466f0ce7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Sat, 17 Oct 2026 22:26:49 +0400 Subject: [PATCH 8/8] chore: use minor version and shorten concurrency docs --- README.md | 21 ++++----------------- version.json | 2 +- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index a858685..36bd2c1 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ It defines contracts and small reusable building blocks for commands, queries, h ```xml - + ``` @@ -303,24 +303,11 @@ When a before behavior returns a failed `Result`, the mediator should stop the p ### Optimistic Concurrency -`ConcurrencyException` in `PANiXiDA.Core.Application.Persistence.Errors` is the provider-independent signal that a persistence operation could not match the expected entity version. It also covers deletion after the entity was read. Adapters can supply a diagnostic message and preserve the original provider exception as `InnerException` with `new ConcurrencyException(message, providerException)`. +Persistence adapters report optimistic concurrency conflicts with `ConcurrencyException` from `PANiXiDA.Core.Application.Persistence.Errors`. `ConcurrencyExceptionBehavior` converts direct conflicts to `Error.Conflict`, preserving existing failed results and leaving other exceptions unhandled. -An adapter that detects the conflict from an affected-row or matched-document count can use `new ConcurrencyException()` without an inner exception. Connection failures, timeouts, and arbitrary database errors must not be classified as optimistic concurrency conflicts. +`FinallyAsync` now returns `Task`: behaviors pass the current result through or replace it. Mediator adapters must pass the returned result to the next behavior and accept exception handling only after all cleanup succeeds. Cleanup failures retain the original processing exception. -`IFinallyRequestBehavior.FinallyAsync` returns `Task`. Every behavior receives the current result and returns it unchanged or supplies a replacement for the next behavior. Null means that no result exists; it is not a signal to discard an existing result. Cleanup returns the supplied object, including a successful `Result` and its value. - -`ConcurrencyExceptionBehavior` returns a new failure containing `Error.Conflict` with a stable, client-safe message for a direct `ConcurrencyException` when the current result is null or successful. Diagnostic exception messages and provider details are not included. It passes the result through unchanged when there is no exception, when the result has already failed, or when the exception is unrelated, canceled, wrapped, or aggregated. - -Mediator adapters must implement the following finally semantics: - -1. Run all finally behaviors in registration order, passing the current result to each one. Keep the original processing exception available to every behavior, even after another behavior returns a conflict. -2. Preserve returned instances that already match `TResult`. For a non-generic failure returned to a `Result` request, construct `Result.Failure(failure.Errors)` before calling the next behavior. Do not cast an untyped failure to `Result`. -3. Treat a new failure returned for a processing exception as a proposal to handle that exception. Passing through the same result, including an existing failed result, does not handle an exception. A successful result never handles an exception. -4. Preserve existing failure results. Replacing a non-null result with null or returning a successful result incompatible with `TResult` is a contract violation. -5. Collect finally failures separately and continue running the remaining behaviors. If any finally behavior fails, propagate the failures together with the original processing exception, regardless of result replacements. This also applies when a cleanup failure itself is a `ConcurrencyException`. Preserve a single failure unchanged and aggregate multiple failures without duplicating the original exception. -6. Accept the proposed exception handling only after all finally behaviors succeed. Otherwise, propagate an unhandled processing exception with its original instance and stack trace. - -Version 6.0 changes the public finally contract from `Task` to `Task`. Existing cleanup implementations must return the supplied result; the before and after contracts are unchanged. There is no separate exception-behavior interface. The Wolverine adapter requires corresponding code-generation changes and finally registration for the concurrency behavior. This Application package alone does not translate provider exceptions or automatically produce HTTP 409. Background message delivery needs an explicit policy because handling an exception as a result changes retry behavior. +Existing finally implementations must adopt the new return type. Provider exception translation and Wolverine integration require separate adapter changes. ### Handler Authorization diff --git a/version.json b/version.json index 60ef2ad..572db95 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "6.0", + "version": "5.1", "publicReleaseRefSpec": [ "^refs/heads/main$", "^refs/heads/master$"