Neelith.Hermes
2.0.0
dotnet add package Neelith.Hermes --version 2.0.0
NuGet\Install-Package Neelith.Hermes -Version 2.0.0
<PackageReference Include="Neelith.Hermes" Version="2.0.0" />
<PackageVersion Include="Neelith.Hermes" Version="2.0.0" />
<PackageReference Include="Neelith.Hermes" />
paket add Neelith.Hermes --version 2.0.0
#r "nuget: Neelith.Hermes, 2.0.0"
#:package Neelith.Hermes@2.0.0
#addin nuget:?package=Neelith.Hermes&version=2.0.0
#tool nuget:?package=Neelith.Hermes&version=2.0.0
Hermes
The messenger of the gods brings consistency and structure to your API.
Overview
Hermes is a comprehensive .NET library that combines three powerful patterns for building maintainable REST APIs:
- CQRS Pattern - Separate read and write operations with command and query handlers
- Response Envelope Pattern - Wrap responses in consistent, well-defined structures
- Result Pattern - Explicit success/failure handling without exceptions
Why Hermes?
The Problems
Modern APIs face several interconnected challenges:
Architectural Complexity
- Business logic scattered across controllers
- Difficult to apply cross-cutting concerns (logging, validation, caching)
- Tight coupling between endpoints and business operations
- Hard to test and maintain as complexity grows
Inconsistent Response Formats
- Different endpoints return data in different structures
- Missing metadata like pagination info, resource types
- Clients must handle multiple response patterns
- Difficult to maintain consistent contracts
Error Handling Chaos
- Exception-based error handling is unpredictable
- Hard to represent multiple validation errors
- Business failures mixed with technical exceptions
- Inconsistent error responses across endpoints
The Benefits
Hermes solves these challenges by providing:
✅ Architectural Clarity - Clear separation between reads (queries) and writes (commands)
✅ Testability - Each handler is an isolated, testable unit of work
✅ Consistency - All responses follow the same { data, attributes } structure
✅ Type Safety - Full compile-time checking for requests, responses, and errors
✅ Explicit Error Handling - No exceptions for business logic failures
✅ Composability - Chain operations with Map, OnSuccess, and OnFailure
✅ Extensibility - Apply cross-cutting concerns via decorators
✅ Scalability - Optimize read and write paths independently
✅ Client Simplicity - Predictable API contracts make integration trivial
Installation
dotnet add package Neelith.Hermes
Requirements:
- .NET 10.0 or later
- Microsoft.Extensions.DependencyInjection (included in ASP.NET Core)
Dependencies:
- Scrutor (for assembly scanning)
Complete Example
Here's a complete example showing all patterns working together:
// 1. Define your DTOs and requests
public record UserDto(int Id, string Name, string Email);
public record CreateUserRequest(string Name, string Email);
// 2. Define your commands and queries
public record GetUserQuery(int UserId) : IQuery<Response<UserDto>>;
public record CreateUserCommand(string Name, string Email) : ICommand<IdResponse<int>>;
// 3. Implement handlers
public class GetUserQueryHandler : IQueryHandler<GetUserQuery, Response<UserDto>>
{
public async Task<Result<Response<UserDto>>> Handle(
GetUserQuery query,
CancellationToken cancellationToken)
{
var user = await _repository.GetByIdAsync(query.UserId, cancellationToken);
if (user == null)
return Result.Ko<Response<UserDto>>("USER_NOT_FOUND", "User not found");
var dto = new UserDto(user.Id, user.Name, user.Email);
var response = Response<UserDto>.Create(dto);
return Result.Ok(response);
}
}
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, IdResponse<int>>
{
public async Task<Result<IdResponse<int>>> Handle(
CreateUserCommand command,
CancellationToken cancellationToken)
{
// Validation
if (string.IsNullOrWhiteSpace(command.Name))
return Result.Ko<IdResponse<int>>("INVALID_NAME", "Name is required");
if (string.IsNullOrWhiteSpace(command.Email))
return Result.Ko<IdResponse<int>>("INVALID_EMAIL", "Email is required");
// Business logic
if (await _repository.EmailExistsAsync(command.Email, cancellationToken))
return Result.Ko<IdResponse<int>>("DUPLICATE_EMAIL", "Email already exists");
var user = await _repository.CreateAsync(command.Name, command.Email, cancellationToken);
var response = IdResponse<int>.Create(user.Id);
return Result.Ok(response);
}
}
// 4. Register handlers in Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHandlers([typeof(Program).Assembly]);
var app = builder.Build();
// 5. Create endpoints
app.MapGet("/users/{id}", async (
int id,
IQueryHandler<GetUserQuery, Response<UserDto>> handler,
CancellationToken ct) =>
{
var result = await handler.Handle(new GetUserQuery(id), ct);
return result.Match(
onSuccess: response => Results.Ok(response),
onFailure: errors => Results.NotFound(new { errors })
);
});
app.MapPost("/users", async (
CreateUserRequest request,
ICommandHandler<CreateUserCommand, IdResponse<int>> handler,
CancellationToken ct) =>
{
var command = new CreateUserCommand(request.Name, request.Email);
var result = await handler.Handle(command, ct);
return result.Match(
onSuccess: response => Results.Created($"/users/{response.Data.Id}", response),
onFailure: errors => Results.BadRequest(new { errors })
);
});
app.Run();
Query Response:
{
"data": {
"id": 1,
"name": "John Doe",
"email": "john@example.com"
},
"attributes": {}
}
Command Response:
{
"data": {
"id": 42
},
"attributes": {
"type": "Int32"
}
}
Error Response:
{
"errors": [
{
"code": "INVALID_EMAIL",
"message": "Email is required"
}
]
}
CQRS & Handlers
Command Query Responsibility Segregation (CQRS) separates read operations (queries) from write operations (commands), allowing you to optimize and scale each path independently.
Commands vs Queries
Queries - Read operations that don't modify state:
- Implement
IQuery<TResponse> - Handled by
IQueryHandler<TQuery, TResponse> - Should be side-effect free
- Can be cached aggressively
Commands - Write operations that modify state:
- Implement
ICommandorICommand<TResponse> - Handled by
ICommandHandler<TCommand>orICommandHandler<TCommand, TResponse> - Cause side effects (create, update, delete)
- Should validate before executing
Defining Requests
// Query - returns data
public record GetUserQuery(int UserId) : IQuery<Response<UserDto>>;
public record GetUsersQuery(int Page, int PageSize) : IQuery<PagedResponse<UserDto>>;
// Command without response - fire and forget
public record DeleteUserCommand(int UserId) : ICommand;
// Command with response - returns created resource ID
public record CreateUserCommand(string Name, string Email) : ICommand<IdResponse<int>>;
Implementing Handlers
// Query handler
public class GetUsersQueryHandler : IQueryHandler<GetUsersQuery, PagedResponse<UserDto>>
{
public async Task<Result<PagedResponse<UserDto>>> Handle(
GetUsersQuery query,
CancellationToken cancellationToken)
{
var users = await _repository.GetPageAsync(query.Page, query.PageSize, cancellationToken);
var totalCount = await _repository.GetTotalCountAsync(cancellationToken);
var response = PagedResponse<UserDto>.Create(
users.Select(u => new UserDto(u.Id, u.Name, u.Email)),
totalCount,
new Dictionary<string, string?>
{
{ "page", query.Page.ToString() },
{ "pageSize", query.PageSize.ToString() }
});
return Result.Ok(response);
}
}
// Command handler (no response)
public class DeleteUserCommandHandler : ICommandHandler<DeleteUserCommand>
{
public async Task<Result> Handle(
DeleteUserCommand command,
CancellationToken cancellationToken)
{
var user = await _repository.GetByIdAsync(command.UserId, cancellationToken);
if (user == null)
return Result.Ko("USER_NOT_FOUND", "User not found");
await _repository.DeleteAsync(user, cancellationToken);
return Result.Ok();
}
}
Handler Registration
Register all handlers automatically by scanning assemblies:
// Register handlers from one or more assemblies
builder.Services.AddHandlers([
typeof(Program).Assembly,
typeof(GetUserQueryHandler).Assembly
]);
The AddHandlers method:
- Scans for all
IQueryHandler<,>implementations - Scans for all
ICommandHandler<>andICommandHandler<,>implementations - Registers them with Scoped lifetime
- Supports both public and internal handlers
Decorators
Apply cross-cutting concerns to all handlers using the decorator pattern:
// Logging decorator
public class LoggingQueryHandlerDecorator<TQuery, TResponse>
: IQueryHandler<TQuery, TResponse>
where TQuery : IQuery<TResponse>
where TResponse : IResponse
{
public async Task<Result<TResponse>> Handle(TQuery query, CancellationToken cancellationToken)
{
_logger.LogInformation("Executing query {QueryType}", typeof(TQuery).Name);
var stopwatch = Stopwatch.StartNew();
var result = await _inner.Handle(query, cancellationToken);
stopwatch.Stop();
_logger.LogInformation("Query {QueryType} completed in {ElapsedMs}ms",
typeof(TQuery).Name, stopwatch.ElapsedMilliseconds);
return result;
}
}
Common decorator use cases:
- Logging and metrics
- Validation
- Caching
- Transaction management
- Authorization
- Retry logic
- Performance monitoring
Decorator execution order (reverse of registration):
services.AddHandlerDecorator(typeof(ICommandHandler<,>), typeof(LoggingDecorator<,>)); // 3rd (outermost)
services.AddHandlerDecorator(typeof(ICommandHandler<,>), typeof(ValidationDecorator<,>)); // 2nd
services.AddHandlerDecorator(typeof(ICommandHandler<,>), typeof(TransactionDecorator<,>)); // 1st (innermost)
// Execution: Transaction -> Validation -> Logging -> Handler
Result Pattern
The Result pattern provides functional error handling, allowing operations to return explicit success or failure without throwing exceptions.
Creating Results
// Success
Result<User> result = Result.Ok(user);
Result result = Result.Ok();
// Single error
Result<User> result = Result.Ko<User>("USER_NOT_FOUND", "User not found");
Pattern Matching
Handle success and failure cases explicitly:
var result = await handler.Handle(query, cancellationToken);
// Match with return value
return result.Match(
onSuccess: data => Results.Ok(data),
onFailure: errors => Results.BadRequest(new { errors })
);
// Match with actions
result.Match(
onSuccess: data => _logger.LogInformation("Success: {Data}", data),
onFailure: errors => _logger.LogError("Failed: {Errors}", errors)
);
Functional Composition
Chain operations on results:
var result = await GetUser(id)
.Map(user => new UserDto(user)) // Transform success value
.OnSuccess(dto => _logger.LogInformation("Retrieved {Name}", dto.Name)) // Execute on success
.OnFailure(errors => _metrics.IncrementFailureCount()); // Execute on failure
return result.Match(
onSuccess: dto => Results.Ok(Response<UserDto>.Create(dto)),
onFailure: errors => Results.NotFound(new { errors })
);
Error Types
// Default error
var error = new Error(
Code: "VALIDATION_FAILED",
Message: "The request validation failed",
Metadata: new Dictionary<string, string?>
{
{ "field", "email" },
{ "constraint", "format" }
}
);
// Custom error type
public record ValidationError(
string Code,
string Message,
string Field,
string Constraint
) : IError
{
public Dictionary<string, string?>? Metadata => new()
{
{ "field", Field },
{ "constraint", Constraint }
};
}
Validation Example
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, IdResponse<int>>
{
public async Task<Result<IdResponse<int>>> Handle(
CreateUserCommand command,
CancellationToken cancellationToken)
{
var errors = new List<IError>();
if (string.IsNullOrWhiteSpace(command.Name))
errors.Add(new Error("INVALID_NAME", "Name is required"));
if (string.IsNullOrWhiteSpace(command.Email))
errors.Add(new Error("INVALID_EMAIL", "Email is required"));
else if (!IsValidEmail(command.Email))
errors.Add(new Error("INVALID_EMAIL_FORMAT", "Email format is invalid"));
if (await _repository.EmailExistsAsync(command.Email, cancellationToken))
errors.Add(new Error("DUPLICATE_EMAIL", "Email already exists"));
if (errors.Any())
return Result.Ko<IdResponse<int>>(errors);
var user = await _repository.CreateAsync(command, cancellationToken);
return Result.Ok(IdResponse<int>.Create(user.Id));
}
}
Response Envelope
The Response Envelope pattern wraps all responses in a consistent { data, attributes } structure, making API contracts predictable and extensible.
Response Types
Response<T> - Generic wrapper for any data:
var user = new UserDto(1, "John Doe", "john@example.com");
var response = Response<UserDto>.Create(user, new Dictionary<string, string?>
{
{ "cached", "true" },
{ "version", "2.0" }
});
{
"data": {
"id": 1,
"name": "John Doe",
"email": "john@example.com"
},
"attributes": {
"cached": "true",
"version": "2.0"
}
}
IdResponse<T> - For returning IDs after creation:
var response = IdResponse<int>.Create(userId);
{
"data": {
"id": 42
},
"attributes": {
"type": "Int32"
}
}
PagedResponse<T> - For paginated collections:
var users = await _repository.GetPageAsync(page, pageSize, ct);
var totalCount = await _repository.GetTotalCountAsync(ct);
var response = PagedResponse<UserDto>.Create(
users,
totalCount,
new Dictionary<string, string?>
{
{ "page", page.ToString() },
{ "pageSize", pageSize.ToString() }
}
);
{
"data": {
"items": [
{ "id": 1, "name": "Alice" },
{ "id": 2, "name": "Bob" }
]
},
"attributes": {
"totalCount": "150",
"page": "2",
"pageSize": "20"
}
}
Custom Attributes
Add metadata to any response:
var attributes = new Dictionary<string, string?>
{
{ "requestId", context.TraceIdentifier },
{ "cached", "true" },
{ "expiresAt", DateTime.UtcNow.AddMinutes(5).ToString("O") }
};
var response = Response<UserDto>.Create(user, attributes);
Predefined Attribute Constants
using Hermes.Responses;
var attributes = new Dictionary<string, string?>
{
{ ResponseAttributes.TotalCount, totalCount.ToString() },
{ ResponseAttributes.Type, typeof(User).Name }
};
Advanced Scenarios
Validation Decorator
Create reusable validation logic:
public class ValidationCommandHandlerDecorator<TCommand, TResponse>
: ICommandHandler<TCommand, TResponse>
where TCommand : ICommand<TResponse>
where TResponse : IResponse
{
public async Task<Result<TResponse>> Handle(
TCommand command,
CancellationToken cancellationToken)
{
var validationResult = await _validator.ValidateAsync(command, cancellationToken);
if (!validationResult.IsValid)
{
var errors = validationResult.Errors
.Select(e => new Error(e.ErrorCode, e.ErrorMessage))
.ToList();
return Result.Ko<TResponse>(errors);
}
return await _inner.Handle(command, cancellationToken);
}
}
Caching Decorator
Cache query results automatically:
public class CachingQueryHandlerDecorator<TQuery, TResponse>
: IQueryHandler<TQuery, TResponse>
where TQuery : IQuery<TResponse>
where TResponse : IResponse
{
public async Task<Result<TResponse>> Handle(
TQuery query,
CancellationToken cancellationToken)
{
var cacheKey = $"{typeof(TQuery).Name}:{JsonSerializer.Serialize(query)}";
if (_cache.TryGetValue<Result<TResponse>>(cacheKey, out var cachedResult))
return cachedResult;
var result = await _inner.Handle(query, cancellationToken);
if (result.IsSuccess)
_cache.Set(cacheKey, result, TimeSpan.FromMinutes(5));
return result;
}
}
Transaction Decorator
Wrap commands in database transactions:
public class TransactionCommandHandlerDecorator<TCommand, TResponse>
: ICommandHandler<TCommand, TResponse>
where TCommand : ICommand<TResponse>
where TResponse : IResponse
{
public async Task<Result<TResponse>> Handle(
TCommand command,
CancellationToken cancellationToken)
{
await using var transaction = await _dbContext.BeginTransactionAsync(cancellationToken);
try
{
var result = await _inner.Handle(command, cancellationToken);
if (result.IsSuccess)
await transaction.CommitAsync(cancellationToken);
else
await transaction.RollbackAsync(cancellationToken);
return result;
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
}
Best Practices
Handler Design
- Single Responsibility - One handler per operation
- Validate Early - Return errors before side effects
- Async All The Way - Use async/await with cancellation tokens
- Descriptive Errors - Include error codes and meaningful messages
- Collect Errors - Return all validation errors at once
Response Design
- Consistent Structure - Always use Response<T> wrappers
- Meaningful Attributes - Add metadata that helps clients
- Appropriate Types - Use
IdResponse<T>for IDs,PagedResponse<T>for lists or create custom wrappers as needed - Include Type Info - Especially useful for polymorphic responses
Error Handling
- Error Codes - Machine-readable error identification
- Clear Messages - Human-readable error descriptions
- Metadata - Additional context (field names, constraints)
- Multiple Errors - Return all validation errors together
- No Exceptions - Use
Result<T>for business logic failures
Decorator Order
Order decorators from innermost (closest to handler) to outermost:
- Handler - Core business logic
- Logging - Log execution details
- Validation - Validate before executing
- Transaction - Wrap in database transaction
- Caching - Cache results (queries only)
- Authorization - Check permissions
Why "Hermes"?
In Greek mythology, Hermes was the messenger of the gods, known for delivering messages reliably and consistently across boundaries. Similarly, this library:
- Delivers your business logic reliably through handlers
- Bridges the gap between clients and services with consistent responses
- Guides operations through success and failure paths with the Result pattern
License
This project is licensed under the terms specified in the LICENSE file.
Contributing
Contributions are welcome! Please feel free to submit issues or pull requests.
Support
For issues, questions, or contributions, visit the GitHub repository.
Author
Neelith
Structure your API with divine consistency. ⚡
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. 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. |
-
net10.0
- Scrutor (>= 7.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.