diff --git a/.editorconfig b/.editorconfig
index 9e451e5..5a9d98a 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -139,3 +139,6 @@ dotnet_diagnostic.CS1591.severity = none
[src/**/*.cs]
dotnet_diagnostic.CS1591.severity = error
+
+[I{Query,Command,Request}.cs]
+dotnet_diagnostic.S2326.severity = none
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 1272c60..765e603 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -1,13 +1,15 @@
-
- true
- true
-
-
-
-
-
-
-
-
-
+
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/PANiXiDA.Core.Template.slnx b/PANiXiDA.Core.Application.slnx
similarity index 70%
rename from PANiXiDA.Core.Template.slnx
rename to PANiXiDA.Core.Application.slnx
index 736a666..05ec795 100644
--- a/PANiXiDA.Core.Template.slnx
+++ b/PANiXiDA.Core.Application.slnx
@@ -16,9 +16,9 @@
-
+
-
+
diff --git a/README.md b/README.md
index 22cff3e..d04f5c3 100644
--- a/README.md
+++ b/README.md
@@ -1,174 +1,173 @@
-## What to do after creating a repository from this template
+# PANiXiDA.Core.Application
-### 1. Rename repository metadata
-- change repository name
-- change solution / project names
-- change package ID
-- change assembly name
-- change repository URLs
-- change ProjectReference in test project
+`PANiXiDA.Core.Application` is a .NET library with application-layer abstractions for Clean Architecture, CQRS, and DDD-based services.
-### 2. Update package metadata
-- description
-- tags
-
-### 3. Update documentation
-- replace this template README with the project README
-- fill all placeholder sections
-- update badges
-- update installation instructions
-- add real usage examples
-
-### 4. Configure GitHub repository
-- check repository visibility
-- configure default branch
-- configure branch protection rules
-- configure Issues / Discussions if needed
-- configure repository description, topics and website
-
-### 5. Prepare the first release
-- update versioning configuration pathFilters in version.json
-- verify NuGet metadata
-- verify README and icon inside the package
-- publish the first package version
-- the version is updated automatically based on the commit history
-
----
-
-# Universal README template for the NuGet package
-
-#
-
-`` is a .NET library for .
-
-It is designed for who need .
+It defines contracts and small reusable building blocks for commands, queries, request behaviors, domain event publishing, unit-of-work orchestration, repositories, aggregate tracking, and read-side paging helpers. The package intentionally does not provide a concrete mediator, database provider, dependency injection module, or transport-specific implementation.
## Status
-[](https://github.com///actions/workflows/ci.yml)
-[](https://www.nuget.org/packages/)
-[](https://www.nuget.org/packages/)
+[](https://github.com/panixida-dotnet-core/application/actions/workflows/ci.yml)
+[](https://www.nuget.org/packages/PANiXiDA.Core.Application)
+[](https://www.nuget.org/packages/PANiXiDA.Core.Application)
[](https://dotnet.microsoft.com/)
-[](LICENSE)
-
-## Overview
-
-Describe:
-
-- what problem this package solves;
-- why it exists;
-- where it fits in the system or ecosystem;
-- how it differs from alternatives, if that matters.
-
-Keep this section short and practical.
+[](LICENSE)
## Features
-- Feature 1
-- Feature 2
-- Feature 3
-- Feature 4
-- Feature 5
-
-## Quick Start
+- 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.
+- Built-in behaviors for transaction start, save, commit, cleanup, and domain event publishing.
+- Event bus and event handler abstractions for `DomainEvent` integration.
+- Unit of work, repository, and aggregate tracker abstractions for DDD persistence boundaries.
+- Read-side helper models for page-based pagination, cursor pagination, sorting, and filtering.
-### Requirements
+## Requirements
- .NET 10 SDK
+- Nullable reference types enabled in consuming projects is recommended
-### Installation
+## Installation
```xml
-
+
-````
+```
+
+## Basic Usage
-### Minimal import
+### Command Contract
```csharp
-using ;
+using PANiXiDA.Core.Application.Messaging.Mediator.Contracts;
+using PANiXiDA.Core.Application.Messaging.Mediator.Handlers;
+using PANiXiDA.Core.ResultPattern;
+
+public sealed record PingCommand : ICommand;
+
+public sealed class PingCommandHandler : ICommandHandler
+{
+ public Task HandleAsync(
+ PingCommand command,
+ CancellationToken cancellationToken)
+ {
+ return Task.FromResult(Result.Success());
+ }
+}
```
-### First example
+### Query Contract
```csharp
-// Add a minimal example here
+using PANiXiDA.Core.Application.Messaging.Mediator.Contracts;
+using PANiXiDA.Core.Application.Messaging.Mediator.Handlers;
+using PANiXiDA.Core.ResultPattern;
+
+public sealed record GetNameQuery(Guid Id) : IQuery>;
+
+public sealed class GetNameQueryHandler : IQueryHandler>
+{
+ public Task> HandleAsync(
+ GetNameQuery query,
+ CancellationToken cancellationToken)
+ {
+ return Task.FromResult(Result.Success("PANiXiDA"));
+ }
+}
```
-## Usage
-
-### Basic usage
+### Page-Based Query Result
```csharp
-// Add a basic example here
+using PANiXiDA.Core.Application.Querying.Pagination;
+
+var result = PaginationResult.Create(
+ items: ["first", "second"],
+ pageNumber: 1,
+ pageSize: 10,
+ totalCount: 2);
+
+var hasNextPage = result.HasNextPage;
```
-### Typical scenario
+### Cursor-Based Query Result
```csharp
-// Add a realistic example here
+using PANiXiDA.Core.Application.Querying.Cursor;
+
+var result = CursorPaginationResult.Create(
+ items: ["first", "second"],
+ limit: 10,
+ nextCursor: "cursor-2",
+ hasNextPage: true);
```
-### Advanced scenario
+## Request Behaviors
-```csharp
-// Add an advanced example here if needed
+The package includes reusable mediator behavior implementations for command transaction orchestration and domain event publication:
+
+- `BeginTransactionBehavior` starts a transaction before a command handler runs.
+- `SaveChangesBehavior` persists changes after a successful command result when a transaction is active.
+- `PublishDomainEventsBehavior` publishes domain events collected from tracked aggregate roots after a successful request 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.
+
+A consuming mediator implementation should register these behaviors in a deterministic order. A typical command pipeline is:
+
+```text
+before: BeginTransactionBehavior
+handler: ICommandHandler
+after: SaveChangesBehavior
+after: PublishDomainEventsBehavior
+after: CommitTransactionBehavior
+finally: CleanupTransactionBehavior
```
-## Configuration
+The exact registration mechanism depends on the mediator or composition root used by the consuming application.
-Describe configuration only if the package actually requires it.
+## API Overview
-Possible topics:
+### Messaging
-* environment variables;
-* `appsettings.json`;
-* feature flags;
-* external services;
-* secrets;
-* runtime prerequisites.
+- `IMediator` dispatches commands and queries.
+- `ICommandHandler` handles state-changing requests.
+- `IQueryHandler` handles read-only requests.
+- `IBeforeRequestBehavior` runs before a handler and is defined in the mediator behavior abstractions namespace.
+- `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.
-If the package does not require runtime configuration, say so explicitly.
+### Domain Events
-## Project Structure
+- `IEventBus` publishes domain events.
+- `IEventHandler` handles a specific domain event type.
+- `IAggregateTracker` tracks aggregate roots touched during a request so their domain events can be published and cleared.
-```text
-.
-├── src/
-│ └── /
-├── tests/
-│ └── .UnitTests/
-├── .editorconfig
-├── .gitattributes
-├── .gitignore
-├── Directory.Build.props
-├── Directory.Build.targets
-├── Directory.Packages.props
-├── global.json
-├── version.json
-├── LICENSE
-└── README.md
-```
+### Persistence
-### Main repository files
+- `IUnitOfWork` defines persistence and transaction operations.
+- `IRepository` defines basic aggregate persistence operations.
+- `IReadRepository` defines read-only existence checks.
-* `src/` — source code
-* `tests/` — automated tests
-* `Directory.Build.props` — shared MSBuild settings
-* `Directory.Build.targets` — shared build / packaging settings
-* `Directory.Packages.props` — centralized package versions
-* `global.json` — SDK and tooling configuration
-* `version.json` — versioning configuration
-* `README.md` — package overview and usage documentation
+### Querying Models
+
+- `PaginationParameters` calculates `Skip` and `Take` for page-based reads.
+- `PaginationResult` returns page metadata and items.
+- `CursorPaginationParameters` represents cursor pagination input.
+- `CursorPaginationResult` returns cursor pagination metadata and items.
+- `SortParameters` and `SortOrder` represent read sorting options.
+- `FilterParameters` is the base type for custom read filter records.
+
+## Configuration
+
+The package does not require runtime configuration. Consumers provide concrete implementations for mediator dispatch, persistence, event bus delivery, aggregate tracking, and dependency injection registration.
## Development
-### Build
+### Restore
```bash
dotnet restore
-dotnet build --configuration Release
```
### Format
@@ -177,110 +176,42 @@ dotnet build --configuration Release
dotnet format
```
-### Test
+### Build
```bash
-dotnet test --configuration Release
+dotnet build --configuration Release
```
-### Pack
+### Test
```bash
-dotnet pack --configuration Release
+dotnet test --configuration Release
```
-### Full local validation
+### Pack
```bash
-dotnet restore
-dotnet format
-dotnet build --configuration Release
-dotnet test --configuration Release
dotnet pack --configuration Release
```
-### Tooling and conventions
-
-This repository uses:
-
-* .NET 10
-* Nullable enabled
-* Implicit usings enabled
-* Central package management
-* GitHub Actions
-* Nerdbank.GitVersioning
-
-Add more items only if they are actually relevant for the repository.
-
-## API / Contracts / Examples
-
-Describe the public API surface here.
-
-Suggested structure:
-
-* core abstractions;
-* main entry points;
-* key extension methods;
-* important behavioral notes;
-* typical integration examples.
-
-## Roadmap / TODO
-
-Potential future improvements:
-
-* item 1;
-* item 2;
-* item 3.
-
-Remove this section if it does not provide value.
-
-## Contributing
-
-Contributions are welcome.
-
-### General rules
-
-* keep the public API intentional;
-* avoid unnecessary dependencies;
-* preserve repository conventions;
-* do not introduce breaking changes without review;
-* keep documentation updated.
-
-### Code style
-
-* follow the repository `.editorconfig`;
-* prefer readable and explicit code;
-* keep naming consistent with the existing codebase.
-
-### Tests
-
-* add or update tests for meaningful behavior changes;
-* cover both success and failure scenarios where applicable;
-* add regression tests for bug fixes.
-
-### Validation before completion
-
-Run:
+## Project Structure
-```bash
-dotnet restore
-dotnet format
-dotnet build --configuration Release
-dotnet test --configuration Release
+```text
+.
+├── src/
+│ └── PANiXiDA.Core.Application/
+├── tests/
+│ └── PANiXiDA.Core.Application.UnitTests/
+├── Directory.Build.props
+├── Directory.Build.targets
+├── Directory.Packages.props
+├── global.json
+├── version.json
+├── icon.png
+├── LICENSE
+└── README.md
```
## License
-This project is licensed under the license.
-
-See the [LICENSE](LICENSE) file for details.
-
-## Maintainers / Contacts
-
-Maintained by .
-
-For questions or improvements, use:
-
-* GitHub Issues
-* Pull Requests
-* GitHub Discussions, if enabled
+This project is licensed under the Apache-2.0 license. See the [LICENSE](LICENSE) file for details.
diff --git a/src/PANiXiDA.Core.Application/Messaging/EventBus/Handlers/IEventHandler.cs b/src/PANiXiDA.Core.Application/Messaging/EventBus/Handlers/IEventHandler.cs
new file mode 100644
index 0000000..c77932b
--- /dev/null
+++ b/src/PANiXiDA.Core.Application/Messaging/EventBus/Handlers/IEventHandler.cs
@@ -0,0 +1,19 @@
+namespace PANiXiDA.Core.Application.Messaging.EventBus.Handlers;
+
+///
+/// Handles a published domain event.
+///
+/// The domain event type handled by the handler.
+public interface IEventHandler
+ where TEvent : DomainEvent
+{
+ ///
+ /// Handles the specified domain event.
+ ///
+ /// The domain event to handle.
+ /// The token used to cancel the operation.
+ /// A task that represents the asynchronous operation.
+ Task HandleAsync(
+ TEvent @event,
+ CancellationToken cancellationToken);
+}
diff --git a/src/PANiXiDA.Core.Application/Messaging/EventBus/IEventBus.cs b/src/PANiXiDA.Core.Application/Messaging/EventBus/IEventBus.cs
new file mode 100644
index 0000000..694f5a4
--- /dev/null
+++ b/src/PANiXiDA.Core.Application/Messaging/EventBus/IEventBus.cs
@@ -0,0 +1,19 @@
+namespace PANiXiDA.Core.Application.Messaging.EventBus;
+
+///
+/// Publishes domain events to their subscribers.
+///
+public interface IEventBus
+{
+ ///
+ /// Publishes the specified domain event.
+ ///
+ /// The type of the domain event to publish.
+ /// The domain event to publish.
+ /// The token used to cancel the operation.
+ /// A task that represents the asynchronous operation.
+ Task PublishAsync(
+ TEvent @event,
+ CancellationToken cancellationToken)
+ where TEvent : DomainEvent;
+}
diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IAfterRequestBehavior.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IAfterRequestBehavior.cs
new file mode 100644
index 0000000..c576c9f
--- /dev/null
+++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IAfterRequestBehavior.cs
@@ -0,0 +1,25 @@
+using PANiXiDA.Core.Application.Messaging.Mediator.Contracts;
+
+namespace PANiXiDA.Core.Application.Messaging.Mediator.Behaviors.Abstractions;
+
+///
+/// Defines behavior that runs after a request handler returns a result.
+///
+/// The request type processed by the behavior.
+/// The result type returned by the request.
+public interface IAfterRequestBehavior
+ where TRequest : IRequest
+ where TResult : Result
+{
+ ///
+ /// Executes behavior after the request handler returns a result.
+ ///
+ /// The request that was processed.
+ /// The result returned by the request handler.
+ /// The token used to cancel the operation.
+ /// A task that represents the asynchronous operation.
+ Task AfterAsync(
+ TRequest request,
+ TResult result,
+ CancellationToken cancellationToken);
+}
diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IBeforeRequestBehavior.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IBeforeRequestBehavior.cs
new file mode 100644
index 0000000..7a8e2fe
--- /dev/null
+++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IBeforeRequestBehavior.cs
@@ -0,0 +1,23 @@
+using PANiXiDA.Core.Application.Messaging.Mediator.Contracts;
+
+namespace PANiXiDA.Core.Application.Messaging.Mediator.Behaviors.Abstractions;
+
+///
+/// Defines behavior that runs before a request handler is executed.
+///
+/// The request type processed by the behavior.
+/// The result type returned by the request.
+public interface IBeforeRequestBehavior
+ where TRequest : IRequest
+ where TResult : Result
+{
+ ///
+ /// Executes behavior before the request handler runs.
+ ///
+ /// The request being processed.
+ /// The token used to cancel the operation.
+ /// A task that represents the asynchronous operation.
+ Task BeforeAsync(
+ TRequest request,
+ 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
new file mode 100644
index 0000000..ba89519
--- /dev/null
+++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/Abstractions/IFinallyRequestBehavior.cs
@@ -0,0 +1,27 @@
+using PANiXiDA.Core.Application.Messaging.Mediator.Contracts;
+
+namespace PANiXiDA.Core.Application.Messaging.Mediator.Behaviors.Abstractions;
+
+///
+/// Defines behavior that runs after request processing completes or fails.
+///
+/// The request type processed by the behavior.
+/// The result type returned by the request.
+public interface IFinallyRequestBehavior
+ where TRequest : IRequest
+ where TResult : Result
+{
+ ///
+ /// Executes behavior after request processing completes, including failure paths.
+ ///
+ /// The request that was processed.
+ /// The result returned by the request handler, 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(
+ TRequest request,
+ TResult? result,
+ Exception? exception,
+ CancellationToken cancellationToken);
+}
diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/BeginTransactionBehavior.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/BeginTransactionBehavior.cs
new file mode 100644
index 0000000..b8e6316
--- /dev/null
+++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/BeginTransactionBehavior.cs
@@ -0,0 +1,30 @@
+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;
+
+///
+/// Begins a unit-of-work transaction before a command handler executes.
+///
+/// The command type processed by the behavior.
+/// The result type returned by the command.
+/// The unit of work used to manage transactions.
+public sealed class BeginTransactionBehavior(IUnitOfWork unitOfWork)
+ : IBeforeRequestBehavior
+ where TCommand : ICommand
+ where TResult : Result
+{
+ ///
+ /// Begins a transaction before the command handler runs.
+ ///
+ /// The command being processed.
+ /// The token used to cancel the operation.
+ /// A task that represents the asynchronous operation.
+ public async Task BeforeAsync(
+ TCommand request,
+ CancellationToken cancellationToken)
+ {
+ await unitOfWork.BeginTransactionAsync(cancellationToken);
+ }
+}
diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/CleanupTransactionBehavior.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/CleanupTransactionBehavior.cs
new file mode 100644
index 0000000..fe196c4
--- /dev/null
+++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/CleanupTransactionBehavior.cs
@@ -0,0 +1,44 @@
+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;
+
+///
+/// Rolls back failed command transactions and releases transaction resources.
+///
+/// The command type processed by the behavior.
+/// The result type returned by the command.
+/// The unit of work used to manage transactions.
+public sealed class CleanupTransactionBehavior(IUnitOfWork unitOfWork)
+ : IFinallyRequestBehavior
+ where TCommand : ICommand
+ where TResult : Result
+{
+ ///
+ /// Rolls back the active transaction on failure and releases transaction resources.
+ ///
+ /// The command that was processed.
+ /// The command execution 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.
+ public async Task FinallyAsync(
+ TCommand request,
+ TResult? result,
+ Exception? exception,
+ CancellationToken cancellationToken)
+ {
+ if (!unitOfWork.HasActiveTransaction)
+ {
+ return;
+ }
+
+ if (exception is not null || result is null || !result.IsSuccess)
+ {
+ await unitOfWork.RollbackTransactionAsync(cancellationToken);
+ }
+
+ await unitOfWork.DisposeTransactionAsync();
+ }
+}
diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/CommitTransactionBehavior.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/CommitTransactionBehavior.cs
new file mode 100644
index 0000000..9ca7121
--- /dev/null
+++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/CommitTransactionBehavior.cs
@@ -0,0 +1,37 @@
+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;
+
+///
+/// Commits the active unit-of-work transaction after a successful command result.
+///
+/// The command type processed by the behavior.
+/// The result type returned by the command.
+/// The unit of work used to manage transactions.
+public sealed class CommitTransactionBehavior(IUnitOfWork unitOfWork)
+ : IAfterRequestBehavior
+ where TCommand : ICommand
+ where TResult : Result
+{
+ ///
+ /// Commits the active transaction when the command succeeded.
+ ///
+ /// The command that was processed.
+ /// The command execution result.
+ /// The token used to cancel the operation.
+ /// A task that represents the asynchronous operation.
+ public Task AfterAsync(
+ TCommand request,
+ TResult result,
+ CancellationToken cancellationToken)
+ {
+ if (!unitOfWork.HasActiveTransaction || !result.IsSuccess)
+ {
+ return Task.CompletedTask;
+ }
+
+ return unitOfWork.CommitTransactionAsync(cancellationToken);
+ }
+}
diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/PublishDomainEventsBehavior.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/PublishDomainEventsBehavior.cs
new file mode 100644
index 0000000..1383a67
--- /dev/null
+++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/PublishDomainEventsBehavior.cs
@@ -0,0 +1,63 @@
+using PANiXiDA.Core.Application.Messaging.EventBus;
+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;
+
+///
+/// Publishes domain events collected from tracked aggregate roots after a successful request result.
+///
+/// The request type processed by the behavior.
+/// The result type returned by the request.
+/// The event bus used to publish domain events.
+/// The tracker that stores aggregate roots touched by the request.
+public sealed class PublishDomainEventsBehavior(
+ IEventBus eventBus,
+ IAggregateTracker aggregateTracker) : IAfterRequestBehavior
+ where TRequest : IRequest
+ where TResult : Result
+{
+ ///
+ /// Publishes domain events when the request succeeded and clears tracked events after completed publication.
+ ///
+ /// The request that was processed.
+ /// The request execution result.
+ /// The token used to cancel the operation.
+ /// A task that represents the asynchronous operation.
+ public async Task AfterAsync(
+ TRequest request,
+ TResult result,
+ CancellationToken cancellationToken)
+ {
+ var aggregateRoots = aggregateTracker.GetAll();
+
+ if (result.IsFailure)
+ {
+ ClearDomainEvents(aggregateRoots);
+ aggregateTracker.Clear();
+ return;
+ }
+
+ foreach (var aggregateRoot in aggregateRoots)
+ {
+ var domainEvents = aggregateRoot.GetDomainEvents();
+
+ foreach (var domainEvent in domainEvents)
+ {
+ await eventBus.PublishAsync(domainEvent, cancellationToken);
+ }
+ }
+
+ ClearDomainEvents(aggregateRoots);
+ aggregateTracker.Clear();
+ }
+
+ private static void ClearDomainEvents(IReadOnlyCollection aggregateRoots)
+ {
+ foreach (var aggregateRoot in aggregateRoots)
+ {
+ aggregateRoot.ClearDomainEvents();
+ }
+ }
+}
diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/SaveChangesBehavior.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/SaveChangesBehavior.cs
new file mode 100644
index 0000000..b1af626
--- /dev/null
+++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Behaviors/SaveChangesBehavior.cs
@@ -0,0 +1,37 @@
+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;
+
+///
+/// Persists unit-of-work changes after a successful command result.
+///
+/// The command type processed by the behavior.
+/// The result type returned by the command.
+/// The unit of work used to persist changes.
+public sealed class SaveChangesBehavior(IUnitOfWork unitOfWork)
+ : IAfterRequestBehavior
+ where TCommand : ICommand
+ where TResult : Result
+{
+ ///
+ /// Persists pending changes when the command succeeded inside an active transaction.
+ ///
+ /// The command that was processed.
+ /// The command execution result.
+ /// The token used to cancel the operation.
+ /// A task that represents the asynchronous operation.
+ public Task AfterAsync(
+ TCommand request,
+ TResult result,
+ CancellationToken cancellationToken)
+ {
+ if (!unitOfWork.HasActiveTransaction || !result.IsSuccess)
+ {
+ return Task.CompletedTask;
+ }
+
+ return unitOfWork.SaveChangesAsync(cancellationToken);
+ }
+}
diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Contracts/ICommand.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Contracts/ICommand.cs
new file mode 100644
index 0000000..4661a0e
--- /dev/null
+++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Contracts/ICommand.cs
@@ -0,0 +1,10 @@
+namespace PANiXiDA.Core.Application.Messaging.Mediator.Contracts;
+
+///
+/// Defines a state-changing application request.
+///
+/// The result type returned by the command.
+public interface ICommand : IRequest
+ where TResult : Result
+{
+}
diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Contracts/IQuery.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Contracts/IQuery.cs
new file mode 100644
index 0000000..42ad158
--- /dev/null
+++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Contracts/IQuery.cs
@@ -0,0 +1,10 @@
+namespace PANiXiDA.Core.Application.Messaging.Mediator.Contracts;
+
+///
+/// Defines a read-only application request.
+///
+/// The result type returned by the query.
+public interface IQuery : IRequest
+ where TResult : Result
+{
+}
diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Contracts/IRequest.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Contracts/IRequest.cs
new file mode 100644
index 0000000..8359b54
--- /dev/null
+++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Contracts/IRequest.cs
@@ -0,0 +1,9 @@
+namespace PANiXiDA.Core.Application.Messaging.Mediator.Contracts;
+
+///
+/// Defines an application request that returns a result.
+///
+/// The result type returned by the request.
+public interface IRequest
+{
+}
diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Handlers/ICommandHandler.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Handlers/ICommandHandler.cs
new file mode 100644
index 0000000..e83006f
--- /dev/null
+++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Handlers/ICommandHandler.cs
@@ -0,0 +1,23 @@
+using PANiXiDA.Core.Application.Messaging.Mediator.Contracts;
+
+namespace PANiXiDA.Core.Application.Messaging.Mediator.Handlers;
+
+///
+/// Handles a command and returns its execution result.
+///
+/// The command type handled by the handler.
+/// The result type returned by the command.
+public interface ICommandHandler
+ where TCommand : ICommand
+ where TResult : Result
+{
+ ///
+ /// Handles the specified command.
+ ///
+ /// The command to handle.
+ /// The token used to cancel the operation.
+ /// The command execution result.
+ Task HandleAsync(
+ TCommand command,
+ CancellationToken cancellationToken);
+}
diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/Handlers/IQueryHandler.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/Handlers/IQueryHandler.cs
new file mode 100644
index 0000000..9215a37
--- /dev/null
+++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/Handlers/IQueryHandler.cs
@@ -0,0 +1,23 @@
+using PANiXiDA.Core.Application.Messaging.Mediator.Contracts;
+
+namespace PANiXiDA.Core.Application.Messaging.Mediator.Handlers;
+
+///
+/// Handles a query and returns its execution result.
+///
+/// The query type handled by the handler.
+/// The result type returned by the query.
+public interface IQueryHandler
+ where TQuery : IQuery
+ where TResult : Result
+{
+ ///
+ /// Handles the specified query.
+ ///
+ /// The query to handle.
+ /// The token used to cancel the operation.
+ /// The query execution result.
+ Task HandleAsync(
+ TQuery query,
+ CancellationToken cancellationToken);
+}
diff --git a/src/PANiXiDA.Core.Application/Messaging/Mediator/IMediator.cs b/src/PANiXiDA.Core.Application/Messaging/Mediator/IMediator.cs
new file mode 100644
index 0000000..191c04b
--- /dev/null
+++ b/src/PANiXiDA.Core.Application/Messaging/Mediator/IMediator.cs
@@ -0,0 +1,33 @@
+using PANiXiDA.Core.Application.Messaging.Mediator.Contracts;
+
+namespace PANiXiDA.Core.Application.Messaging.Mediator;
+
+///
+/// Dispatches application commands and queries to their handlers.
+///
+public interface IMediator
+{
+ ///
+ /// Sends a command to the matching command handler.
+ ///
+ /// The result type returned by the command.
+ /// The command to dispatch.
+ /// The token used to cancel the operation.
+ /// The command execution result.
+ Task SendAsync(
+ ICommand command,
+ CancellationToken cancellationToken)
+ where TResult : Result;
+
+ ///
+ /// Sends a query to the matching query handler.
+ ///
+ /// The result type returned by the query.
+ /// The query to dispatch.
+ /// The token used to cancel the operation.
+ /// The query execution result.
+ Task QueryAsync(
+ IQuery query,
+ CancellationToken cancellationToken)
+ where TResult : Result;
+}
diff --git a/src/PANiXiDA.Core.Application/PANiXiDA.Core.Application.csproj b/src/PANiXiDA.Core.Application/PANiXiDA.Core.Application.csproj
new file mode 100644
index 0000000..16da558
--- /dev/null
+++ b/src/PANiXiDA.Core.Application/PANiXiDA.Core.Application.csproj
@@ -0,0 +1,28 @@
+
+
+ true
+
+ PANiXiDA.Core.Application
+
+ PANiXiDA.Core.Application
+ Core application-layer abstractions and building blocks for .NET applications, including contracts, messaging, validation, and use case orchestration.
+ dotnet;application-layer;clean-architecture;ddd;cqrs;use-cases;abstractions;contracts;validation;mediator
+
+ https://github.com/panixida-dotnet-core/application
+ https://github.com/panixida-dotnet-core/application
+
+
+
+
+ all
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PANiXiDA.Core.Application/Persistence/IAggregateTracker.cs b/src/PANiXiDA.Core.Application/Persistence/IAggregateTracker.cs
new file mode 100644
index 0000000..8ef7f09
--- /dev/null
+++ b/src/PANiXiDA.Core.Application/Persistence/IAggregateTracker.cs
@@ -0,0 +1,24 @@
+namespace PANiXiDA.Core.Application.Persistence;
+
+///
+/// Tracks aggregate roots touched during an application request.
+///
+public interface IAggregateTracker
+{
+ ///
+ /// Adds an aggregate root to the current tracking scope.
+ ///
+ /// The aggregate root to track.
+ void Track(IAggregateRoot aggregateRoot);
+
+ ///
+ /// Gets all aggregate roots tracked in the current scope.
+ ///
+ /// The tracked aggregate roots.
+ IReadOnlyCollection GetAll();
+
+ ///
+ /// Clears the current aggregate tracking scope.
+ ///
+ void Clear();
+}
diff --git a/src/PANiXiDA.Core.Application/Persistence/IReadRepository.cs b/src/PANiXiDA.Core.Application/Persistence/IReadRepository.cs
new file mode 100644
index 0000000..fe801a3
--- /dev/null
+++ b/src/PANiXiDA.Core.Application/Persistence/IReadRepository.cs
@@ -0,0 +1,26 @@
+namespace PANiXiDA.Core.Application.Persistence;
+
+///
+/// Defines read-only persistence checks for an entity or aggregate root identifier.
+///
+/// The identifier type.
+public interface IReadRepository
+ where TId : struct
+{
+ ///
+ /// Determines whether an item with the specified identifier exists.
+ ///
+ /// The item identifier.
+ /// The token used to cancel the operation.
+ /// if an item with the identifier exists; otherwise, .
+ Task ExistsByIdAsync(
+ TId id,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Determines whether the read repository contains any items.
+ ///
+ /// The token used to cancel the operation.
+ /// if at least one item exists; otherwise, .
+ Task AnyAsync(CancellationToken cancellationToken);
+}
diff --git a/src/PANiXiDA.Core.Application/Persistence/IRepository.cs b/src/PANiXiDA.Core.Application/Persistence/IRepository.cs
new file mode 100644
index 0000000..3f5bbff
--- /dev/null
+++ b/src/PANiXiDA.Core.Application/Persistence/IRepository.cs
@@ -0,0 +1,37 @@
+namespace PANiXiDA.Core.Application.Persistence;
+
+///
+/// Defines basic persistence operations for an aggregate root.
+///
+/// The aggregate root identifier type.
+/// The aggregate root type.
+public interface IRepository
+ where TId : struct
+ where TAggregateRoot : class, IAggregateRoot
+{
+ ///
+ /// Gets an aggregate root by its identifier.
+ ///
+ /// The aggregate root identifier.
+ /// The token used to cancel the operation.
+ /// The aggregate root when found; otherwise, .
+ Task GetByIdAsync(TId id, CancellationToken cancellationToken);
+
+ ///
+ /// Marks the aggregate root for insertion.
+ ///
+ /// The aggregate root to add.
+ void Add(TAggregateRoot aggregateRoot);
+
+ ///
+ /// Marks the aggregate root for update.
+ ///
+ /// The aggregate root to update.
+ void Update(TAggregateRoot aggregateRoot);
+
+ ///
+ /// Marks the aggregate root for deletion.
+ ///
+ /// The aggregate root to delete.
+ void Delete(TAggregateRoot aggregateRoot);
+}
diff --git a/src/PANiXiDA.Core.Application/Persistence/IUnitOfWork.cs b/src/PANiXiDA.Core.Application/Persistence/IUnitOfWork.cs
new file mode 100644
index 0000000..ccdcbfb
--- /dev/null
+++ b/src/PANiXiDA.Core.Application/Persistence/IUnitOfWork.cs
@@ -0,0 +1,54 @@
+namespace PANiXiDA.Core.Application.Persistence;
+
+///
+/// Coordinates persistence changes and transaction boundaries for an application request.
+///
+public interface IUnitOfWork
+{
+ ///
+ /// Persists pending changes in the current unit of work.
+ ///
+ /// The token used to cancel the operation.
+ /// A task that represents the asynchronous operation.
+ Task SaveChangesAsync(CancellationToken cancellationToken);
+
+ ///
+ /// Executes an action inside a transaction managed by the unit of work.
+ ///
+ /// The action to execute inside the transaction.
+ /// The token used to cancel the operation.
+ /// A task that represents the asynchronous operation.
+ Task ExecuteInTransactionAsync(Func action, CancellationToken cancellationToken);
+
+ ///
+ /// Begins a new transaction.
+ ///
+ /// The token used to cancel the operation.
+ /// A task that represents the asynchronous operation.
+ Task BeginTransactionAsync(CancellationToken cancellationToken);
+
+ ///
+ /// Commits the active transaction.
+ ///
+ /// The token used to cancel the operation.
+ /// A task that represents the asynchronous operation.
+ Task CommitTransactionAsync(CancellationToken cancellationToken);
+
+ ///
+ /// Rolls back the active transaction.
+ ///
+ /// The token used to cancel the operation.
+ /// A task that represents the asynchronous operation.
+ Task RollbackTransactionAsync(CancellationToken cancellationToken);
+
+ ///
+ /// Releases resources owned by the active transaction.
+ ///
+ /// A value task that represents the asynchronous operation.
+ ValueTask DisposeTransactionAsync();
+
+ ///
+ /// Gets a value indicating whether the unit of work has an active transaction.
+ ///
+ bool HasActiveTransaction { get; }
+}
diff --git a/src/PANiXiDA.Core.Application/Querying/Cursor/CursorDirection.cs b/src/PANiXiDA.Core.Application/Querying/Cursor/CursorDirection.cs
new file mode 100644
index 0000000..90c5a63
--- /dev/null
+++ b/src/PANiXiDA.Core.Application/Querying/Cursor/CursorDirection.cs
@@ -0,0 +1,21 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace PANiXiDA.Core.Application.Querying.Cursor;
+
+///
+/// Defines cursor pagination directions.
+///
+public enum CursorDirection
+{
+ ///
+ /// Reads items after the current cursor.
+ ///
+ [Display(Name = "Вперёд")]
+ Forward = 1,
+
+ ///
+ /// Reads items before the current cursor.
+ ///
+ [Display(Name = "Назад")]
+ Backward = 2
+}
diff --git a/src/PANiXiDA.Core.Application/Querying/Cursor/CursorPaginationParameters.cs b/src/PANiXiDA.Core.Application/Querying/Cursor/CursorPaginationParameters.cs
new file mode 100644
index 0000000..5ab7c73
--- /dev/null
+++ b/src/PANiXiDA.Core.Application/Querying/Cursor/CursorPaginationParameters.cs
@@ -0,0 +1,26 @@
+namespace PANiXiDA.Core.Application.Querying.Cursor;
+
+///
+/// Represents cursor-based pagination request parameters.
+///
+/// The cursor used as the page boundary.
+/// The maximum number of items to return.
+/// The read direction relative to the cursor.
+public sealed record CursorPaginationParameters(
+ string? Cursor,
+ int Limit = 10,
+ CursorDirection Direction = CursorDirection.Forward)
+{
+ ///
+ /// Creates parameters for the first page.
+ ///
+ /// The maximum number of items to return.
+ /// Cursor pagination parameters for the first page.
+ public static CursorPaginationParameters FirstPage(int limit)
+ {
+ return new CursorPaginationParameters(
+ null,
+ limit,
+ CursorDirection.Forward);
+ }
+}
diff --git a/src/PANiXiDA.Core.Application/Querying/Cursor/CursorPaginationResult.cs b/src/PANiXiDA.Core.Application/Querying/Cursor/CursorPaginationResult.cs
new file mode 100644
index 0000000..ed2b0e1
--- /dev/null
+++ b/src/PANiXiDA.Core.Application/Querying/Cursor/CursorPaginationResult.cs
@@ -0,0 +1,111 @@
+namespace PANiXiDA.Core.Application.Querying.Cursor;
+
+///
+/// Represents a cursor-based pagination result.
+///
+/// The item type.
+public sealed class CursorPaginationResult
+{
+ ///
+ /// Gets the current page items.
+ ///
+ public IReadOnlyList Items { get; }
+
+ ///
+ /// Gets the requested item limit.
+ ///
+ public int Limit { get; }
+
+ ///
+ /// Gets the cursor used to request the next page.
+ ///
+ public string? NextCursor { get; }
+
+ ///
+ /// Gets the cursor used to request the previous page.
+ ///
+ public string? PreviousCursor { get; }
+
+ ///
+ /// Gets a value indicating whether a next page exists.
+ ///
+ public bool HasNextPage { get; }
+
+ ///
+ /// Gets a value indicating whether a previous page exists.
+ ///
+ public bool HasPreviousPage { get; }
+
+ private CursorPaginationResult(
+ IReadOnlyList items,
+ int limit,
+ string? nextCursor,
+ string? previousCursor,
+ bool hasNextPage,
+ bool hasPreviousPage)
+ {
+ ArgumentNullException.ThrowIfNull(items);
+
+ if (limit <= 0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(limit),
+ limit,
+ "Лимит должен быть больше 0.");
+ }
+
+ Items = items;
+ Limit = limit;
+ NextCursor = nextCursor;
+ PreviousCursor = previousCursor;
+ HasNextPage = hasNextPage;
+ HasPreviousPage = hasPreviousPage;
+ }
+
+ ///
+ /// Creates a cursor-based pagination result.
+ ///
+ /// The current page items.
+ /// The requested item limit.
+ /// The cursor used to request the next page.
+ /// The cursor used to request the previous page.
+ /// A value indicating whether a next page exists.
+ /// A value indicating whether a previous page exists.
+ /// A cursor-based pagination result.
+ public static CursorPaginationResult Create(
+ IEnumerable items,
+ int limit,
+ string? nextCursor = null,
+ string? previousCursor = null,
+ bool hasNextPage = false,
+ bool hasPreviousPage = false)
+ {
+ ArgumentNullException.ThrowIfNull(items);
+
+ return new CursorPaginationResult(
+ items is IReadOnlyList readOnlyList
+ ? readOnlyList
+ : [.. items],
+ limit,
+ nextCursor,
+ previousCursor,
+ hasNextPage,
+ hasPreviousPage);
+ }
+
+ ///
+ /// Creates an empty cursor-based pagination result.
+ ///
+ /// The requested item limit.
+ /// An empty cursor-based pagination result.
+ public static CursorPaginationResult Empty(int limit)
+ {
+ return new CursorPaginationResult(
+ items: [],
+ limit: limit,
+ nextCursor: null,
+ previousCursor: null,
+ hasNextPage: false,
+ hasPreviousPage: false);
+ }
+}
diff --git a/src/PANiXiDA.Core.Application/Querying/Filtering/FilterParameters.cs b/src/PANiXiDA.Core.Application/Querying/Filtering/FilterParameters.cs
new file mode 100644
index 0000000..c2723e0
--- /dev/null
+++ b/src/PANiXiDA.Core.Application/Querying/Filtering/FilterParameters.cs
@@ -0,0 +1,6 @@
+namespace PANiXiDA.Core.Application.Querying.Filtering;
+
+///
+/// Represents the base type for read request filter parameters.
+///
+public abstract record FilterParameters;
diff --git a/src/PANiXiDA.Core.Application/Querying/Pagination/PaginationParameters.cs b/src/PANiXiDA.Core.Application/Querying/Pagination/PaginationParameters.cs
new file mode 100644
index 0000000..4660a9d
--- /dev/null
+++ b/src/PANiXiDA.Core.Application/Querying/Pagination/PaginationParameters.cs
@@ -0,0 +1,19 @@
+namespace PANiXiDA.Core.Application.Querying.Pagination;
+
+///
+/// Represents page-based pagination request parameters.
+///
+/// The requested page number.
+/// The number of items per page.
+public sealed record PaginationParameters(int PageNumber = 1, int PageSize = 10)
+{
+ ///
+ /// Gets the number of items to skip for the current page.
+ ///
+ public int Skip => (Math.Max(PageNumber, 1) - 1) * Math.Max(PageSize, 1);
+
+ ///
+ /// Gets the number of items to take for the current page.
+ ///
+ public int Take => Math.Max(PageSize, 1);
+}
diff --git a/src/PANiXiDA.Core.Application/Querying/Pagination/PaginationResult.cs b/src/PANiXiDA.Core.Application/Querying/Pagination/PaginationResult.cs
new file mode 100644
index 0000000..f09294a
--- /dev/null
+++ b/src/PANiXiDA.Core.Application/Querying/Pagination/PaginationResult.cs
@@ -0,0 +1,127 @@
+namespace PANiXiDA.Core.Application.Querying.Pagination;
+
+///
+/// Represents a page-based pagination result.
+///
+/// The item type.
+public sealed class PaginationResult
+{
+ ///
+ /// Gets the current page items.
+ ///
+ public IReadOnlyList Items { get; }
+
+ ///
+ /// Gets the current page number.
+ ///
+ public int PageNumber { get; }
+
+ ///
+ /// Gets the current page size.
+ ///
+ public int PageSize { get; }
+
+ ///
+ /// Gets the total item count.
+ ///
+ public long TotalCount { get; }
+
+ ///
+ /// Gets the total page count.
+ ///
+ public long TotalPages { get; }
+
+ ///
+ /// Gets a value indicating whether a previous page exists.
+ ///
+ public bool HasPreviousPage { get; }
+
+ ///
+ /// Gets a value indicating whether a next page exists.
+ ///
+ public bool HasNextPage { get; }
+
+ private PaginationResult(
+ IReadOnlyList items,
+ int pageNumber,
+ int pageSize,
+ long totalCount)
+ {
+ ArgumentNullException.ThrowIfNull(items);
+
+ if (pageNumber <= 0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(pageNumber),
+ pageNumber,
+ "Номер страницы должен быть больше 0.");
+ }
+
+ if (pageSize <= 0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(pageSize),
+ pageSize,
+ "Размер страницы должен быть больше 0.");
+ }
+
+ if (totalCount < 0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(totalCount),
+ totalCount,
+ "Общее количество элементов не может быть отрицательным.");
+ }
+
+ Items = items;
+ PageNumber = pageNumber;
+ PageSize = pageSize;
+ TotalCount = totalCount;
+ TotalPages = totalCount == 0
+ ? 0
+ : (totalCount + pageSize - 1) / pageSize;
+
+ HasPreviousPage = pageNumber > 1;
+ HasNextPage = pageNumber < TotalPages;
+ }
+
+ ///
+ /// Creates a page-based pagination result.
+ ///
+ /// The current page items.
+ /// The current page number.
+ /// The current page size.
+ /// The total item count.
+ /// A page-based pagination result.
+ public static PaginationResult Create(
+ IEnumerable items,
+ int pageNumber,
+ int pageSize,
+ long totalCount)
+ {
+ ArgumentNullException.ThrowIfNull(items);
+
+ return new PaginationResult(
+ items is IReadOnlyList readOnlyList
+ ? readOnlyList
+ : [.. items],
+ pageNumber,
+ pageSize,
+ totalCount);
+ }
+
+ ///
+ /// Creates an empty page-based pagination result.
+ ///
+ /// The current page number.
+ /// The current page size.
+ /// An empty page-based pagination result.
+ public static PaginationResult Empty(int pageNumber, int pageSize)
+ {
+ return new PaginationResult(
+ items: [],
+ pageNumber: pageNumber,
+ pageSize: pageSize,
+ totalCount: 0);
+ }
+}
diff --git a/src/PANiXiDA.Core.Application/Querying/Sorting/SortOrder.cs b/src/PANiXiDA.Core.Application/Querying/Sorting/SortOrder.cs
new file mode 100644
index 0000000..bb5db00
--- /dev/null
+++ b/src/PANiXiDA.Core.Application/Querying/Sorting/SortOrder.cs
@@ -0,0 +1,21 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace PANiXiDA.Core.Application.Querying.Sorting;
+
+///
+/// Defines sort directions.
+///
+public enum SortOrder
+{
+ ///
+ /// Sorts values in ascending order.
+ ///
+ [Display(Name = "По возрастанию")]
+ Ascending = 0,
+
+ ///
+ /// Sorts values in descending order.
+ ///
+ [Display(Name = "По убыванию")]
+ Descending = 1,
+}
diff --git a/src/PANiXiDA.Core.Application/Querying/Sorting/SortParameters.cs b/src/PANiXiDA.Core.Application/Querying/Sorting/SortParameters.cs
new file mode 100644
index 0000000..ddcdb00
--- /dev/null
+++ b/src/PANiXiDA.Core.Application/Querying/Sorting/SortParameters.cs
@@ -0,0 +1,20 @@
+namespace PANiXiDA.Core.Application.Querying.Sorting;
+
+///
+/// Represents sorting parameters.
+///
+/// The field to sort by.
+/// The sort direction.
+public sealed record SortParameters(
+ string? Field = null,
+ SortOrder Order = SortOrder.Ascending)
+{
+ ///
+ /// Creates default sorting parameters.
+ ///
+ /// Default sorting parameters.
+ public static SortParameters Default()
+ {
+ return new SortParameters();
+ }
+}
diff --git a/src/PANiXiDA.Core.Template/PANiXiDA.Core.Template.csproj b/src/PANiXiDA.Core.Template/PANiXiDA.Core.Template.csproj
deleted file mode 100644
index 03bc1a3..0000000
--- a/src/PANiXiDA.Core.Template/PANiXiDA.Core.Template.csproj
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
- true
-
- PANiXiDA.Core.Template
-
- PANiXiDA.Core.Template
- Change me.
- Template;ChangeMe
-
- https://github.com/panixida-dotnet-core/template
- https://github.com/panixida-dotnet-core/template
-
-
-
-
- all
-
-
-
diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestAggregateRoot.cs b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestAggregateRoot.cs
new file mode 100644
index 0000000..1fa0982
--- /dev/null
+++ b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestAggregateRoot.cs
@@ -0,0 +1,12 @@
+using PANiXiDA.Core.Domain.AggregateRoots;
+using PANiXiDA.Core.Domain.DomainEvents;
+
+namespace PANiXiDA.Core.Application.UnitTests.Messaging.Mediator.Behaviors.Fakes;
+
+internal sealed class TestAggregateRoot(Guid id) : AggregateRoot(id)
+{
+ public void Raise(DomainEvent domainEvent)
+ {
+ AddDomainEvent(domainEvent);
+ }
+}
diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestAggregateTracker.cs b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestAggregateTracker.cs
new file mode 100644
index 0000000..4345eb0
--- /dev/null
+++ b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestAggregateTracker.cs
@@ -0,0 +1,27 @@
+using PANiXiDA.Core.Application.Persistence;
+using PANiXiDA.Core.Domain.AggregateRoots;
+
+namespace PANiXiDA.Core.Application.UnitTests.Messaging.Mediator.Behaviors.Fakes;
+
+internal sealed class TestAggregateTracker : IAggregateTracker
+{
+ private readonly List aggregateRoots = [];
+
+ public int ClearCalls { get; private set; }
+
+ public void Track(IAggregateRoot aggregateRoot)
+ {
+ aggregateRoots.Add(aggregateRoot);
+ }
+
+ public IReadOnlyCollection GetAll()
+ {
+ return aggregateRoots;
+ }
+
+ public void Clear()
+ {
+ ClearCalls++;
+ aggregateRoots.Clear();
+ }
+}
diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestCommand.cs b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestCommand.cs
new file mode 100644
index 0000000..e1a4886
--- /dev/null
+++ b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestCommand.cs
@@ -0,0 +1,6 @@
+using PANiXiDA.Core.Application.Messaging.Mediator.Contracts;
+using PANiXiDA.Core.ResultPattern;
+
+namespace PANiXiDA.Core.Application.UnitTests.Messaging.Mediator.Behaviors.Fakes;
+
+internal sealed record TestCommand : ICommand;
diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestDomainEvent.cs b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestDomainEvent.cs
new file mode 100644
index 0000000..4046208
--- /dev/null
+++ b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestDomainEvent.cs
@@ -0,0 +1,5 @@
+using PANiXiDA.Core.Domain.DomainEvents;
+
+namespace PANiXiDA.Core.Application.UnitTests.Messaging.Mediator.Behaviors.Fakes;
+
+internal sealed record TestDomainEvent : DomainEvent;
diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestEventBus.cs b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestEventBus.cs
new file mode 100644
index 0000000..139fc74
--- /dev/null
+++ b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestEventBus.cs
@@ -0,0 +1,23 @@
+using PANiXiDA.Core.Application.Messaging.EventBus;
+using PANiXiDA.Core.Domain.DomainEvents;
+
+namespace PANiXiDA.Core.Application.UnitTests.Messaging.Mediator.Behaviors.Fakes;
+
+internal sealed class TestEventBus : IEventBus
+{
+ public List PublishedEvents { get; } = [];
+ public Exception? Exception { get; init; }
+
+ public Task PublishAsync(TEvent @event, CancellationToken cancellationToken)
+ where TEvent : DomainEvent
+ {
+ if (Exception is not null)
+ {
+ throw Exception;
+ }
+
+ PublishedEvents.Add(@event);
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestRequest.cs b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestRequest.cs
new file mode 100644
index 0000000..fef10fd
--- /dev/null
+++ b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestRequest.cs
@@ -0,0 +1,6 @@
+using PANiXiDA.Core.Application.Messaging.Mediator.Contracts;
+using PANiXiDA.Core.ResultPattern;
+
+namespace PANiXiDA.Core.Application.UnitTests.Messaging.Mediator.Behaviors.Fakes;
+
+internal sealed record TestRequest : IRequest;
diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestUnitOfWork.cs b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestUnitOfWork.cs
new file mode 100644
index 0000000..ab938da
--- /dev/null
+++ b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/Fakes/TestUnitOfWork.cs
@@ -0,0 +1,65 @@
+using PANiXiDA.Core.Application.Persistence;
+
+namespace PANiXiDA.Core.Application.UnitTests.Messaging.Mediator.Behaviors.Fakes;
+
+internal sealed class TestUnitOfWork : IUnitOfWork
+{
+ public int SaveChangesCalls { get; private set; }
+ public int ExecuteInTransactionCalls { get; private set; }
+ public int BeginTransactionCalls { get; private set; }
+ public int CommitTransactionCalls { get; private set; }
+ public int RollbackTransactionCalls { get; private set; }
+ public int DisposeTransactionCalls { get; private set; }
+ public CancellationToken LastCancellationToken { get; private set; }
+ public bool HasActiveTransaction { get; set; }
+
+ public Task SaveChangesAsync(CancellationToken cancellationToken)
+ {
+ SaveChangesCalls++;
+ LastCancellationToken = cancellationToken;
+
+ return Task.CompletedTask;
+ }
+
+ public async Task ExecuteInTransactionAsync(
+ Func action,
+ CancellationToken cancellationToken)
+ {
+ ExecuteInTransactionCalls++;
+ LastCancellationToken = cancellationToken;
+ await action(cancellationToken);
+ }
+
+ public Task BeginTransactionAsync(CancellationToken cancellationToken)
+ {
+ BeginTransactionCalls++;
+ LastCancellationToken = cancellationToken;
+ HasActiveTransaction = true;
+
+ return Task.CompletedTask;
+ }
+
+ public Task CommitTransactionAsync(CancellationToken cancellationToken)
+ {
+ CommitTransactionCalls++;
+ LastCancellationToken = cancellationToken;
+
+ return Task.CompletedTask;
+ }
+
+ public Task RollbackTransactionAsync(CancellationToken cancellationToken)
+ {
+ RollbackTransactionCalls++;
+ LastCancellationToken = cancellationToken;
+
+ return Task.CompletedTask;
+ }
+
+ public ValueTask DisposeTransactionAsync()
+ {
+ DisposeTransactionCalls++;
+ HasActiveTransaction = false;
+
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/RequestBehaviorsTests.cs b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/RequestBehaviorsTests.cs
new file mode 100644
index 0000000..be362e2
--- /dev/null
+++ b/tests/PANiXiDA.Core.Application.UnitTests/Messaging/Mediator/Behaviors/RequestBehaviorsTests.cs
@@ -0,0 +1,246 @@
+using PANiXiDA.Core.Application.Messaging.Mediator.Behaviors;
+using PANiXiDA.Core.Application.UnitTests.Messaging.Mediator.Behaviors.Fakes;
+using PANiXiDA.Core.ResultPattern;
+
+namespace PANiXiDA.Core.Application.UnitTests.Messaging.Mediator.Behaviors;
+
+public sealed class RequestBehaviorsTests
+{
+ [Fact(DisplayName = "BeginTransactionBehavior begins a transaction")]
+ public async Task BeforeAsync_WhenCalled_BeginsTransaction()
+ {
+ var unitOfWork = new TestUnitOfWork();
+ var behavior = new BeginTransactionBehavior(unitOfWork);
+ using var cancellationTokenSource = new CancellationTokenSource();
+
+ await behavior.BeforeAsync(new TestCommand(), cancellationTokenSource.Token);
+
+ unitOfWork.BeginTransactionCalls.Should().Be(1);
+ unitOfWork.HasActiveTransaction.Should().BeTrue();
+ unitOfWork.LastCancellationToken.Should().Be(cancellationTokenSource.Token);
+ }
+
+ [Fact(DisplayName = "SaveChangesBehavior saves changes for successful commands in an active transaction")]
+ public async Task AfterAsync_WhenCommandSucceededInActiveTransaction_SavesChanges()
+ {
+ var unitOfWork = new TestUnitOfWork
+ {
+ HasActiveTransaction = true
+ };
+ var behavior = new SaveChangesBehavior(unitOfWork);
+
+ await behavior.AfterAsync(new TestCommand(), Result.Success(), CancellationToken.None);
+
+ unitOfWork.SaveChangesCalls.Should().Be(1);
+ }
+
+ [Fact(DisplayName = "SaveChangesBehavior skips when there is no active transaction")]
+ public async Task AfterAsync_WhenTransactionIsNotActive_DoesNotSaveChanges()
+ {
+ var unitOfWork = new TestUnitOfWork();
+ var behavior = new SaveChangesBehavior(unitOfWork);
+
+ await behavior.AfterAsync(new TestCommand(), Result.Success(), CancellationToken.None);
+
+ unitOfWork.SaveChangesCalls.Should().Be(0);
+ }
+
+ [Fact(DisplayName = "SaveChangesBehavior skips failed command results")]
+ public async Task AfterAsync_WhenCommandFailed_DoesNotSaveChanges()
+ {
+ var unitOfWork = new TestUnitOfWork
+ {
+ HasActiveTransaction = true
+ };
+ var behavior = new SaveChangesBehavior(unitOfWork);
+
+ await behavior.AfterAsync(new TestCommand(), CreateFailureResult(), CancellationToken.None);
+
+ unitOfWork.SaveChangesCalls.Should().Be(0);
+ }
+
+ [Fact(DisplayName = "CommitTransactionBehavior commits successful commands in an active transaction")]
+ public async Task AfterAsync_WhenCommandSucceededInActiveTransaction_CommitsTransaction()
+ {
+ var unitOfWork = new TestUnitOfWork
+ {
+ HasActiveTransaction = true
+ };
+ var behavior = new CommitTransactionBehavior(unitOfWork);
+
+ await behavior.AfterAsync(new TestCommand(), Result.Success(), CancellationToken.None);
+
+ unitOfWork.CommitTransactionCalls.Should().Be(1);
+ }
+
+ [Fact(DisplayName = "CommitTransactionBehavior skips when there is no active transaction")]
+ public async Task AfterAsync_WhenTransactionIsNotActive_DoesNotCommitTransaction()
+ {
+ var unitOfWork = new TestUnitOfWork();
+ var behavior = new CommitTransactionBehavior(unitOfWork);
+
+ await behavior.AfterAsync(new TestCommand(), Result.Success(), CancellationToken.None);
+
+ unitOfWork.CommitTransactionCalls.Should().Be(0);
+ }
+
+ [Fact(DisplayName = "CommitTransactionBehavior skips failed command results")]
+ public async Task AfterAsync_WhenCommandFailed_DoesNotCommitTransaction()
+ {
+ var unitOfWork = new TestUnitOfWork
+ {
+ HasActiveTransaction = true
+ };
+ var behavior = new CommitTransactionBehavior(unitOfWork);
+
+ await behavior.AfterAsync(new TestCommand(), CreateFailureResult(), CancellationToken.None);
+
+ unitOfWork.CommitTransactionCalls.Should().Be(0);
+ }
+
+ [Fact(DisplayName = "CleanupTransactionBehavior skips when there is no active transaction")]
+ public async Task FinallyAsync_WhenTransactionIsNotActive_DoesNotRollbackOrDispose()
+ {
+ var unitOfWork = new TestUnitOfWork();
+ var behavior = new CleanupTransactionBehavior(unitOfWork);
+
+ await behavior.FinallyAsync(new TestCommand(), Result.Success(), null, CancellationToken.None);
+
+ unitOfWork.RollbackTransactionCalls.Should().Be(0);
+ unitOfWork.DisposeTransactionCalls.Should().Be(0);
+ }
+
+ [Fact(DisplayName = "CleanupTransactionBehavior disposes successful active transactions")]
+ public async Task FinallyAsync_WhenCommandSucceededInActiveTransaction_DisposesTransaction()
+ {
+ var unitOfWork = new TestUnitOfWork
+ {
+ HasActiveTransaction = true
+ };
+ var behavior = new CleanupTransactionBehavior(unitOfWork);
+
+ await behavior.FinallyAsync(new TestCommand(), Result.Success(), null, CancellationToken.None);
+
+ unitOfWork.RollbackTransactionCalls.Should().Be(0);
+ unitOfWork.DisposeTransactionCalls.Should().Be(1);
+ }
+
+ [Fact(DisplayName = "CleanupTransactionBehavior rolls back failed command results")]
+ public async Task FinallyAsync_WhenCommandFailedInActiveTransaction_RollsBackAndDisposesTransaction()
+ {
+ var unitOfWork = new TestUnitOfWork
+ {
+ HasActiveTransaction = true
+ };
+ var behavior = new CleanupTransactionBehavior(unitOfWork);
+
+ await behavior.FinallyAsync(new TestCommand(), CreateFailureResult(), null, CancellationToken.None);
+
+ unitOfWork.RollbackTransactionCalls.Should().Be(1);
+ unitOfWork.DisposeTransactionCalls.Should().Be(1);
+ }
+
+ [Fact(DisplayName = "CleanupTransactionBehavior rolls back when the command result is missing")]
+ public async Task FinallyAsync_WhenResultIsNullInActiveTransaction_RollsBackAndDisposesTransaction()
+ {
+ var unitOfWork = new TestUnitOfWork
+ {
+ HasActiveTransaction = true
+ };
+ var behavior = new CleanupTransactionBehavior(unitOfWork);
+
+ await behavior.FinallyAsync(new TestCommand(), null, null, CancellationToken.None);
+
+ unitOfWork.RollbackTransactionCalls.Should().Be(1);
+ unitOfWork.DisposeTransactionCalls.Should().Be(1);
+ }
+
+ [Fact(DisplayName = "CleanupTransactionBehavior rolls back when an exception is provided")]
+ public async Task FinallyAsync_WhenExceptionIsProvidedInActiveTransaction_RollsBackAndDisposesTransaction()
+ {
+ var unitOfWork = new TestUnitOfWork
+ {
+ HasActiveTransaction = true
+ };
+ var behavior = new CleanupTransactionBehavior(unitOfWork);
+
+ await behavior.FinallyAsync(
+ new TestCommand(),
+ Result.Success(),
+ new InvalidOperationException(),
+ CancellationToken.None);
+
+ unitOfWork.RollbackTransactionCalls.Should().Be(1);
+ unitOfWork.DisposeTransactionCalls.Should().Be(1);
+ }
+
+ [Fact(DisplayName = "PublishDomainEventsBehavior publishes events from tracked aggregates")]
+ public async Task AfterAsync_WhenRequestSucceeded_PublishesDomainEventsAndClearsTracking()
+ {
+ var eventBus = new TestEventBus();
+ var aggregateTracker = new TestAggregateTracker();
+ var aggregateRoot = new TestAggregateRoot(Guid.NewGuid());
+ var firstEvent = new TestDomainEvent();
+ var secondEvent = new TestDomainEvent();
+ aggregateRoot.Raise(firstEvent);
+ aggregateRoot.Raise(secondEvent);
+ aggregateTracker.Track(aggregateRoot);
+
+ var behavior = new PublishDomainEventsBehavior(eventBus, aggregateTracker);
+
+ await behavior.AfterAsync(new TestRequest(), Result.Success(), CancellationToken.None);
+
+ eventBus.PublishedEvents.Should().Equal(firstEvent, secondEvent);
+ aggregateRoot.GetDomainEvents().Should().BeEmpty();
+ aggregateTracker.ClearCalls.Should().Be(1);
+ aggregateTracker.GetAll().Should().BeEmpty();
+ }
+
+ [Fact(DisplayName = "PublishDomainEventsBehavior clears tracking without publishing failed request results")]
+ public async Task AfterAsync_WhenRequestFailed_ClearsTrackingWithoutPublishing()
+ {
+ var eventBus = new TestEventBus();
+ var aggregateTracker = new TestAggregateTracker();
+ var aggregateRoot = new TestAggregateRoot(Guid.NewGuid());
+ aggregateRoot.Raise(new TestDomainEvent());
+ aggregateTracker.Track(aggregateRoot);
+
+ var behavior = new PublishDomainEventsBehavior(eventBus, aggregateTracker);
+
+ await behavior.AfterAsync(new TestRequest(), CreateFailureResult(), CancellationToken.None);
+
+ eventBus.PublishedEvents.Should().BeEmpty();
+ aggregateRoot.GetDomainEvents().Should().BeEmpty();
+ aggregateTracker.ClearCalls.Should().Be(1);
+ aggregateTracker.GetAll().Should().BeEmpty();
+ }
+
+ [Fact(DisplayName = "PublishDomainEventsBehavior does not clear tracking when publishing throws")]
+ public async Task AfterAsync_WhenPublishingThrows_DoesNotClearTrackingAndRethrows()
+ {
+ var eventBus = new TestEventBus
+ {
+ Exception = new InvalidOperationException("Publish failed.")
+ };
+ var aggregateTracker = new TestAggregateTracker();
+ var aggregateRoot = new TestAggregateRoot(Guid.NewGuid());
+ aggregateRoot.Raise(new TestDomainEvent());
+ aggregateTracker.Track(aggregateRoot);
+
+ var behavior = new PublishDomainEventsBehavior(eventBus, aggregateTracker);
+
+ Func act = () => behavior.AfterAsync(new TestRequest(), Result.Success(), CancellationToken.None);
+
+ await act.Should()
+ .ThrowAsync()
+ .WithMessage("Publish failed.");
+ aggregateRoot.GetDomainEvents().Should().NotBeEmpty();
+ aggregateTracker.ClearCalls.Should().Be(0);
+ aggregateTracker.GetAll().Should().ContainSingle();
+ }
+
+ private static Result CreateFailureResult()
+ {
+ return Result.Failure(Error.Failure("Failure."));
+ }
+}
diff --git a/tests/PANiXiDA.Core.Template.UnitTests/PANiXiDA.Core.Template.UnitTests.csproj b/tests/PANiXiDA.Core.Application.UnitTests/PANiXiDA.Core.Application.UnitTests.csproj
similarity index 84%
rename from tests/PANiXiDA.Core.Template.UnitTests/PANiXiDA.Core.Template.UnitTests.csproj
rename to tests/PANiXiDA.Core.Application.UnitTests/PANiXiDA.Core.Application.UnitTests.csproj
index 7e03d53..621b41e 100644
--- a/tests/PANiXiDA.Core.Template.UnitTests/PANiXiDA.Core.Template.UnitTests.csproj
+++ b/tests/PANiXiDA.Core.Application.UnitTests/PANiXiDA.Core.Application.UnitTests.csproj
@@ -11,7 +11,7 @@
-
+
diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Querying/Cursor/CursorPaginationParametersTests.cs b/tests/PANiXiDA.Core.Application.UnitTests/Querying/Cursor/CursorPaginationParametersTests.cs
new file mode 100644
index 0000000..c5de1fe
--- /dev/null
+++ b/tests/PANiXiDA.Core.Application.UnitTests/Querying/Cursor/CursorPaginationParametersTests.cs
@@ -0,0 +1,62 @@
+using PANiXiDA.Core.Application.Querying.Cursor;
+using System.ComponentModel.DataAnnotations;
+using System.Reflection;
+
+namespace PANiXiDA.Core.Application.UnitTests.Querying.Cursor;
+
+public sealed class CursorPaginationParametersTests
+{
+ [Fact(DisplayName = "FirstPage returns forward parameters without a cursor")]
+ public void FirstPage_WhenLimitIsProvided_ReturnsForwardParametersWithoutCursor()
+ {
+ var parameters = CursorPaginationParameters.FirstPage(limit: 50);
+
+ parameters.Cursor.Should().BeNull();
+ parameters.Limit.Should().Be(50);
+ parameters.Direction.Should().Be(CursorDirection.Forward);
+ }
+
+ [Fact(DisplayName = "Constructor stores cursor pagination values")]
+ public void Constructor_WhenValuesAreProvided_StoresValues()
+ {
+ var parameters = new CursorPaginationParameters(
+ Cursor: "cursor-1",
+ Limit: 10,
+ Direction: CursorDirection.Backward);
+
+ parameters.Cursor.Should().Be("cursor-1");
+ parameters.Limit.Should().Be(10);
+ parameters.Direction.Should().Be(CursorDirection.Backward);
+ }
+
+ [Fact(DisplayName = "With expression copies and updates cursor pagination parameters")]
+ public void WithExpression_WhenValuesAreChanged_CopiesAndUpdatesParameters()
+ {
+ var parameters = CursorPaginationParameters.FirstPage(limit: 10);
+
+ var updated = parameters with
+ {
+ Cursor = "cursor-2",
+ Limit = 20,
+ Direction = CursorDirection.Backward
+ };
+
+ updated.Cursor.Should().Be("cursor-2");
+ updated.Limit.Should().Be(20);
+ updated.Direction.Should().Be(CursorDirection.Backward);
+ }
+
+ [Theory(DisplayName = "CursorDirection has localized display names")]
+ [InlineData(CursorDirection.Forward, "Вперёд")]
+ [InlineData(CursorDirection.Backward, "Назад")]
+ public void DisplayName_WhenCursorDirectionIsProvided_ReturnsLocalizedName(
+ CursorDirection direction,
+ string expectedDisplayName)
+ {
+ var member = typeof(CursorDirection).GetMember(direction.ToString()).Single();
+ var displayAttribute = member.GetCustomAttribute();
+
+ displayAttribute.Should().NotBeNull();
+ displayAttribute!.Name.Should().Be(expectedDisplayName);
+ }
+}
diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Querying/Cursor/CursorPaginationResultTests.cs b/tests/PANiXiDA.Core.Application.UnitTests/Querying/Cursor/CursorPaginationResultTests.cs
new file mode 100644
index 0000000..7320820
--- /dev/null
+++ b/tests/PANiXiDA.Core.Application.UnitTests/Querying/Cursor/CursorPaginationResultTests.cs
@@ -0,0 +1,76 @@
+using PANiXiDA.Core.Application.Querying.Cursor;
+
+namespace PANiXiDA.Core.Application.UnitTests.Querying.Cursor;
+
+public sealed class CursorPaginationResultTests
+{
+ [Fact(DisplayName = "Create returns cursor page metadata")]
+ public void Create_WhenParametersAreValid_ReturnsCursorMetadata()
+ {
+ IReadOnlyList items = [1, 2];
+
+ var result = CursorPaginationResult.Create(
+ items,
+ limit: 2,
+ nextCursor: "next",
+ previousCursor: "previous",
+ hasNextPage: true,
+ hasPreviousPage: true);
+
+ result.Items.Should().BeSameAs(items);
+ result.Limit.Should().Be(2);
+ result.NextCursor.Should().Be("next");
+ result.PreviousCursor.Should().Be("previous");
+ result.HasNextPage.Should().BeTrue();
+ result.HasPreviousPage.Should().BeTrue();
+ }
+
+ [Fact(DisplayName = "Create copies enumerable cursor items into a read-only list")]
+ public void Create_WhenItemsAreEnumerable_CopiesItemsIntoReadOnlyList()
+ {
+ var items = Enumerable.Range(1, 2).Where(item => item > 0);
+
+ var result = CursorPaginationResult.Create(items, limit: 10);
+
+ result.Items.Should().Equal(1, 2);
+ result.Items.Should().BeAssignableTo>();
+ result.NextCursor.Should().BeNull();
+ result.PreviousCursor.Should().BeNull();
+ result.HasNextPage.Should().BeFalse();
+ result.HasPreviousPage.Should().BeFalse();
+ }
+
+ [Fact(DisplayName = "Empty returns an empty cursor result")]
+ public void Empty_WhenLimitIsValid_ReturnsEmptyResult()
+ {
+ var result = CursorPaginationResult.Empty(limit: 25);
+
+ result.Items.Should().BeEmpty();
+ result.Limit.Should().Be(25);
+ result.NextCursor.Should().BeNull();
+ result.PreviousCursor.Should().BeNull();
+ result.HasNextPage.Should().BeFalse();
+ result.HasPreviousPage.Should().BeFalse();
+ }
+
+ [Fact(DisplayName = "Create throws when cursor items are null")]
+ public void Create_WhenItemsAreNull_Throws()
+ {
+ Action act = () => CursorPaginationResult.Create(items: null!, limit: 10);
+
+ act.Should()
+ .Throw()
+ .WithParameterName("items");
+ }
+
+ [Fact(DisplayName = "Create throws when cursor limit is not positive")]
+ public void Create_WhenLimitIsNotPositive_Throws()
+ {
+ Action act = () => CursorPaginationResult.Create(items: [], limit: 0);
+
+ act.Should()
+ .Throw()
+ .WithParameterName("limit")
+ .WithMessage("*Лимит должен быть больше 0.*");
+ }
+}
diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Querying/Filtering/FilterParametersTests.cs b/tests/PANiXiDA.Core.Application.UnitTests/Querying/Filtering/FilterParametersTests.cs
new file mode 100644
index 0000000..60ed656
--- /dev/null
+++ b/tests/PANiXiDA.Core.Application.UnitTests/Querying/Filtering/FilterParametersTests.cs
@@ -0,0 +1,29 @@
+using PANiXiDA.Core.Application.Querying.Filtering;
+
+namespace PANiXiDA.Core.Application.UnitTests.Querying.Filtering;
+
+public sealed class FilterParametersTests
+{
+ [Fact(DisplayName = "FilterParameters can be used as a read filter base type")]
+ public void FilterParameters_WhenDerived_CanBeUsedAsBaseType()
+ {
+ FilterParameters parameters = new TestFilterParameters("active");
+
+ parameters.Should().BeOfType();
+ }
+
+ [Fact(DisplayName = "With expression copies filter parameters")]
+ public void WithExpression_WhenFilterValuesAreChanged_CopiesFilterParameters()
+ {
+ var parameters = new TestFilterParameters("active");
+
+ var updated = parameters with
+ {
+ Status = "inactive"
+ };
+
+ updated.Status.Should().Be("inactive");
+ }
+
+ private sealed record TestFilterParameters(string Status) : FilterParameters;
+}
diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Querying/Pagination/PaginationParametersTests.cs b/tests/PANiXiDA.Core.Application.UnitTests/Querying/Pagination/PaginationParametersTests.cs
new file mode 100644
index 0000000..40f7850
--- /dev/null
+++ b/tests/PANiXiDA.Core.Application.UnitTests/Querying/Pagination/PaginationParametersTests.cs
@@ -0,0 +1,45 @@
+using PANiXiDA.Core.Application.Querying.Pagination;
+
+namespace PANiXiDA.Core.Application.UnitTests.Querying.Pagination;
+
+public sealed class PaginationParametersTests
+{
+ [Fact(DisplayName = "Skip and Take use the requested positive page values")]
+ public void SkipAndTake_WhenValuesArePositive_UseRequestedValues()
+ {
+ var parameters = new PaginationParameters(PageNumber: 3, PageSize: 20);
+
+ var skip = parameters.Skip;
+ var take = parameters.Take;
+
+ skip.Should().Be(40);
+ take.Should().Be(20);
+ }
+
+ [Fact(DisplayName = "Skip and Take clamp invalid page values to one")]
+ public void SkipAndTake_WhenValuesAreInvalid_ClampToOne()
+ {
+ var parameters = new PaginationParameters(PageNumber: 0, PageSize: 0);
+
+ var skip = parameters.Skip;
+ var take = parameters.Take;
+
+ skip.Should().Be(0);
+ take.Should().Be(1);
+ }
+
+ [Fact(DisplayName = "With expression copies and updates pagination parameters")]
+ public void WithExpression_WhenValuesAreChanged_CopiesAndUpdatesParameters()
+ {
+ var parameters = new PaginationParameters(PageNumber: 1, PageSize: 10);
+
+ var updated = parameters with
+ {
+ PageNumber = 2,
+ PageSize = 20
+ };
+
+ updated.PageNumber.Should().Be(2);
+ updated.PageSize.Should().Be(20);
+ }
+}
diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Querying/Pagination/PaginationResultTests.cs b/tests/PANiXiDA.Core.Application.UnitTests/Querying/Pagination/PaginationResultTests.cs
new file mode 100644
index 0000000..a5399ea
--- /dev/null
+++ b/tests/PANiXiDA.Core.Application.UnitTests/Querying/Pagination/PaginationResultTests.cs
@@ -0,0 +1,116 @@
+using PANiXiDA.Core.Application.Querying.Pagination;
+
+namespace PANiXiDA.Core.Application.UnitTests.Querying.Pagination;
+
+public sealed class PaginationResultTests
+{
+ [Fact(DisplayName = "Create returns calculated page metadata")]
+ public void Create_WhenParametersAreValid_ReturnsCalculatedMetadata()
+ {
+ IReadOnlyList items = [3, 4];
+
+ var result = PaginationResult.Create(
+ items,
+ pageNumber: 2,
+ pageSize: 2,
+ totalCount: 5);
+
+ result.Items.Should().BeSameAs(items);
+ result.PageNumber.Should().Be(2);
+ result.PageSize.Should().Be(2);
+ result.TotalCount.Should().Be(5);
+ result.TotalPages.Should().Be(3);
+ result.HasPreviousPage.Should().BeTrue();
+ result.HasNextPage.Should().BeTrue();
+ }
+
+ [Fact(DisplayName = "Create copies enumerable items into a read-only list")]
+ public void Create_WhenItemsAreEnumerable_CopiesItemsIntoReadOnlyList()
+ {
+ var items = Enumerable.Range(1, 2).Where(item => item > 0);
+
+ var result = PaginationResult.Create(
+ items,
+ pageNumber: 1,
+ pageSize: 10,
+ totalCount: 2);
+
+ result.Items.Should().Equal(1, 2);
+ result.Items.Should().BeAssignableTo>();
+ result.HasPreviousPage.Should().BeFalse();
+ result.HasNextPage.Should().BeFalse();
+ }
+
+ [Fact(DisplayName = "Empty returns an empty page result")]
+ public void Empty_WhenParametersAreValid_ReturnsEmptyResult()
+ {
+ var result = PaginationResult.Empty(pageNumber: 1, pageSize: 25);
+
+ result.Items.Should().BeEmpty();
+ result.PageNumber.Should().Be(1);
+ result.PageSize.Should().Be(25);
+ result.TotalCount.Should().Be(0);
+ result.TotalPages.Should().Be(0);
+ result.HasPreviousPage.Should().BeFalse();
+ result.HasNextPage.Should().BeFalse();
+ }
+
+ [Fact(DisplayName = "Create throws when items are null")]
+ public void Create_WhenItemsAreNull_Throws()
+ {
+ Action act = () => PaginationResult.Create(
+ items: null!,
+ pageNumber: 1,
+ pageSize: 10,
+ totalCount: 0);
+
+ act.Should()
+ .Throw()
+ .WithParameterName("items");
+ }
+
+ [Fact(DisplayName = "Create throws when page number is not positive")]
+ public void Create_WhenPageNumberIsNotPositive_Throws()
+ {
+ Action act = () => PaginationResult.Create(
+ items: [],
+ pageNumber: 0,
+ pageSize: 10,
+ totalCount: 0);
+
+ act.Should()
+ .Throw()
+ .WithParameterName("pageNumber")
+ .WithMessage("*Номер страницы должен быть больше 0.*");
+ }
+
+ [Fact(DisplayName = "Create throws when page size is not positive")]
+ public void Create_WhenPageSizeIsNotPositive_Throws()
+ {
+ Action act = () => PaginationResult.Create(
+ items: [],
+ pageNumber: 1,
+ pageSize: 0,
+ totalCount: 0);
+
+ act.Should()
+ .Throw()
+ .WithParameterName("pageSize")
+ .WithMessage("*Размер страницы должен быть больше 0.*");
+ }
+
+ [Fact(DisplayName = "Create throws when total count is negative")]
+ public void Create_WhenTotalCountIsNegative_Throws()
+ {
+ Action act = () => PaginationResult.Create(
+ items: [],
+ pageNumber: 1,
+ pageSize: 10,
+ totalCount: -1);
+
+ act.Should()
+ .Throw()
+ .WithParameterName("totalCount")
+ .WithMessage("*Общее количество элементов не может быть отрицательным.*");
+ }
+}
diff --git a/tests/PANiXiDA.Core.Application.UnitTests/Querying/Sorting/SortParametersTests.cs b/tests/PANiXiDA.Core.Application.UnitTests/Querying/Sorting/SortParametersTests.cs
new file mode 100644
index 0000000..debfb94
--- /dev/null
+++ b/tests/PANiXiDA.Core.Application.UnitTests/Querying/Sorting/SortParametersTests.cs
@@ -0,0 +1,55 @@
+using PANiXiDA.Core.Application.Querying.Sorting;
+using System.ComponentModel.DataAnnotations;
+using System.Reflection;
+
+namespace PANiXiDA.Core.Application.UnitTests.Querying.Sorting;
+
+public sealed class SortParametersTests
+{
+ [Fact(DisplayName = "Default returns ascending sorting without a field")]
+ public void Default_WhenCalled_ReturnsAscendingSortingWithoutField()
+ {
+ var parameters = SortParameters.Default();
+
+ parameters.Field.Should().BeNull();
+ parameters.Order.Should().Be(SortOrder.Ascending);
+ }
+
+ [Fact(DisplayName = "Constructor stores sorting values")]
+ public void Constructor_WhenValuesAreProvided_StoresValues()
+ {
+ var parameters = new SortParameters(Field: "name", Order: SortOrder.Descending);
+
+ parameters.Field.Should().Be("name");
+ parameters.Order.Should().Be(SortOrder.Descending);
+ }
+
+ [Fact(DisplayName = "With expression copies and updates sorting parameters")]
+ public void WithExpression_WhenSortValuesAreChanged_CopiesAndUpdatesParameters()
+ {
+ var parameters = new SortParameters(Field: "name", Order: SortOrder.Ascending);
+
+ var updated = parameters with
+ {
+ Field = "createdAt",
+ Order = SortOrder.Descending
+ };
+
+ updated.Field.Should().Be("createdAt");
+ updated.Order.Should().Be(SortOrder.Descending);
+ }
+
+ [Theory(DisplayName = "SortOrder has localized display names")]
+ [InlineData(SortOrder.Ascending, "По возрастанию")]
+ [InlineData(SortOrder.Descending, "По убыванию")]
+ public void DisplayName_WhenSortOrderIsProvided_ReturnsLocalizedName(
+ SortOrder sortOrder,
+ string expectedDisplayName)
+ {
+ var member = typeof(SortOrder).GetMember(sortOrder.ToString()).Single();
+ var displayAttribute = member.GetCustomAttribute();
+
+ displayAttribute.Should().NotBeNull();
+ displayAttribute!.Name.Should().Be(expectedDisplayName);
+ }
+}
diff --git a/version.json b/version.json
index 9ed2b25..15b5840 100644
--- a/version.json
+++ b/version.json
@@ -11,7 +11,7 @@
}
},
"pathFilters": [
- ":/src/PANiXiDA.Core.Template/",
+ ":/src/PANiXiDA.Core.Application/",
":/Directory.Build.props",
":/Directory.Build.targets",
":/Directory.Packages.props",