Odex.AspNetCore.Clarc.Application
0.2.0
dotnet add package Odex.AspNetCore.Clarc.Application --version 0.2.0
NuGet\Install-Package Odex.AspNetCore.Clarc.Application -Version 0.2.0
<PackageReference Include="Odex.AspNetCore.Clarc.Application" Version="0.2.0" />
<PackageVersion Include="Odex.AspNetCore.Clarc.Application" Version="0.2.0" />
<PackageReference Include="Odex.AspNetCore.Clarc.Application" />
paket add Odex.AspNetCore.Clarc.Application --version 0.2.0
#r "nuget: Odex.AspNetCore.Clarc.Application, 0.2.0"
#:package Odex.AspNetCore.Clarc.Application@0.2.0
#addin nuget:?package=Odex.AspNetCore.Clarc.Application&version=0.2.0
#tool nuget:?package=Odex.AspNetCore.Clarc.Application&version=0.2.0
Odex.AspNetCore.Clarc.Application
Application layer for CQRSβbased ASP.NET Core applications
Provides MediatR pipelines, CQRS abstractions, FluentValidation integration, and typed application exceptions.
π¦ Overview
Odex.AspNetCore.Clarc.Application is the application layer component of the Clarc framework. It bridges the domain and
infrastructure layers by implementing CQRS (Command Query Responsibility Segregation) patterns using MediatR and
FluentValidation. It provides:
- CQRS Abstractions β Base records for commands, queries, and responses.
- Validation Pipeline β Automatic validation of requests using FluentValidation.
- Paged Requests & Responses β Reusable pagination and sorting models.
- Application Exceptions β Typed exceptions for validation, duplicates, service failures, authorization, etc.
- DI Extensions β Oneβline registration of MediatR, validators, and pipeline behaviors.
π Get Started
Prerequisites
- .NET 9.0 SDK or later
- An ASP.NET Core project
- Odex.AspNetCore.Clarc.Domain in your solution when handlers use aggregates, repositories, or domain value objects (this application package does not reference Domain).
Installation
dotnet add package Odex.AspNetCore.Clarc.Application
Or using the Package Manager Console:
Install-Package Odex.AspNetCore.Clarc.Application
Basic Setup
1. Register the Application Layer in Program.cs
using Odex.AspNetCore.Clarc.Application;
var builder = WebApplication.CreateBuilder(args);
// Register MediatR, validators, and validation pipeline
builder.Services.AddClarcApplication<Program>(); // T is any type from your assembly
// ... rest of your configuration
2. Create a Simple Query and Handler
using MediatR;
using Odex.AspNetCore.Clarc.Application.CQRS;
public record GetUserQuery(int Id) : BaseQuery<UserResponse>;
public class GetUserQueryHandler : IRequestHandler<GetUserQuery, UserResponse>
{
public async Task<UserResponse> Handle(GetUserQuery request, CancellationToken cancellationToken)
{
// Fetch user from repository
var user = await _userRepository.GetByIdAsync(request.Id, cancellationToken);
return new UserResponse { Id = user.Id, Name = user.Name };
}
}
3. Create a Validator for the Query
using FluentValidation;
using Odex.AspNetCore.Clarc.Application.Validators;
public class GetUserQueryValidator : BaseValidator<GetUserQuery>
{
public GetUserQueryValidator()
{
RuleFor(x => x.Id).GreaterThan(0).WithMessage("User ID must be positive");
}
}
4. Send the Query from a Controller
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly IMediator _mediator;
public UsersController(IMediator mediator) => _mediator = mediator;
[HttpGet("{id}")]
public async Task<IActionResult> GetUser(int id)
{
var result = await _mediator.Send(new GetUserQuery(id));
return Ok(result);
}
}
Minimal API Example
app.MapGet("/users/{id}", async (IMediator mediator, int id) =>
{
var user = await mediator.Send(new GetUserQuery(id));
return Results.Ok(user);
});
Advanced registration
Use ClarcApplicationBuilder when you scan multiple assemblies, customize MediatR, toggle pipeline behaviors, or set validator lifetime:
using Odex.AspNetCore.Clarc.Application;
using Odex.AspNetCore.Clarc.Application.Configuration;
builder.Services.AddClarcApplication(b =>
{
b.AddAssemblyContaining<Program>()
.AddAssembly(typeof(SomeOtherHandlers).Assembly)
.ConfigureMediatR(cfg => { /* optional */ })
.UseValidatorLifetime(ServiceLifetime.Scoped)
.ConfigureValidation(v => { v.RuleSet = "Write"; v.LogFailures = true; })
.EnableMetricsPipeline(true);
});
Pipeline order (outer β inner): unhandled-exception logging, request logging, metrics, FluentValidation. AddClarcApplication calls AddLogging() so ILogger<> resolves.
Security note
Pipeline loggers never write request payloads. Treat application DTOs as untrusted until validated; keep domain invariants inside the Domain model.
β¨ Features
| Feature | Description |
|---|---|
| π§© CQRS Base Records | BaseCommand / BaseCommand<TResponse>, BaseQuery<TResponse>, ICommand / IQuery, BaseResponse with timestamps. |
| π Paged Support | PagedQuery<TResponse> (includes SkipCount) / PagedResponse<T> with IReadOnlyList<T> items; PagedValidator<T> extends BaseValidator<PagedQuery<T>> for untrusted input. |
| β Validation Pipeline | ValidationPipelineBehavior with rule sets, parallel/sequential validators, ISkipValidation opt-out, structured ValidationFailureDetail. |
| β οΈ Application Exceptions | Typed exceptions including NotFoundException, ConflictException, GoneException, ResourceTimeoutException, TooManyRequestsException, plus ErrorCode on ApplicationException. |
| π Observability | Optional LoggingPipelineBehavior, RequestMetricsPipelineBehavior (System.Diagnostics.Metrics), UnhandledExceptionLoggingPipelineBehavior. |
| π Registration | AddClarcApplication<T>() or AddClarcApplication(Action<ClarcApplicationBuilder>). |
| π§Ύ Result model | Result<T> / Error for APIs that prefer non-exception flows. |
ποΈ Core Components
1. CQRS Abstractions
BaseCommand<TResponse>
using MediatR;
namespace Odex.AspNetCore.Clarc.Application.CQRS;
public abstract record BaseCommand<TResponse> : IRequest<TResponse>, ICommand
{
public DateTime ExecutedAt { get; init; } = DateTime.UtcNow;
}
BaseQuery<TResponse>
using MediatR;
namespace Odex.AspNetCore.Clarc.Application.CQRS;
public abstract record BaseQuery<TResponse> : IRequest<TResponse>, IQuery
{
public DateTime RequestedAt { get; init; } = DateTime.UtcNow;
}
BaseResponse
namespace Odex.AspNetCore.Clarc.Application.CQRS;
public abstract record BaseResponse
{
public DateTime GeneratedAt { get; init; } = DateTime.UtcNow;
}
PagedQuery<TResponse>
namespace Odex.AspNetCore.Clarc.Application.CQRS;
public abstract record PagedQuery<TResponse> : BaseQuery<TResponse>
{
#region Pagination
public int Page { get; init; } = 1;
public int PageSize { get; init; } = 20;
public int SkipCount => (Page - 1) * PageSize;
#endregion
#region Sorting
public string? SortBy { get; init; }
public bool SortDescending { get; init; } = false;
#endregion
}
PagedResponse<T>
namespace Odex.AspNetCore.Clarc.Application.CQRS;
public abstract record PagedResponse<T> : BaseResponse
{
public required IReadOnlyList<T> Items { get; init; }
public int TotalCount { get; init; }
public int Page { get; init; }
public int PageSize { get; init; }
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
public bool HasPreviousPage => Page > 1;
public bool HasNextPage => Page < TotalPages;
}
2. Validation Pipeline
ValidationPipelineBehavior<TRequest, TResponse>
using FluentValidation;
using MediatR;
namespace Odex.AspNetCore.Clarc.Application.Behaviors;
public class ValidationPipelineBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
if (!validators.Any()) return await next(cancellationToken);
var context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll(
validators.Select(v => v.ValidateAsync(context, cancellationToken))
);
var failures = validationResults
.SelectMany(result => result.Errors)
.Where(f => f != null)
.ToList();
if (failures.Count != 0)
{
var errors = failures
.GroupBy(f => string.IsNullOrEmpty(f.PropertyName) ? string.Empty : f.PropertyName)
.ToDictionary(
g => string.IsNullOrEmpty(g.Key) ? "_" : g.Key,
g => g.Select(e => e.ErrorMessage).ToArray());
throw new Odex.AspNetCore.Clarc.Application.Exceptions.ValidationException(errors);
}
return await next(cancellationToken);
}
}
3. Application Exceptions
Exception Types Enum:
namespace Odex.AspNetCore.Clarc.Application.Enums;
public enum ExceptionType
{
Unknown,
Duplicate,
ServiceFailed,
ServiceUnavailable,
OperationDenied,
UnallowedOperation,
AccessDenied,
ValidationFailed,
NotFound,
Conflict,
Gone,
Timeout,
TooManyRequests
}
Base Exception:
using Odex.AspNetCore.Clarc.Application.Enums;
namespace Odex.AspNetCore.Clarc.Application.Exceptions;
public abstract class ApplicationException : Exception
{
public ExceptionType Type { get; }
public string? ErrorCode { get; }
protected ApplicationException(string message, ExceptionType type, Exception? innerException = null, string? errorCode = null)
: base(message, innerException) { Type = type; ErrorCode = errorCode; }
}
Concrete Exceptions:
| Exception | Code |
|---|---|
DuplicateResourceException |
public class DuplicateResourceException(string resource, string identifier) : ApplicationException($"{resource} with identifier '{identifier}' already exists", ExceptionType.Duplicate); |
OperationDeniedException |
public class OperationDeniedException(string operation, string reason = "No reason provided") : ApplicationException($"Operation '{operation}' denied due to security reasons: {reason}", ExceptionType.OperationDenied); |
OperationNotAllowedException |
public class OperationNotAllowedException(string operation, string reason = "No reason provided") : ApplicationException($"Operation '{operation}' is not allowed: {reason}", ExceptionType.UnallowedOperation); |
ServiceInternalException |
ServiceInternalException β service failure with ErrorCode service_failed. |
ServiceUnavailableException |
ServiceUnavailableException β temporary unavailability with ErrorCode service_unavailable. |
UnauthorizedAccessException |
UnauthorizedAccessException β access denied with ErrorCode access_denied. |
ValidationException |
Carries Errors and structured FailureDetails; use ValidationException.FromFailures from custom pipelines if needed. |
NotFoundException |
NotFoundException with ErrorCode not_found. |
ConflictException |
ConflictException with ErrorCode conflict. |
TooManyRequestsException |
TooManyRequestsException with ErrorCode too_many_requests. |
4. Validators
BaseValidator<T>
Thin base type for application validators (inherits FluentValidationβs AbstractValidator<T>). Use it for commands, queries, and custom rules; subclass it the same way for paging.
using FluentValidation;
namespace Odex.AspNetCore.Clarc.Application.Validators;
public class BaseValidator<T> : AbstractValidator<T>;
PagedValidator<T>
Built-in paging rules for PagedQuery<TResponse> (page β₯ 1, page size 1β512). Inherits BaseValidator<PagedQuery<T>> so it follows the same validator pattern as your other application validators.
using Odex.AspNetCore.Clarc.Application.CQRS;
namespace Odex.AspNetCore.Clarc.Application.Validators;
public class PagedValidator<T> : BaseValidator<PagedQuery<T>>
{
public PagedValidator()
{
RuleFor(x => x.Page).GreaterThanOrEqualTo(1);
RuleFor(x => x.PageSize).InclusiveBetween(1, 512);
}
}
5. Service registration
The library registers MediatR, FluentValidation, Microsoft.Extensions.Logging, IOptions<T> for pipeline settings, and (by default) four IPipelineBehavior<,> implementations.
Use AddClarcApplication<T>() for single-assembly apps, or AddClarcApplication(Action<ClarcApplicationBuilder>) for full control (see Advanced registration above). See source for ServiceCollectionExtensions.
π Usage Examples
Creating a Command
public record CreateUserCommand(string Name, string Email) : BaseCommand<UserResponse>;
Creating a Query Handler
public class GetUserQueryHandler : IRequestHandler<GetUserQuery, UserResponse>
{
private readonly IUserRepository _userRepository;
public GetUserQueryHandler(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public async Task<UserResponse> Handle(GetUserQuery request, CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.Id, cancellationToken);
if (user is null)
throw new NotFoundException(nameof(User), request.Id);
return new UserResponse(user.Id, user.Name, user.Email);
}
}
Creating a Validator
public class CreateUserCommandValidator : BaseValidator<CreateUserCommand>
{
public CreateUserCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
RuleFor(x => x.Email).NotEmpty().EmailAddress();
}
}
Using Paged Query
Register PagedValidator<TResponse> (or subclass it from BaseValidator<PagedQuery<TResponse>>) when the query is bound from HTTP. Default rules: page β₯ 1, page size 1β512.
public record GetUsersPagedQuery : PagedQuery<PagedResponse<UserResponse>>;
// Optional: add query-specific rules on top of paging (T matches PagedQuery<TResponse>'s TResponse)
public class GetUsersPagedQueryValidator : PagedValidator<PagedResponse<UserResponse>>
{
public GetUsersPagedQueryValidator()
{
RuleFor(x => x.SortBy).MaximumLength(64).When(x => x.SortBy is not null);
}
}
public class GetUsersPagedQueryHandler : IRequestHandler<GetUsersPagedQuery, PagedResponse<UserResponse>>
{
public async Task<PagedResponse<UserResponse>> Handle(GetUsersPagedQuery request, CancellationToken ct)
{
var users = await _userRepository.GetPagedAsync(request.Page, request.PageSize, ct);
var total = await _userRepository.CountAsync(ct);
return new PagedResponse<UserResponse>
{
Items = users.Select(u => new UserResponse(u.Id, u.Name)).ToList(),
TotalCount = total,
Page = request.Page,
PageSize = request.PageSize
};
}
}
Throwing Application Exceptions
if (await _userRepository.EmailExistsAsync(command.Email))
throw new DuplicateResourceException("User", command.Email);
if (!_authorizationService.CanDelete(userId, currentUserId))
throw new UnauthorizedAccessException("DeleteUser", "Insufficient permissions");
try
{
await _emailService.SendAsync(user.Email);
}
catch (HttpRequestException)
{
throw new ServiceUnavailableException("EmailService");
}
π Namespace Map
| Namespace | Purpose |
|---|---|
Odex.AspNetCore.Clarc.Application.Behaviors |
Pipeline behaviors (validation, logging, metrics, unhandled exception logging) |
Odex.AspNetCore.Clarc.Application.Configuration |
ClarcApplicationBuilder, ClarcApplicationDescriptor, options types |
Odex.AspNetCore.Clarc.Application.CQRS |
Base command/query/response records, markers (ICommand, IQuery, ISkipValidation) |
Odex.AspNetCore.Clarc.Application.Enums |
ExceptionType enum |
Odex.AspNetCore.Clarc.Application.Exceptions |
Application-specific exceptions |
Odex.AspNetCore.Clarc.Application.Results |
Result<T>, Error |
Odex.AspNetCore.Clarc.Application.Validators |
BaseValidator<T>, PagedValidator<T>, CommonValidationExtensions |
Odex.AspNetCore.Clarc.Application |
ServiceCollectionExtensions |
π Related Packages
- Odex.AspNetCore.Clarc.Domain β Domain layer (add in your host when handlers use domain types).
- Odex.AspNetCore.Clarc.Infrastructure β Query builders, pagination extensions, and infrastructure exceptions (optional in your host).
π€ Contributing
Contributions are welcome! Please follow the standard GitHub flow:
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Commit changes (
git commit -m 'Add amazing feature') - Push to branch (
git push origin feature/amazing-feature) - Open a Pull Request
See CONTRIBUTING.md for maintainer expectations.
π License
This project is licensed under the MIT License β see the LICENSE file for details.
Built with β€οΈ for clean CQRS and validation on ASP.NET Core
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net9.0
- FluentValidation (>= 12.1.1)
- FluentValidation.DependencyInjectionExtensions (>= 12.1.1)
- MediatR (>= 13.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.16)
- Microsoft.Extensions.Logging (>= 9.0.0)
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.0)
- Microsoft.Extensions.Options (>= 9.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.