Communication for .NET
$ dotnet add package ManagedCode.CommunicationResult pattern for .NET that replaces exceptions with type-safe return values. Features railway-oriented programming, ASP.NET Core integration, RFC 7807 Problem Details, and built-in pagination. Designed for production systems requiring explicit error handling without the overhead of throwing exceptions.
ManagedCode.Communication brings functional error handling to .NET through the Result pattern. Instead of throwing exceptions, methods return Result types that explicitly indicate success or failure. This approach eliminates hidden control flow, improves performance, and makes error handling a first-class concern in your codebase.
Traditional exception handling has several drawbacks:
The Result pattern solves these issues by:
Result: Represents success/failure without a valueResult<T>: Represents success with value T or failureCollectionResult<T>: Represents collections with built-in paginationProblem: RFC 7807 compliant error detailsIResultFactory<T> and ICommandFactory<T> deliver a consistent surface while bridge helpers remove repetitive boilerplate.Succeed/Fail contract—the shared helpers provide the rest.PaginationRequest encapsulates skip/take semantics, built-in normalization, and clamping helpers.PaginationOptions lets you define default, minimum, and maximum page sizes for a bounded API surface.PaginationCommand captures pagination intent as a first-class command with generated overloads for skip/take, page numbers, and enum command types.CollectionResult<T>.Succeed(..., PaginationRequest request, int totalItems) keeps result metadata aligned with pagination commands.Complete set of functional combinators for composing operations:
Map: Transform success valuesBind / Then: Chain Result-returning operationsTap / Do: Execute side effectsMatch: Pattern matching on success/failureCompensate: Recovery from failuresMerge / Combine: Aggregate multiple resultsLoggerCenter APIs provide zero-allocation logging across ASP.NET Core filters, SignalR hubs, and command stores.[LoggerMessage] partials to keep high-volume paths allocation free.Pre-defined error categories with appropriate HTTP status codes:
# Core library
Install-Package ManagedCode.Communication
# ASP.NET Core integration
Install-Package ManagedCode.Communication.AspNetCore
# Minimal API extensions
Install-Package ManagedCode.Communication.Extensions
# Orleans integration
Install-Package ManagedCode.Communication.Orleans# Core library
dotnet add package ManagedCode.Communication
# ASP.NET Core integration
dotnet add package ManagedCode.Communication.AspNetCore
# Minimal API extensions
dotnet add package ManagedCode.Communication.Extensions
# Orleans integration
dotnet add package ManagedCode.Communication.Orleans<PackageReference Include="ManagedCode.Communication" Version="9.6.0" />
<PackageReference Include="ManagedCode.Communication.AspNetCore" Version="9.6.0" />
<PackageReference Include="ManagedCode.Communication.Extensions" Version="9.6.0" />
<PackageReference Include="ManagedCode.Communication.Orleans" Version="9.6.0" />The library includes integrated logging for error scenarios. Configure logging to capture detailed error information:
var builder = WebApplication.CreateBuilder(args);
// Add your logging configuration
builder.Logging.AddConsole();
builder.Logging.AddDebug();
// Register other services
builder.Services.AddControllers();
// Configure Communication library - this enables automatic error logging
builder.Services.ConfigureCommunication();
var app = builder.Build();Add the optional ManagedCode.Communication.Extensions package to bridge Minimal API endpoints with the Result pattern. The
package provides the ResultEndpointFilter and a fluent helper WithCommunicationResults that wraps the endpoint builder and
returns IResult instances automatically:
var builder = WebApplication.CreateBuilder(args);
builder.Services.ConfigureCommunication();
var app = builder.Build();
// Apply the filter to a single endpoint
app.MapGet("/orders/{id}", async (Guid id, IOrderService orders) =>
await orders.GetAsync(id))
.WithCommunicationResults();
// Or apply it to a group so every route inherits the conversion
app.MapGroup("/orders")
.WithCommunicationResults()
.MapPost(string.Empty, async (CreateOrder command, IOrderService orders) =>
await orders.CreateAsync(command));
app.Run();Handlers can return any Result or Result<T> instance and the filter will reuse the existing ASP.NET Core converters so
you do not need to write manual IResult translations.
The extensions package also ships helpers that turn HttpClient calls directly into Result instances and optionally run
them through Polly resilience pipelines:
using ManagedCode.Communication.Extensions.Http;
using Polly;
using Polly.Retry;
var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
.AddRetry(new RetryStrategyOptions<HttpResponseMessage>
{
MaxRetryAttempts = 3,
Delay = TimeSpan.FromMilliseconds(200),
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.HandleResult(response => !response.IsSuccessStatusCode)
})
.Build();
var result = await httpClient.SendForResultAsync<OrderDto>(
() => new HttpRequestMessage(HttpMethod.Get, $"/orders/{orderId}"),
pipeline);
if (result.IsSuccess)
{
// access result.Value without manually reading the HTTP payload
}The helpers use the existing HttpResponseMessage converters, so non-success status codes automatically map to a
Problem with the response body and status code.
success responses map to 200 OK/204 No Content while failures become RFC 7807 problem details. Native Microsoft.AspNetCore.Http.IResult
responses pass through unchanged, so you can mix and match traditional Minimal API patterns with ManagedCode.Communication results.
var services = new ServiceCollection();
// Add logging
services.AddLogging(builder =>
{
builder.AddConsole()
.SetMinimumLevel(LogLevel.Information);
});
// Configure Communication library
services.ConfigureCommunication();
var serviceProvider = services.BuildServiceProvider();The library automatically logs errors in Result factory methods (From, Try, etc.) with detailed context including file names, line numbers, and method names for easier debugging.
The Result type represents an operation that can either succeed or fail:
public struct Result
{
public bool IsSuccess { get; }
public Problem? Problem { get; }
}The generic Result<T> includes a value on success:
public struct Result<T>
{
public bool IsSuccess { get; }
public T? Value { get; }
public Problem? Problem { get; }
}Implements RFC 7807 Problem Details for HTTP APIs:
public class Problem
{
public string Type { get; set; }
public string Title { get; set; }
public int StatusCode { get; set; }
public string Detail { get; set; }
public Dictionary<string, object> Extensions { get; set; }
}Use built-in helpers to convert technical Problem payloads into UI-friendly messages:
var problem = Problem.Create("RegistrationUnavailable", "Service is temporarily unavailable", 503);
problem.ErrorCode = "RegistrationUnavailable";
// Default message resolution chain:
// ErrorCode mapper -> Detail -> Title -> defaultMessage -> "An error occurred"
var message = problem.ToDisplayMessage(defaultMessage: "Please try again later");
var registrationMessages = new Dictionary<string, string>
{
["RegistrationUnavailable"] = "Registration is currently unavailable.",
["RegistrationBlocked"] = "Registration is temporarily blocked.",
["RegistrationInviteRequired"] = "Registration requires an invitation code."
};
// 1) Dictionary overload
var byDictionary = problem.ToDisplayMessage(
registrationMessages,
defaultMessage: "Please try again later");
// 2) Tuple mappings overload
var byTuples = problem.ToDisplayMessage(
"Please try again later",
("RegistrationUnavailable", "Registration is currently unavailable."),
("RegistrationBlocked", "Registration is temporarily blocked."),
("RegistrationInviteRequired", "Registration requires an invitation code."));
// 3) Delegate overload
static string? ResolveRegistrationMessage(string code) => code switch
{
"RegistrationUnavailable" => "Registration is currently unavailable.",
"RegistrationBlocked" => "Registration is temporarily blocked.",
"RegistrationInviteRequired" => "Registration requires an invitation code.",
_ => null
};
var byDelegate = problem.ToDisplayMessage(
ResolveRegistrationMessage,
defaultMessage: "Please try again later");
// The same overloads are available for Result, Result<T> and CollectionResult<T>
var resultMessage = Result.Fail(problem).ToDisplayMessage(
registrationMessages,
defaultMessage: "Please try again later");
// Typed extension access
if (problem.TryGetExtension("retryAfter", out int retryAfterSeconds))
{
Console.WriteLine($"Retry after: {retryAfterSeconds}s");
}using ManagedCode.Communication;
// Creating Results
var success = Result.Succeed();
var failure = Result.Fail("Operation failed");
// Results with values
var userResult = Result<User>.Succeed(new User { Id = 1, Name = "John" });
var notFound = Result<User>.FailNotFound("User not found");
// Validation errors
var invalid = Result.FailValidation(
("email", "Email is required"),
("age", "Age must be positive")
);
// From exceptions
try
{
// risky operation
}
catch (Exception ex)
{
var error = Result.Fail(ex);
}if (result.IsSuccess)
{
// Handle success
}
if (result.IsFailed)
{
// Handle failure
}
if (result.IsInvalid)
{
// Handle validation errors
}
// Pattern matching
result.Match(
onSuccess: () => Console.WriteLine("Success!"),
onFailure: problem => Console.WriteLine($"Failed: {problem.Detail}")
);// Basic success
Result.Succeed()
Result<T>.Succeed(T value)
CollectionResult<T>.Succeed(T[] items, int pageNumber, int pageSize, int totalItems)
// From operations
Result.From(Action action)
Result<T>.From(Func<T> func)
Result<T>.From(Task<T> task)
// Try pattern with exception catching
Result.Try(Action action)
Result<T>.Try(Func<T> func)// Basic failures
Result.Fail()
Result.Fail(string title)
Result.Fail(string title, string detail)
Result.Fail(Problem problem)
Result.Fail(Exception exception)
// HTTP status failures
Result.FailNotFound(string detail)
Result.FailUnauthorized(string detail)
Result.FailForbidden(string detail)
// Validation failures
Result.FailValidation(params (string field, string message)[] errors)
Result.Invalid(string message)
Result.Invalid(string field, string message)
// Enum-based failures
Result.Fail<TEnum>(TEnum errorCode) where TEnum : Enum// Map: Transform the value
Result<int> ageResult = userResult.Map(user => user.Age);
// Bind: Chain operations that return Results
Result<Order> orderResult = userResult
.Bind(user => GetUserCart(user.Id))
.Bind(cart => CreateOrder(cart));
// Tap: Execute side effects
Result<User> result = userResult
.Tap(user => _logger.LogInfo($"Processing user {user.Id}"))
.Tap(user => _cache.Set(user.Id, user));// Ensure: Add validation
Result<User> validUser = userResult
.Ensure(user => user.Age >= 18, Problem.Create("User must be 18+"))
.Ensure(user => user.Email.Contains("@"), Problem.Create("Invalid email"));
// Where: Filter with predicate
Result<User> filtered = userResult
.Where(user => user.IsActive, "User is not active");
// FailIf: Conditional failure
Result<Order> order = orderResult
.FailIf(o => o.Total <= 0, "Order total must be positive");
// OkIf: Must satisfy condition
Result<Payment> payment = paymentResult
.OkIf(p => p.IsAuthorized, "Payment not authorized");Railway-oriented programming treats operations as a series of tracks where success continues on the main track and failures switch to an error track:
public Result<Order> ProcessOrder(int userId)
{
return Result.From(() => GetUser(userId))
.Then(user => ValidateUser(user))
.Then(user => GetUserCart(user.Id))
.Then(cart => ValidateCart(cart))
.Then(cart => CreateOrder(cart))
.Then(order => ProcessPayment(order))
.Then(order => SendConfirmation(order));
}public async Task<Result<Order>> ProcessOrderAsync(int userId)
{
return await Result.From(() => GetUserAsync(userId))
.ThenAsync(user => ValidateUserAsync(user))
.ThenAsync(user => GetUserCartAsync(user.Id))
.ThenAsync(cart => CreateOrderAsync(cart))
.ThenAsync(order => ProcessPaymentAsync(order))
.ThenAsync(order => SendConfirmationAsync(order));
}var result = await GetPrimaryService()
.CompensateAsync(async error =>
{
_logger.LogWarning($"Primary service failed: {error.Detail}");
return await GetFallbackService();
})
.CompensateWith(defaultValue); // Final fallback// Merge: Stop at first failure
var firstFailureResult = Result.Merge(
ValidateName(name),
ValidateEmail(email),
ValidateAge(age)
);
// MergeAll: aggregate all failures
var allFailuresResult = Result.MergeAll(
ValidateName(name),
ValidateEmail(email),
ValidateAge(age)
);
if (allFailuresResult.TryGetProblem(out var problem))
{
// All failures were validation failures:
// problem.GetValidationErrors() returns merged field errors.
//
// Mixed failures (401/403/500/...) return aggregate problem:
// problem.StatusCode == 500
// problem.Extensions["errors"] contains the original Problem[] list.
}
if (allFailuresResult.TryGetProblem(out var aggregateProblem) &&
aggregateProblem.TryGetExtension("errors", out Problem[]? originalErrors))
{
foreach (var error in originalErrors)
{
Console.WriteLine($"{error.StatusCode}: {error.Title} - {error.Detail}");
}
}
// Combine: Aggregate values
var combined = Result.Combine(
GetUserProfile(),
GetUserSettings(),
GetUserPermissions()
); // Returns CollectionResult<T>
// CombineAll: aggregate failures while preserving original errors
var combinedAll = Result.CombineAll(
GetUserProfile(),
GetUserSettings(),
GetUserPermissions()
);The library includes built-in support for command pattern with distributed idempotency:
// Basic command
public class CreateOrderCommand : Command<Order>
{
public CreateOrderCommand(string orderId, Order order)
: base(orderId, "CreateOrder")
{
Value = order;
UserId = "user123";
CorrelationId = Guid.NewGuid().ToString();
}
}
// Command with metadata
var command = new Command("command-id", "ProcessPayment")
{
UserId = "user123",
SessionId = "session456",
CorrelationId = "correlation789",
CausationId = "parent-command-id",
TraceId = Activity.Current?.TraceId.ToString(),
SpanId = Activity.Current?.SpanId.ToString()
};Pagination is now a first-class command concept that keeps factories DRY and metadata consistent:
var options = new PaginationOptions(defaultPageSize: 25, maxPageSize: 100);
var request = PaginationRequest.Create(skip: 0, take: 0, options); // take defaults to 25
// Rich factory surface without duplicate overloads
var paginationCommand = PaginationCommand.Create(request, options)
.WithCorrelationId(Guid.NewGuid().ToString());
// Apply to results without manually recalculating metadata
var page = CollectionResult<Order>.Succeed(orders, paginationCommand.Value!, totalItems: 275, options);
// Use enum-based command types when desired
enum PaginationCommandType { ListCustomers }
var typedCommand = PaginationCommand.Create(PaginationCommandType.ListCustomers);PaginationRequest exposes helpers such as Normalize, ClampToTotal, and ToSlice to keep skip/take logic predictable. Configure bounds globally with PaginationOptions to protect APIs from oversized queries.
// Register idempotency store
builder.Services.AddSingleton<ICommandIdempotencyStore, InMemoryCommandIdempotencyStore>();
// Or use Orleans-based store
builder.Services.AddSingleton<ICommandIdempotencyStore, OrleansCommandIdempotencyStore>();
// Service with idempotent operations
public class PaymentService
{
private readonly ICommandIdempotencyStore _idempotencyStore;
public async Task<Result<Payment>> ProcessPaymentAsync(ProcessPaymentCommand command)
{
// Automatic idempotency - returns cached result if already executed
return await _idempotencyStore.ExecuteIdempotentAsync(
command.Id,
async () =>
{
// This code runs only once per command ID
var payment = await _paymentGateway.ChargeAsync(command.Amount);
await _repository.SavePaymentAsync(payment);
return Result<Payment>.Succeed(payment);
},
command.Metadata
);
}
}// Automatic idempotency with Orleans grains
public class OrderGrain : Grain, IOrderGrain
{
private readonly ICommandIdempotencyStore _idempotencyStore;
public async Task<Result<Order>> CreateOrderAsync(CreateOrderCommand command)
{
// Uses ICommandIdempotencyGrain internally for distributed coordination
return await _idempotencyStore.ExecuteIdempotentAsync(
command.Id,
async () =>
{
// Guaranteed to execute only once across the cluster
var order = new Order { /* ... */ };
await SaveOrderAsync(order);
return Result<Order>.Succeed(order);
}
);
}
}public enum CommandExecutionStatus
{
NotStarted, // Command hasn't been processed
Processing, // Currently being processed
Completed, // Successfully completed
Failed, // Processing failed
Expired // Result expired from cache
}
// Check command status
var status = await _idempotencyStore.GetCommandStatusAsync("command-id");
if (status == CommandExecutionStatus.Completed)
{
var result = await _idempotencyStore.GetCommandResultAsync<Order>("command-id");
}Commands implement ICommand and surface correlation, causation, trace, span, user, and session identifiers alongside optional metadata so every hop can attach observability context. The base Command and Command<T> types keep those properties on the
root object, and serializers/Orleans surrogates round-trip them without custom plumbing.
root object, and serializers/Orleans surrogates round-trip them without custom plumbing.
Guid.CreateVersion7() and stamp a UTC timestamp so commands can be sorted chronologically even when sharded.WithCorrelationId, WithTraceId, and similar extension methods that return the same command instance.| Field | Purpose | Typical source | Notes |
|---|---|---|---|
CommandId | Unique, monotonic identifier for deduplication | Static command factories | Remains stable for retries and storage lookups. |
CorrelationId | Ties a command to an upstream workflow/request | HTTP X-Correlation-Id, message headers | Preserved through |
| serialization and Orleans surrogates. | |||
CausationId | Records the predecessor command/event | Current command ID | Supports causal chains in telemetry. |
TraceId | Connects to distributed tracing spans | OpenTelemetry/Activity context | The library stores, but never generate |
| s, trace identifiers. | |||
SpanId | Identifies the originating span | OpenTelemetry/Activity context | Often paired with Metadata.TraceId for deep |
| er traces. | |||
UserId / SessionId | Attach security/session principals | Authentication middleware | Useful for multi-tenant auditing. |
A ctivity identifiers through serialization so telemetry back-ends can stitch spans together.Command.Create(...) / Command<T>.Create(...) (or the matching From(...) helpers) to get a version 7 identifier and U
TC timestamp automatically..WithCorrelationId(...) before d
ispatching commands.Activity.TraceId/Activity.SpanId through .WithTraceId(...) and .WithSpanId(...) (and metadata counterparts) wh
en bridging to queues, Orleans, or background pipelines.Comma ndType values for traceability.The shared idempotency helpers (CommandIdempotencyExtensions), default in-memory store, and test coverage work together to pro
tect concurrency, caching, and retry behaviour across hosts.
ExecuteIdempotentAsync only invokes the provided delegate after atomically claiming th
e command, writes the result, and then flips the status to Completed, so retries either reuse cached output or wait for the in
-flight execution to finish.null or default values.SemaphoreSlim instances eliminate global contention, and reference
counting ensures locks are released once no callers use a key.Failed when appropriate.Completed but the result entry expired, the extensions currently return the
default value. Stores that can distinguish “missing” from “stored default” should override TryGetCachedResultAsync to trigger
a re-execution.default values f
rom missing entries.public Result<User> CreateUser(CreateUserDto dto)
{
// Collect all validation errors
var errors = new List<(string field, string message)>();
if (string.IsNullOrEmpty(dto.Email))
errors.Add(("email", "Email is required"));
if (!dto.Email.Contains("@"))
errors.Add(("email", "Invalid email format"));
if (dto.Age < 0)
errors.Add(("age", "Age must be positive"));
if (dto.Age < 18)
errors.Add(("age", "Must be 18 or older"));
if (errors.Any())
return Result<User>.FailValidation(errors.ToArray());
var user = new User { /* ... */ };
return Result<User>.Succeed(user);
}public class UserRepository
{
private readonly AppDbContext _context;
private readonly ILogger<UserRepository> _logger;
public async Task<Result<User>> GetByIdAsync(int id)
{
try
{
var user = await _context.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.Id == id);
if (user == null)
return Result<User>.FailNotFound($"User {id} not found");
return Result<User>.Succeed(user);
}
catch (Exception ex)
{
_logger.LogError(ex, "Database error getting user {UserId}", id);
return Result<User>.Fail(ex);
}
}
public async Task<CollectionResult<User>> GetPagedAsync(
int page,
int pageSize,
Expression<Func<User, bool>>? filter = null,
Expression<Func<User, object>>? orderBy = null)
{
try
{
// Build query with IQueryable for efficient SQL generation
IQueryable<User> query = _context.Users.AsNoTracking();
// Apply filter if provided
if (filter != null)
query = query.Where(filter);
// Apply ordering
query = orderBy != null
? query.OrderBy(orderBy)
: query.OrderBy(u => u.Id);
// Get total count - generates COUNT(*) SQL query
var totalItems = await query.CountAsync();
if (totalItems == 0)
return CollectionResult<User>.Succeed(Array.Empty<User>(), page, pageSize, 0);
// Get page of data - generates SQL with OFFSET and FETCH
var users = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToArrayAsync();
return CollectionResult<User>.Succeed(users, page, pageSize, totalItems);
}
catch (Exception ex)
{
_logger.LogError(ex, "Database error in GetPagedAsync");
return CollectionResult<User>.Fail(ex);
}
}
// Example with complex query
public async Task<CollectionResult<UserDto>> SearchUsersAsync(
string searchTerm,
int page,
int pageSize)
{
try
{
var query = _context.Users
.AsNoTracking()
.Where(u => u.IsActive)
.Where(u => EF.Functions.Like(u.Name, $"%{searchTerm}%") ||
EF.Functions.Like(u.Email, $"%{searchTerm}%"));
// Count before projection for efficiency
var totalItems = await query.CountAsync();
// Project to DTO and paginate - single SQL query
var users = await query
.OrderBy(u => u.Name)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(u => new UserDto
{
Id = u.Id,
Name = u.Name,
Email = u.Email,
LastLoginDate = u.LastLoginDate
})
.ToArrayAsync();
return CollectionResult<UserDto>.Succeed(users, page, pageSize, totalItems);
}
catch (Exception ex)
{
_logger.LogError(ex, "Search failed for term: {SearchTerm}", searchTerm);
return CollectionResult<UserDto>.Fail(ex);
}
}
}public class OrderService
{
public async Task<Result<Order>> CreateOrderAsync(CreateOrderDto dto)
{
// Validate input
var validationResult = ValidateOrderDto(dto);
if (validationResult.IsFailed)
return validationResult;
// Get user
var userResult = await _userRepo.GetByIdAsync(dto.UserId);
if (userResult.IsFailed)
return Result<Order>.Fail(userResult.Problem);
// Check permissions
var user = userResult.Value;
if (!user.CanCreateOrders)
return Result<Order>.FailForbidden("User cannot create orders");
// Create order
return await Result.Try(async () =>
{
var order = new Order
{
UserId = user.Id,
Items = dto.Items,
Total = CalculateTotal(dto.Items)
};
await _orderRepo.SaveAsync(order);
return order;
});
}
}// 1. Install NuGet package
// dotnet add package ManagedCode.Communication.AspNetCore
// 2. Program.cs configuration
var builder = WebApplication.CreateBuilder(args);
// Method 1: Simple configuration with auto-detection of environment
builder.AddCommunication(); // ShowErrorDetails = IsDevelopment
// Method 2: Custom configuration
builder.Services.AddCommunication(options =>
{
options.ShowErrorDetails = true; // Show detailed error messages in responses
});
// 3. Add filters to MVC controllers (ORDER MATTERS!)
builder.Services.AddControllers(options =>
{
options.AddCommunicationFilters();
// Filters are applied in this order:
// 1. CommunicationModelValidationFilter - Catches validation errors first
// 2. ResultToActionResultFilter - Converts Result to HTTP response
// 3. CommunicationExceptionFilter - Catches any unhandled exceptions last
});
// 4. Optional: Add filters to SignalR hubs
builder.Services.AddSignalR(options =>
{
options.AddCommunicationFilters();
});
var app = builder.Build();The order of filters is important for proper error handling:
| Order | Filter | Purpose | When It Runs |
|---|---|---|---|
| 1 | CommunicationModelValidationFilter | Converts ModelState errors to Result.FailValidation | Before action execution if model is invalid |
| 2 | ResultToActionResultFilter | Maps Result<T> return values to HTTP responses | After action execution |
| 3 | CommunicationExceptionFilter | Catches unhandled exceptions, returns Problem Details | On any exception |
⚠️ Important: The filters must be registered using AddCommunicationFilters() to ensure correct ordering. Manual registration may cause unexpected behavior.
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly IUserService _userService;
[HttpGet("{id}")]
[ProducesResponseType(typeof(User), 200)]
[ProducesResponseType(typeof(Problem), 404)]
public async Task<Result<User>> GetUser(int id)
{
return await _userService.GetUserAsync(id);
}
[HttpPost]
[ProducesResponseType(typeof(User), 201)]
[ProducesResponseType(typeof(Problem), 400)]
public async Task<Result<User>> CreateUser([FromBody] CreateUserDto dto)
{
return await _userService.CreateUserAsync(dto);
}
[HttpGet]
[ProducesResponseType(typeof(CollectionResult<User>), 200)]
public async Task<CollectionResult<User>> GetUsers(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 10)
{
return await _userService.GetUsersAsync(page, pageSize);
}
}The library automatically converts Result types to appropriate HTTP responses:
| Result State | HTTP Status | Response Body |
|---|---|---|
Result.Succeed() | 204 No Content | Empty |
Result<T>.Succeed(value) | 200 OK | value |
Result.FailValidation(...) | 400 Bad Request | Problem Details |
Result.FailUnauthorized() | 401 Unauthorized | Problem Details |
Result.FailForbidden() | 403 Forbidden | Problem Details |
Result.FailNotFound() | 404 Not Found | Problem Details |
Result.Fail(...) | 500 Internal Server Error | Problem Details |
public class ChatHub : Hub
{
public async Task<Result<MessageDto>> SendMessage(string user, string message)
{
if (string.IsNullOrEmpty(message))
return Result<MessageDto>.FailValidation(("message", "Message cannot be empty"));
var messageDto = new MessageDto
{
User = user,
Message = message,
Timestamp = DateTime.UtcNow
};
await Clients.All.SendAsync("ReceiveMessage", user, message);
return Result<MessageDto>.Succeed(messageDto);
}
public async Task<Result> JoinGroup(string groupName)
{
if (string.IsNullOrEmpty(groupName))
return Result.FailValidation(("groupName", "Group name is required"));
await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
return Result.Succeed();
}
}// Silo configuration
var builder = Host.CreateDefaultBuilder(args)
.UseOrleans(silo =>
{
silo.UseLocalhostClustering()
.UseOrleansCommunication(); // Required for Result serialization
});
// Client configuration
var clientBuilder = Host.CreateDefaultBuilder(args)
.UseOrleansClient(client =>
{
client.UseOrleansCommunication(); // Required for Result serialization
});That's it! The UseOrleansCommunication() extension automatically configures:
public interface IUserGrain : IGrainWithStringKey
{
Task<Result<UserState>> GetStateAsync();
Task<Result> UpdateProfileAsync(UpdateProfileDto dto);
Task<CollectionResult<Activity>> GetActivitiesAsync(int page, int pageSize);
}
public class UserGrain : Grain, IUserGrain
{
private readonly IPersistentState<UserState> _state;
public UserGrain([PersistentState("user")] IPersistentState<UserState> state)
{
_state = state;
}
public Task<Result<UserState>> GetStateAsync()
{
if (!_state.RecordExists)
return Task.FromResult(Result<UserState>.FailNotFound("User not found"));
return Task.FromResult(Result<UserState>.Succeed(_state.State));
}
public async Task<Result> UpdateProfileAsync(UpdateProfileDto dto)
{
if (!_state.RecordExists)
return Result.FailNotFound("User not found");
// Validate
if (string.IsNullOrEmpty(dto.DisplayName))
return Result.FailValidation(("displayName", "Display name is required"));
// Update state
_state.State.DisplayName = dto.DisplayName;
_state.State.Bio = dto.Bio;
_state.State.UpdatedAt = DateTime.UtcNow;
await _state.WriteStateAsync();
return Result.Succeed();
}
public async Task<CollectionResult<Activity>> GetActivitiesAsync(int page, int pageSize)
{
if (!_state.RecordExists)
return CollectionResult<Activity>.FailNotFound("User not found");
// For real data, use a repository with Entity Framework
var repository = GrainFactory.GetGrain<IActivityRepositoryGrain>(0);
return await repository.GetUserActivitiesAsync(this.GetPrimaryKeyString(), page, pageSize);
}
}Result and Result<T> are value types (structs) to avoid heap allocationConfigureAwait(false) in library codeThe repository uses xUnit with Shouldly for assertions. Shared matchers such as ShouldBeEquivalentTo and AssertProblem() live in ManagedCode.Communication.Tests/TestHelpers, keeping tests fluent without FluentAssertions.
dotnet test ManagedCode.Communication.Tests/ManagedCode.Communication.Tests.csprojdotnet test ManagedCode.Communication.Tests/ManagedCode.Communication.Tests.csproj /p:CollectCoverage=true /p:CoverletOutputFormat=lcovExecution helpers (Result.From, Result<T>.From, task/value-task shims) and the command metadata extensions now have direct tests, pushing the core assembly above 80% line coverage. Mirror those patterns when adding APIs—exercise both success and failure paths and prefer invoking the public fluent surface instead of internal helpers.
| Feature | ManagedCode.Communication | FluentResults | CSharpFunctionalExtensions | ErrorOr |
|---|---|---|---|---|
| Multiple Errors | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes |
| Railway-Oriented | ✅ Full | ✅ Full | ✅ Full | ⚠️ Limited |
| HTTP Integration | ✅ Built-in | ❌ No | ⚠️ Extension | ❌ No |
| Orleans Support | ✅ Built-in | ❌ No | ❌ No | ❌ No |
| SignalR Support | ✅ Built-in | ❌ No | ❌ No | ❌ No |
| RFC 7807 | ✅ Full | ❌ No | ❌ No | ❌ No |
| Pagination | ✅ Built-in | ❌ No | ❌ No | ❌ No |
| Command Pattern | ✅ Built-in | ❌ No | ❌ No | ❌ No |
| Performance | ✅ Struct-based | ❌ Class-based | ✅ Struct-based | ✅ Struct-based |
| Async Support | ✅ Full | ✅ Full | ✅ Full | ✅ Full |
Choose this library when you need:
// DO: Use Result for operations that can fail
public Result<User> GetUser(int id)
{
var user = _repository.FindById(id);
return user != null
? Result<User>.Succeed(user)
: Result<User>.FailNotFound($"User {id} not found");
}
// DO: Chain operations using railway-oriented programming
public Result<Order> ProcessOrder(OrderDto dto)
{
return ValidateOrder(dto)
.Then(CreateOrder)
.Then(CalculateTotals)
.Then(ApplyDiscounts)
.Then(SaveOrder);
}
// DO: Provide specific error information
public Result ValidateEmail(string email)
{
if (string.IsNullOrEmpty(email))
return Result.FailValidation(("email", "Email is required"));
if (!email.Contains("@"))
return Result.FailValidation(("email", "Invalid email format"));
return Result.Succeed();
}
// DO: Use CollectionResult for paginated data
public CollectionResult<Product> GetProducts(int page, int pageSize)
{
var products = _repository.GetPaged(page, pageSize);
var total = _repository.Count();
return CollectionResult<Product>.Succeed(products, page, pageSize, total);
}// DON'T: Throw exceptions from Result-returning methods
public Result<User> GetUser(int id)
{
if (id <= 0)
throw new ArgumentException("Invalid ID"); // ❌ Don't throw
// Instead:
if (id <= 0)
return Result<User>.FailValidation(("id", "ID must be positive")); // ✅
}
// DON'T: Ignore Result values
var result = UpdateUser(user); // ❌ Result ignored
DoSomethingElse();
// Instead:
var result = UpdateUser(user);
if (result.IsFailed)
return result; // ✅ Handle the failure
// DON'T: Mix Result and exceptions
public async Task<User> GetUserMixed(int id)
{
var result = await GetUserAsync(id);
if (result.IsFailed)
throw new Exception(result.Problem.Detail); // ❌ Mixing patterns
return result.Value;
}
// DON'T: Create generic error messages
return Result.Fail("Error"); // ❌ Too vague
// Instead:
return Result.Fail("User creation failed", "Email already exists"); // ✅// Domain Model
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public int Stock { get; set; }
}
// Service Interface
public interface IProductService
{
Task<Result<Product>> GetByIdAsync(int id);
Task<Result<Product>> CreateAsync(CreateProductDto dto);
Task<Result> UpdateStockAsync(int id, int quantity);
Task<CollectionResult<Product>> SearchAsync(string query, int page, int pageSize);
}
// Service Implementation
public class ProductService : IProductService
{
private readonly IProductRepository _repository;
private readonly ILogger<ProductService> _logger;
public async Task<Result<Product>> GetByIdAsync(int id)
{
return await Result.Try(async () =>
{
var product = await _repository.FindByIdAsync(id);
return product ?? throw new KeyNotFoundException($"Product {id} not found");
})
.CompensateAsync(async error =>
{
_logger.LogWarning("Product {Id} not found, checking archive", id);
var archived = await _repository.FindInArchiveAsync(id);
return archived != null
? Result<Product>.Succeed(archived)
: Result<Product>.FailNotFound($"Product {id} not found");
});
}
public async Task<Result<Product>> CreateAsync(CreateProductDto dto)
{
// Validation
var validationResult = await ValidateProductDto(dto);
if (validationResult.IsFailed)
return Result<Product>.Fail(validationResult.Problem);
// Check for duplicates
var existing = await _repository.FindByNameAsync(dto.Name);
if (existing != null)
return Result<Product>.Fail("Duplicate product",
$"Product with name '{dto.Name}' already exists");
// Create product
var product = new Product
{
Name = dto.Name,
Price = dto.Price,
Stock = dto.InitialStock
};
await _repository.AddAsync(product);
await _repository.SaveChangesAsync();
return Result<Product>.Succeed(product);
}
public async Task<Result> UpdateStockAsync(int id, int quantity)
{
return await GetByIdAsync(id)
.Then(product =>
{
if (product.Stock + quantity < 0)
return Result.Fail("Insufficient stock",
$"Cannot reduce stock by {Math.Abs(quantity)}. Current stock: {product.Stock}");
product.Stock += quantity;
return Result.Succeed();
})
.ThenAsync(async () =>
{
await _repository.SaveChangesAsync();
return Result.Succeed();
});
}
public async Task<CollectionResult<Product>> SearchAsync(string query, int page, int pageSize)
{
try
{
var (products, total) = await _repository.SearchAsync(query, page, pageSize);
return CollectionResult<Product>.Succeed(products, page, pageSize, total);
}
catch (Exception ex)
{
_logger.LogError(ex, "Search failed for query: {Query}", query);
return CollectionResult<Product>.Fail(ex);
}
}
private async Task<Result> ValidateProductDto(CreateProductDto dto)
{
var errors = new List<(string field, string message)>();
if (string.IsNullOrWhiteSpace(dto.Name))
errors.Add(("name", "Product name is required"));
else if (dto.Name.Length > 100)
errors.Add(("name", "Product name must be 100 characters or less"));
if (dto.Price <= 0)
errors.Add(("price", "Price must be greater than zero"));
if (dto.InitialStock < 0)
errors.Add(("initialStock", "Initial stock cannot be negative"));
// Async validation
if (!string.IsNullOrWhiteSpace(dto.Name))
{
var categoryExists = await _repository.CategoryExistsAsync(dto.CategoryId);
if (!categoryExists)
errors.Add(("categoryId", "Invalid category"));
}
return errors.Any()
? Result.FailValidation(errors.ToArray())
: Result.Succeed();
}
}
// Controller
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductService _productService;
[HttpGet("{id}")]
public async Task<Result<Product>> Get(int id)
{
return await _productService.GetByIdAsync(id);
}
[HttpPost]
public async Task<Result<Product>> Create([FromBody] CreateProductDto dto)
{
return await _productService.CreateAsync(dto);
}
[HttpPatch("{id}/stock")]
public async Task<Result> UpdateStock(int id, [FromBody] UpdateStockDto dto)
{
return await _productService.UpdateStockAsync(id, dto.Quantity);
}
[HttpGet("search")]
public async Task<CollectionResult<Product>> Search(
[FromQuery] string q,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
return await _productService.SearchAsync(q, page, pageSize);
}
}public class OrderProcessingService
{
public async Task<Result<Order>> ProcessOrderAsync(ProcessOrderCommand command)
{
// Complete order processing pipeline
return await Result
// Validate command
.From(() => ValidateCommand(command))
// Load user
.ThenAsync(async () => await _userRepository.GetByIdAsync(command.UserId))
// Check user permissions
.Then(user => user.CanPlaceOrders
? Result<User>.Succeed(user)
: Result<User>.FailForbidden("User cannot place orders"))
// Verify user credit
.ThenAsync(async user => await _creditService.CheckCreditAsync(user.Id))
.Then(creditResult => creditResult.AvailableCredit >= command.TotalAmount
? Result.Succeed()
: Result.Fail("Insufficient credit"))
// Check inventory
.ThenAsync(async () => await CheckInventoryAsync(command.Items))
// Reserve inventory
.ThenAsync(async () => await ReserveInventoryAsync(command.Items))
// Create order
.ThenAsync(async () => await CreateOrderAsync(command))
// Process payment
.ThenAsync(async order => await ProcessPaymentAsync(order, command.PaymentMethod))
// Send confirmation
.ThenAsync(async order => await SendOrderConfirmationAsync(order))
// Handle any failures
.CompensateAsync(async problem =>
{
_logger.LogError("Order processing failed: {Problem}", problem.Detail);
// Rollback inventory reservation
await ReleaseInventoryAsync(command.Items);
// Notify user
await _notificationService.NotifyOrderFailedAsync(command.UserId, problem.Detail);
return Result<Order>.Fail(problem);
});
}
private async Task<Result> CheckInventoryAsync(List<OrderItem> items)
{
var unavailable = new List<string>();
foreach (var item in items)
{
var stock = await _inventoryService.GetStockAsync(item.ProductId);
if (stock < item.Quantity)
{
unavailable.Add($"{item.ProductName}: requested {item.Quantity}, available {stock}");
}
}
return unavailable.Any()
? Result.Fail("Insufficient inventory", string.Join("; ", unavailable))
: Result.Succeed();
}
}public User GetUser(int id)
{
if (id <= 0)
throw new ArgumentException("Invalid ID");
var user = _repository.FindById(id);
if (user == null)
throw new NotFoundException($"User {id} not found");
if (!user.IsActive)
throw new InvalidOperationException("User is not active");
return user;
}
// Usage
try
{
var user = GetUser(id);
// Process user
}
catch (ArgumentException ex)
{
// Handle validation error
}
catch (NotFoundException ex)
{
// Handle not found
}
catch (Exception ex)
{
// Handle other errors
}public Result<User> GetUser(int id)
{
if (id <= 0)
return Result<User>.FailValidation(("id", "ID must be positive"));
var user = _repository.FindById(id);
if (user == null)
return Result<User>.FailNotFound($"User {id} not found");
if (!user.IsActive)
return Result<User>.Fail("User inactive", "User account is not active");
return Result<User>.Succeed(user);
}
// Usage
var result = GetUser(id);
result.Match(
onSuccess: user => { /* Process user */ },
onFailure: problem =>
{
if (result.IsInvalid)
{
// Handle validation error
}
else if (problem.StatusCode == 404)
{
// Handle not found
}
else
{
// Handle other errors
}
}
);Result.Try() to wrap exception-throwing codeT to Result<T>// Step 1: Wrap existing code
public Result<User> GetUserSafe(int id)
{
return Result.Try(() => GetUserUnsafe(id));
}
// Step 2: Gradually refactor internals
public Result<User> GetUserRefactored(int id)
{
// Refactored implementation without exceptions
}
// Step 3: Update consumers
public async Task<IActionResult> GetUser(int id)
{
var result = await _service.GetUserRefactored(id);
return result.Match(
onSuccess: user => Ok(user),
onFailure: problem => Problem(problem)
);
}Contributions are welcome! Fork the repository and submit a pull request.
# Clone the repository
git clone https://github.com/managed-code-hub/Communication.git
# Build the solution
dotnet build
# Run tests
dotnet test
# Run benchmarks
dotnet run -c Release --project benchmarks/ManagedCode.Communication.BenchmarksThis project is licensed under the MIT License - see the LICENSE file for details.