Josephan.CQRS.FunctionalResults
1.0.7
dotnet add package Josephan.CQRS.FunctionalResults --version 1.0.7
NuGet\Install-Package Josephan.CQRS.FunctionalResults -Version 1.0.7
<PackageReference Include="Josephan.CQRS.FunctionalResults" Version="1.0.7" />
<PackageVersion Include="Josephan.CQRS.FunctionalResults" Version="1.0.7" />
<PackageReference Include="Josephan.CQRS.FunctionalResults" />
paket add Josephan.CQRS.FunctionalResults --version 1.0.7
#r "nuget: Josephan.CQRS.FunctionalResults, 1.0.7"
#:package Josephan.CQRS.FunctionalResults@1.0.7
#addin nuget:?package=Josephan.CQRS.FunctionalResults&version=1.0.7
#tool nuget:?package=Josephan.CQRS.FunctionalResults&version=1.0.7
Josephan.CQRS.FunctionalResults
Functional CQRS built around Result and Result<T>.
Instead of throwing exceptions for expected business failures, commands and queries return strongly typed results that flow through the pipeline as values. This makes failures explicit, composable, testable, and easy to map to HTTP responses.
Installation
dotnet add package Josephan.CQRS.FunctionalResults
Why Functional Results?
Traditional applications often use exceptions for both unexpected failures and expected business outcomes.
if (user is null)
throw new UserNotFoundException();
With functional results, failures become part of the method contract:
if (user is null)
return Error.NotFound(
"User.NotFound",
"User was not found");
Benefits:
- Explicit success and failure paths
- No exception-driven control flow
- Easier testing
- Better API contracts
- Composable pipelines
- Natural HTTP response mapping
Quick Start
Define a command:
public sealed record CreateUserCommand(
string Name,
string Email)
: ICommand<Guid>;
Create a handler:
public sealed class CreateUserCommandHandler
: ICommandHandler<CreateUserCommand, Guid>
{
private readonly IUserRepository _repository;
public CreateUserCommandHandler(
IUserRepository repository)
{
_repository = repository;
}
public async Task<Result<Guid>> Handle(
CreateUserCommand command,
CancellationToken cancellationToken)
{
if (await _repository.ExistsAsync(command.Email))
{
return Error.Conflict(
"User.AlreadyExists",
"Email is already registered");
}
var user = new User(
command.Name,
command.Email);
await _repository.AddAsync(user);
return Result.Success(user.Id);
}
}
Register services:
builder.Services.AddCQRS(options =>
{
options.AddHandlersFromAssemblies(
typeof(Program).Assembly);
});
Send a command:
var result = await sender.Send(
new CreateUserCommand(
"Ali",
"ali@mail.com"));
if (result.IsFailure)
{
return Results.BadRequest(result.Errors);
}
return Results.Ok(result.Value);
Core Concepts
Every operation returns one of two result types:
| Type | Description |
|---|---|
Result |
Success or failure without a value |
Result<T> |
Success or failure with a value |
Failures are represented using the Error type.
Error
Error represents a failure.
Each error contains:
- Code
- Description
- Type
public readonly record struct Error(
string Code,
string Description,
ErrorType Type);
Built-In Errors
Error.None
Error.NullValue
Error.Unexpected
| Error | Description |
|---|---|
None |
Represents success internally |
NullValue |
A required value was null |
Unexpected |
Unknown or unclassified failure |
Creating Errors
Not Found
var error = Error.NotFound(
"User.NotFound",
"User was not found");
Validation
var error = Error.Validation(
"User.InvalidEmail",
"Email is invalid");
Conflict
var error = Error.Conflict(
"User.AlreadyExists",
"User already exists");
Failure
var error = Error.Failure(
"User.SaveFailed",
"Failed to save user");
Unauthorized
var error = Error.Unauthorized(
"Auth.Unauthenticated",
"User is not authenticated");
Forbidden
var error = Error.Forbidden(
"Auth.Forbidden",
"User lacks permissions");
Error Types
| Type | Meaning |
|---|---|
Failure |
General application failure |
Validation |
Validation or business rule failure |
Conflict |
State conflict |
NotFound |
Resource not found |
Unauthorized |
Authentication required |
Forbidden |
Permission denied |
Unexpected |
Unknown failure |
Result
Represents the outcome of an operation that returns no value.
Success
Result result = Result.Success();
Failure
Result result = Result.Failure(
Error.NotFound(
"User.NotFound",
"User was not found"));
Multiple Errors
All errors must share the same ErrorType.
Result result = Result.Failure(
Error.Validation(
"Name.Required",
"Name is required"),
Error.Validation(
"Email.Required",
"Email is required"));
Implicit Conversions
Error → Result
Result result =
Error.NotFound(
"User.NotFound",
"User was not found");
Error[] → Result
Result result = new[]
{
Error.Validation(
"Name.Required",
"Name is required"),
Error.Validation(
"Email.Invalid",
"Email is invalid")
};
Inspecting Results
if (result.IsSuccess)
{
}
if (result.IsFailure)
{
}
foreach (var error in result.Errors)
{
Console.WriteLine(
$"{error.Code}: {error.Description}");
}
Result<T>
Represents the outcome of an operation that returns a value.
Success
Result<Guid> result =
Result.Success(Guid.NewGuid());
Failure
Result<Guid> result =
Result.Failure<Guid>(
Error.NotFound(
"User.NotFound",
"User not found"));
From Value (Recommended entry point for railway pipelines)
Result<User> result =
Result.From(user);
Returns:
Error.NullValue
if the value is null.
Implicit Conversion
TValue → Result<T>
Result<string> result =
"Hello";
Error → Result<T>
Result<Guid> result =
Error.NotFound(
"User.NotFound",
"User was not found");
Accessing Values
if (result.IsSuccess)
{
var value = result.Value;
}
Accessing Value on a failed result throws:
InvalidOperationException
Commands
Commands represent write operations.
Command Without Response
public sealed record DeleteUserCommand(
Guid UserId)
: ICommand;
public sealed class DeleteUserCommandHandler
: ICommandHandler<DeleteUserCommand>
{
private readonly IUserRepository _repository;
public DeleteUserCommandHandler(
IUserRepository repository)
{
_repository = repository;
}
public async Task<Result> Handle(
DeleteUserCommand command,
CancellationToken cancellationToken)
{
var user =
await _repository.FindAsync(
command.UserId);
if (user is null)
{
return Error.NotFound(
"User.NotFound",
"User was not found");
}
await _repository.DeleteAsync(user);
return Result.Success();
}
}
Command With Response
public sealed record CreateUserCommand(
string Name,
string Email)
: ICommand<Guid>;
public sealed class CreateUserCommandHandler
: ICommandHandler<CreateUserCommand, Guid>
{
private readonly IUserRepository _repository;
public CreateUserCommandHandler(
IUserRepository repository)
{
_repository = repository;
}
public async Task<Result<Guid>> Handle(
CreateUserCommand command,
CancellationToken cancellationToken)
{
if (await _repository.ExistsAsync(command.Email))
{
return Error.Conflict(
"User.AlreadyExists",
"Email is already registered");
}
var user = new User(
command.Name,
command.Email);
await _repository.AddAsync(user);
return Result.Success(user.Id);
}
}
Queries
Queries represent read operations.
public sealed record GetUserQuery(
Guid Id)
: IQuery<UserDto>;
public sealed class GetUserQueryHandler
: IQueryHandler<GetUserQuery, UserDto>
{
private readonly IUserRepository _repository;
public GetUserQueryHandler(
IUserRepository repository)
{
_repository = repository;
}
public async Task<Result<UserDto>> Handle(
GetUserQuery query,
CancellationToken cancellationToken)
{
var user =
await _repository.FindAsync(query.Id);
if (user is null)
{
return Error.NotFound(
"User.NotFound",
"User was not found");
}
return Result.Success(
new UserDto(
user.Id,
user.Name));
}
}
Pipeline Behaviors
Behaviors work exactly like regular CQRS middleware.
Open-Generic Behavior
public sealed class LoggingBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
where TResponse : Result
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TRequest, TResponse> next,
CancellationToken cancellationToken)
{
Console.WriteLine($"Handling {typeof(TReq).Name}");
var response = await next(request, ct);
Console.WriteLine($"Handled {typeof(TReq).Name}");
return response;
}
}
Closed-Generic Behavior
Runs only for a specific request/response pair — ideal for caching, auditing, or type-specific logic.
public class CachingBehavior : IPipelineBehavior<GetUserQuery, Result<UserDto>>
{
public async Task<Result<UserDto>> Handle(
GetUserQuery request,
RequestHandlerDelegate<GetUserQuery, UserDto> next,
CancellationToken ct)
{
// Implement Logic Here
}
}
Registration:
services.AddCQRS(options =>
{
options.AddHandlersFromAssemblies(typeof(Program).Assembly);
// Open-Generic
options.AddBehavior(typeof(LoggingBehavior<,>));
// Closed-Generic
options.AddBehavior<GetUserQuery, Result<UserDto>, CachingBehavior>();
// Or simply
options.AddBehavior<CachingBehavior>();
// Command-only → Implelent ICommandPipelineBehavior
options.AddCommandBehavior(typeof(AuditBehavior<,>));
// Query-only → Implelent IQueryPipelineBehavior
options.AddQueryBehavior(typeof(QueryCachingBehavior<,>));
});
Calling AddCQRS from Multiple Layers/Modules
It's safe to call AddCQRS more than once — for example, once from an Application layer module to register behaviors, and once from an Infrastructure layer module to register handlers:
// Application layer
services.AddCQRS(options =>
{
options.AddBehavior(typeof(LoggingBehavior<,>));
options.AddCommandBehavior(typeof(AuditBehavior<,>));
});
// Infrastructure layer
services.AddCQRS(options =>
{
options.AddHandlersFromAssemblies(
typeof(ApplicationAssemblyMarker).Assembly,
typeof(InfrastructureAssemblyMarker).Assembly);
});
Handlers, behaviors, and pipeline decoration are all idempotent — no duplicate registrations, no double-wrapped pipelines, no matter how many modules call AddCQRS or in what order.
Notifications
Notifications allow broadcasting events to multiple handlers.
Define Notification
public sealed record UserCreatedNotification(
Guid UserId,
string Email)
: INotification;
Handlers
public sealed class SendWelcomeEmailHandler
: INotificationHandler<UserCreatedNotification>
{
public Task Handle(
UserCreatedNotification notification,
CancellationToken cancellationToken)
{
Console.WriteLine(
$"Sending email to {notification.Email}");
return Task.CompletedTask;
}
}
public sealed class AuditUserCreationHandler
: INotificationHandler<UserCreatedNotification>
{
public Task Handle(
UserCreatedNotification notification,
CancellationToken cancellationToken)
{
Console.WriteLine(
$"Created user {notification.UserId}");
return Task.CompletedTask;
}
}
Publish Notification
await publisher.PublishAsync(
new UserCreatedNotification(
userId,
email));
Publishing Strategies
| Strategy | Description |
|---|---|
Sequential |
Default. Executes handlers one by one |
Parallel |
Executes handlers concurrently |
await publisher.PublishAsync(
notification,
NotificationPublishingStrategy.Parallel);
Railway-Oriented Pipeline
Composable operators for building workflows without deeply nested conditionals and repetitive success/failure checks.
Bind
Transforms a successful result into another result-producing operation. If the current result is a failure, the chain stops and the failure is propagated.
var result = await Result
.From(command.Email)
.Bind(ValidateEmail)
.BindAsync(_repository.FindByEmailAsync)
.BindAsync(ActivateUserAsync);
Map
Transforms the success value while preserving the result state. Failures pass through unchanged.
Result<UserDto> dto =
await userResult.MapAsync(
user => new UserDto(
user.Id,
user.Name));
Tap
Executes side effects on success without modifying the result. Useful for logging, caching, publishing events, or metrics.
await result
.TapAsync(user =>
_cache.SetAsync(
user.Id,
user))
.TapAsync(user =>
_publisher.PublishAsync(
new UserActivatedEvent(
user.Id)));
TapError
Executes side effects on failure without modifying the result. Useful for logging or monitoring failed operations.
result.TapError(error =>
{
logger.LogWarning(
"{Code}: {Description}",
error.Code,
error.Description);
});
Match
Handles both success and failure branches and produces a final value.
return result.Match(
onSuccess: user =>
Results.Ok(user),
onFailure: failure =>
Results.BadRequest(
failure.Errors));
Ensure
Validates a value and converts validation failures into a result. Useful for enforcing business rules inside a pipeline.
var result = ResultExtensions.Ensure(
command.Email,
email => email.Contains('@'),
Error.Validation(
"Email.Invalid",
"Email is invalid"));
Try
Executes code that may throw exceptions and converts exceptions into failures.
result.Try(
value =>
_externalService.Notify(value),
ex =>
Error.Failure(
"Notify.Failed",
ex.Message));
Finally
Executes a callback regardless of success or failure. The original result is returned unchanged.
await resultTask.FinallyAsync(result =>
{
logger.LogInformation(
"Completed. Success={Success}",
result.IsSuccess);
});
Combine
Merges multiple results into a single result. If any operation fails, all errors are aggregated.
var result = ResultExtensions.Combine(
ValidateName(name),
ValidateEmail(email),
ValidateAge(age));
Combine typed results into a tuple:
var result = ResultExtensions.Combine(
userResult,
orderResult);
Result:
Result<(User User, Order Order)>
Unit
Unit is a void-like success value.
public sealed class PingHandler
: ICommandHandler<PingCommand>
{
public async Task<Result> Handle(
PingCommand command,
CancellationToken cancellationToken)
{
await Task.Delay(
100,
cancellationToken);
return Unit.Value;
}
}
Fully Functional style (No Imperative style)
A complete handler written as a pure railway pipeline — no if, no early returns, no exception throws.
public sealed class CreateUserCommandHandler
: ICommandHandler<CreateUserCommand, Guid>
{
private readonly IUserRepository _repository;
private readonly IPublisher _publisher;
private readonly ILogger<CreateUserCommandHandler> _logger;
public CreateUserCommandHandler(
IUserRepository repository,
IPublisher publisher,
ILogger<CreateUserCommandHandler> logger)
{
_repository = repository;
_publisher = publisher;
_logger = logger;
}
public Task<Result<Guid>> Handle(
CreateUserCommand command,
CancellationToken cancellationToken)
{
return Result
.From(command) // Entry point — wraps value, guards null
.Ensure( // Validate email format
cmd => cmd.Email.Contains('@'),
Error.Validation(
"User.InvalidEmail",
"Email is invalid"))
.Ensure( // Validate name not empty
cmd => !string.IsNullOrWhiteSpace(cmd.Name),
Error.Validation(
"User.InvalidName",
"Name is required"))
.BindAsync(cmd => // Check for duplicate — stops chain on conflict
_repository.ExistsAsync(cmd.Email)
? Task.FromResult<Result<CreateUserCommand>>(
Error.Conflict(
"User.AlreadyExists",
"Email is already registered"))
: Task.FromResult(Result.Success(cmd)))
.MapAsync(cmd => // Pure transformation — create the entity
new User(cmd.Name, cmd.Email))
.BindAsync(user => // Persist — propagates storage errors
_repository
.AddAsync(user)
.ContinueWith(_ => Result.Success(user)))
.TapAsync(user => // Side effect on success — publish event
_publisher.PublishAsync(
new UserCreatedNotification(
user.Id,
user.Email),
cancellationToken))
.TapError(errors => // Side effect on failure — log errors
_logger.LogWarning(
"CreateUser failed: {Errors}",
string.Join(", ", errors.Select(e => e.Code))))
.MapAsync(user => user.Id) // Project — extract the Guid
.FinallyAsync(result => // Always runs — telemetry / cleanup
_logger.LogInformation(
"CreateUser completed. Success={Success}",
result.IsSuccess));
}
}
Wire it up in the endpoint:
app.MapPost("/users", async (CreateUserCommand command, ISender sender) =>
{
return await Result
.From(command)
.BindAsync(_ => sender.Send(command))
.MatchAsync(
onSuccess: id =>
Results.Created($"/users/{id}", new { Id = id }),
onFailure: failure =>
failure.Errors.First().Type switch
{
ErrorType.Validation => Results.BadRequest(failure.Errors),
ErrorType.Conflict => Results.Conflict(),
ErrorType.NotFound => Results.NotFound(),
ErrorType.Unauthorized => Results.Unauthorized(),
ErrorType.Forbidden => Results.StatusCode(403),
_ => Results.StatusCode(500)
});
});
Each operator's role at a glance:
| Step | Operator | Purpose |
|---|---|---|
| 1 | Result.From |
Entry point — wraps raw value, auto-fails on null |
| 2–3 | Ensure |
Business rule validation before any I/O |
| 4 | BindAsync |
Duplicate check — short-circuits on conflict |
| 5 | MapAsync |
Pure entity construction — no failure possible |
| 6 | BindAsync |
Persist — propagates repository errors |
| 7 | TapAsync |
Publish domain event — success side effect only |
| 8 | TapError |
Log failure — error side effect only |
| 9 | MapAsync |
Project entity → return value (Guid) |
| 10 | FinallyAsync |
Telemetry / cleanup — always runs regardless |
The failure from any step short-circuits all remaining Bind/Map/Tap steps and flows straight to TapError → FinallyAsync → Match.
HTTP Response Mapping
A common ASP.NET Core pattern:
return result.Match(
onSuccess: value =>
Results.Ok(value),
onFailure: failure =>
failure.Errors.First().Type switch
{
ErrorType.Validation =>
Results.BadRequest(failure.Errors),
ErrorType.NotFound =>
Results.NotFound(),
ErrorType.Conflict =>
Results.Conflict(),
ErrorType.Unauthorized =>
Results.Unauthorized(),
ErrorType.Forbidden =>
Results.StatusCode(403),
_ =>
Results.StatusCode(500)
});
License
MIT © Yousef Yahia
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. 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. |
-
net8.0
- Microsoft.Extensions.DependencyInjection (>= 10.0.7)
NuGet packages (2)
Showing the top 2 NuGet packages that depend on Josephan.CQRS.FunctionalResults:
| Package | Downloads |
|---|---|
|
Josephan.CQRS.FunctionalResults.NewtonsoftJson
Newtonsoft.Json converters for Josephan.CQRS Functional Results. Provides serialization and deserialization support for Result, Result<T>, and Error with a single extension method for JsonSerializerSettings. |
|
|
Josephan.CQRS.FunctionalResults.SystemTextJson
System.Text.Json converters for Josephan.CQRS Functional Results. Provides serialization and deserialization support for Result, Result<T>, and Error with a single extension method for JsonSerializerOptions. |
GitHub repositories
This package is not used by any popular GitHub repositories.