Skip to content
25 changes: 19 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,14 @@ It defines contracts and small reusable building blocks for commands, queries, h

- CQRS request contracts: `ICommand<TResult>`, `IQuery<TResult>`, and `IRequest<TResult>`.
- 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<T>` 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.
Expand All @@ -37,7 +38,7 @@ It defines contracts and small reusable building blocks for commands, queries, h

```xml
<ItemGroup>
<PackageReference Include="PANiXiDA.Core.Application" Version="5.0.0" />
<PackageReference Include="PANiXiDA.Core.Application" Version="5.1.0" />
</ItemGroup>
```

Expand Down Expand Up @@ -269,16 +270,17 @@ 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<TRequest, TResult, THandler>` checks authentication and permissions declared by handlers implementing `IRequireAuthorization`.
- `ValidationBehavior<TRequest, TResult, THandler>` validates requests with registered FluentValidation `IValidator<TRequest>` implementations and returns a failed `Result` before the handler runs when validation fails.
- `BeginTransactionBehavior<TCommand, TResult, THandler>` starts a transaction before a command handler runs.
- `PublishDomainEventsBehavior<TCommand, TResult>` 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<TCommand, TResult>` commits the active transaction after a successful command result.
- `CleanupTransactionBehavior<TCommand, TResult>` rolls back failed command transactions and disposes transaction resources.
- `CleanupTransactionBehavior<TCommand, TResult>` rolls back failed command transactions, disposes transaction resources, and returns the supplied result unchanged.
- `ConcurrencyExceptionBehavior<TRequest, TResult>` 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. 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<TResult>` and do not apply to `IQuery<TResult>` or plain `IRequest<TResult>` 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.

Expand All @@ -292,12 +294,21 @@ handler: ICommandHandler<TCommand, TResult>
after: PublishDomainEventsBehavior
after: CommitTransactionBehavior
finally: CleanupTransactionBehavior
finally: ConcurrencyExceptionBehavior
```

The exact registration mechanism depends on the mediator or composition root used by the consuming application.
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

Persistence adapters report optimistic concurrency conflicts with `ConcurrencyException` from `PANiXiDA.Core.Application.Persistence.Errors`. `ConcurrencyExceptionBehavior<TRequest, TResult>` converts direct conflicts to `Error.Conflict`, preserving existing failed results and leaving other exceptions unhandled.

`FinallyAsync` now returns `Task<Result?>`: 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.

Existing finally implementations must adopt the new return type. Provider exception translation and Wolverine integration require separate adapter changes.

### Handler Authorization

Implement `IRequireAuthorization` on a command or query handler and provide
Expand Down Expand Up @@ -475,7 +486,8 @@ The aggregate repository contract is intentionally not defined by this package;
- `IReadModel` identifies query and read repository result payloads.
- `IBeforeRequestBehavior<TRequest, TResult, THandler>` runs before a handler and returns `Result.Success()` to continue request processing, or a failed `Result` to stop it.
- `IAfterRequestBehavior<TRequest, TResult>` runs after a handler returns a result and is defined in the mediator behavior abstractions namespace.
- `IFinallyRequestBehavior<TRequest, TResult>` runs after request processing completes or fails and is defined in the mediator behavior abstractions namespace.
- `IFinallyRequestBehavior<TRequest, TResult>` runs after request processing completes or fails and returns the current result or a replacement for the next finally behavior.
- `ConcurrencyExceptionBehavior<TRequest, TResult>` maps direct optimistic concurrency exceptions to `Error.Conflict` without replacing existing failure results.

### Validation

Expand All @@ -492,6 +504,7 @@ The aggregate repository contract is intentionally not defined by this package;

- `IUnitOfWork` defines persistence and transaction operations.
- `IReadRepository<TId>` defines read-only `ExistsByIdAsync` and `AnyAsync` checks.
- `ConcurrencyException` reports an optimistic concurrency conflict independently of a database provider.
- Aggregate persistence uses `IRepository<TId, TAggregateRoot>` from `PANiXiDA.Core.Domain`.

### Querying Models
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ namespace PANiXiDA.Core.Application.Messaging.Mediator.Behaviors.Abstractions;
/// </summary>
/// <typeparam name="TRequest">The request type processed by the behavior.</typeparam>
/// <typeparam name="TResult">The result type returned by the request.</typeparam>
/// <remarks>
/// 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.
/// </remarks>
public interface IFinallyRequestBehavior<TRequest, in TResult>
where TRequest : IRequest<TResult>
where TResult : Result
Expand All @@ -15,11 +27,11 @@ public interface IFinallyRequestBehavior<TRequest, in TResult>
/// Executes behavior after request processing completes, including failure paths.
/// </summary>
/// <param name="request">The request that was processed.</param>
/// <param name="result">The result returned by the request handler, if any.</param>
/// <param name="result">The current result returned by the handler or a preceding finally behavior, if any.</param>
/// <param name="exception">The exception thrown during request processing, if any.</param>
/// <param name="cancellationToken">The token used to cancel the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task FinallyAsync(
/// <returns>The supplied result or its replacement, or null when no result exists.</returns>
Task<Result?> FinallyAsync(
TRequest request,
TResult? result,
Exception? exception,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,24 @@ public sealed class CleanupTransactionBehavior<TCommand, TResult>(IUnitOfWork un
/// Rolls back the active transaction on failure and releases transaction resources.
/// </summary>
/// <param name="request">The command that was processed.</param>
/// <param name="result">The command execution result, if any.</param>
/// <param name="result">The current command result, if any.</param>
/// <param name="exception">The exception thrown during command processing, if any.</param>
/// <param name="cancellationToken">The token used to cancel the operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
/// <returns>The supplied result unchanged after successful cleanup.</returns>
/// <remarks>
/// Disposal is attempted even when rollback fails. A single cleanup failure is rethrown unchanged.
/// Multiple failures, including the supplied exception, are reported in an <see cref="AggregateException" />.
/// If cleanup succeeds, propagating the supplied exception remains the caller's responsibility.
/// </remarks>
public async Task FinallyAsync(
public async Task<Result?> FinallyAsync(
TCommand request,
TResult? result,
Exception? exception,
CancellationToken cancellationToken)
{
if (!unitOfWork.HasActiveTransaction)
{
return;
return result;
}

List<Exception>? cleanupExceptions = null;
Expand All @@ -64,21 +64,23 @@ public async Task FinallyAsync(
(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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using PANiXiDA.Core.Application.Messaging.Mediator.Behaviors.Abstractions;
using PANiXiDA.Core.Application.Messaging.Mediator.Contracts;
using PANiXiDA.Core.Application.Persistence.Errors;

namespace PANiXiDA.Core.Application.Messaging.Mediator.Behaviors;

/// <summary>
/// Converts a direct optimistic concurrency exception to a conflict failure result.
/// </summary>
/// <typeparam name="TRequest">The request type processed by the behavior.</typeparam>
/// <typeparam name="TResult">The result type returned by the request.</typeparam>
/// <remarks>
/// 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 accept exception handling only after all finally behaviors succeed.
/// </remarks>
public sealed class ConcurrencyExceptionBehavior<TRequest, TResult> : IFinallyRequestBehavior<TRequest, TResult>
where TRequest : IRequest<TResult>
where TResult : Result
{
/// <summary>
/// Offers a conflict result for a concurrency exception when there is no existing failure result.
/// </summary>
/// <param name="request">The request that was processed.</param>
/// <param name="result">The current request result, or null when no result was produced.</param>
/// <param name="exception">The processing exception to inspect, if any.</param>
/// <param name="cancellationToken">The cancellation token for the operation.</param>
/// <returns>A conflict failure result for a handled exception, or the supplied result unchanged.</returns>
public Task<Result?> FinallyAsync(
TRequest request,
TResult? result,
Exception? exception,
CancellationToken cancellationToken)
{
if (result is { IsFailure: true } || exception is not ConcurrencyException)
{
return Task.FromResult<Result?>(result);
}

return Task.FromResult<Result?>(Result.Failure(
Error.Conflict("The entity was modified or deleted. Reload it and try again.")));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
namespace PANiXiDA.Core.Application.Persistence.Errors;

/// <summary>
/// Indicates that a persistence operation could not match the expected entity version.
/// </summary>
/// <remarks>
/// Persistence adapters use this exception for optimistic concurrency conflicts, including concurrent deletion.
/// Provider-specific exceptions can be retained as the inner exception.
/// </remarks>
public sealed class ConcurrencyException : Exception
{
/// <summary>
/// Initializes a concurrency exception with a default message.
/// </summary>
public ConcurrencyException()
: this("The entity was modified or deleted by another operation.")
{
}

/// <summary>
/// Initializes a concurrency exception with a diagnostic message.
/// </summary>
/// <param name="message">A nonblank diagnostic message.</param>
/// <exception cref="ArgumentNullException">The message is null.</exception>
/// <exception cref="ArgumentException">The message is empty or whitespace.</exception>
public ConcurrencyException(string message)
: this(message, null)
{
}

/// <summary>
/// Initializes a concurrency exception with a diagnostic message and the original failure.
/// </summary>
/// <param name="message">A nonblank diagnostic message.</param>
/// <param name="innerException">The original failure, or null when the adapter detected the conflict directly.</param>
/// <exception cref="ArgumentNullException">The message is null.</exception>
/// <exception cref="ArgumentException">The message is empty or whitespace.</exception>
public ConcurrencyException(string message, Exception? innerException)
: base(message, innerException)
{
ArgumentException.ThrowIfNullOrWhiteSpace(message);
}
}
Loading
Loading