Result 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.
- Overview
- Key Features
- Installation
- Logging Configuration
- Core Concepts
- Quick Start
- API Reference
- Railway-Oriented Programming
- Command Pattern and Idempotency
- Error Handling Patterns
- Integration Guides
- Performance
- Comparison
- Best Practices
- Examples
- Migration Guide
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:
- Performance overhead: Throwing exceptions is expensive
- Hidden control flow: Exceptions create invisible exit points in your code
- Unclear contracts: Methods don't explicitly declare what errors they might produce
- Testing complexity: Exception paths require separate test scenarios
The Result pattern solves these issues by:
- Explicit error handling: Errors are part of the method signature
- Performance: No exception throwing overhead
- Composability: Chain operations using railway-oriented programming
- Type safety: Compiler ensures error handling
- Testability: All paths are explicit and easy to test
Result: Represents success/failure without a valueResult<T>: Represents success with valueTor failureCollectionResult<T>: Represents collections with built-in paginationProblem: RFC 7807 compliant error details
- Leverage C# static interface members to centralize factory overloads for every result, command, and collection type.
IResultFactory<T>andICommandFactory<T>deliver a consistent surface while bridge helpers remove repetitive boilerplate.- Extending the library now only requires implementing the minimal
Succeed/Failcontract—the shared helpers provide the rest.
PaginationRequestencapsulates skip/take semantics, built-in normalization, and clamping helpers.PaginationOptionslets you define default, minimum, and maximum page sizes for a bounded API surface.PaginationCommandcaptures 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 results
- ASP.NET Core: Automatic HTTP response mapping
- SignalR: Hub filters for real-time error handling
- Microsoft Orleans: Grain call filters and surrogates
- Command Pattern: Built-in command infrastructure with idempotency
- Source-generated
LoggerCenterAPIs provide zero-allocation logging across ASP.NET Core filters, SignalR hubs, and command stores. - Call sites automatically check log levels, so you only pay for the logs you emit.
- Extend logging with additional
[LoggerMessage]partials to keep high-volume paths allocation free.
Pre-defined error categories with appropriate HTTP status codes:
- Validation errors (400 Bad Request)
- Not Found (404)
- Unauthorized (401)
- Forbidden (403)
- Internal Server Error (500)
- Custom enum-based errors
# Core libraryInstall-Package ManagedCode.Communication
# ASP.NET Core integrationInstall-Package ManagedCode.Communication.AspNetCore
# Minimal API extensionsInstall-Package ManagedCode.Communication.Extensions
# Orleans integrationInstall-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<PackageReferenceInclude="ManagedCode.Communication"Version="9.6.0" />
<PackageReferenceInclude="ManagedCode.Communication.AspNetCore"Version="9.6.0" />
<PackageReferenceInclude="ManagedCode.Communication.Extensions"Version="9.6.0" />
<PackageReferenceInclude="ManagedCode.Communication.Orleans"Version="9.6.0" />The library includes integrated logging for error scenarios. Configure logging to capture detailed error information:
varbuilder=WebApplication.CreateBuilder(args);// Add your logging configurationbuilder.Logging.AddConsole();builder.Logging.AddDebug();// Register other servicesbuilder.Services.AddControllers();// Configure Communication library - this enables automatic error loggingbuilder.Services.ConfigureCommunication();varapp=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:
varbuilder=WebApplication.CreateBuilder(args);builder.Services.ConfigureCommunication();varapp=builder.Build();// Apply the filter to a single endpointapp.MapGet("/orders/{id}",async(Guidid,IOrderServiceorders)=>awaitorders.GetAsync(id)).WithCommunicationResults();// Or apply it to a group so every route inherits the conversionapp.MapGroup("/orders").WithCommunicationResults().MapPost(string.Empty,async(CreateOrdercommand,IOrderServiceorders)=>awaitorders.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:
usingManagedCode.Communication.Extensions.Http;usingPolly;usingPolly.Retry;varpipeline=newResiliencePipelineBuilder<HttpResponseMessage>().AddRetry(newRetryStrategyOptions<HttpResponseMessage>{MaxRetryAttempts=3,Delay=TimeSpan.FromMilliseconds(200),ShouldHandle=newPredicateBuilder<HttpResponseMessage>().HandleResult(response =>!response.IsSuccessStatusCode)}).Build();varresult=awaithttpClient.SendForResultAsync<OrderDto>(()=>newHttpRequestMessage(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.
varservices=newServiceCollection();// Add loggingservices.AddLogging(builder =>{builder.AddConsole().SetMinimumLevel(LogLevel.Information);});// Configure Communication libraryservices.ConfigureCommunication();varserviceProvider=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:
publicstructResult{publicboolIsSuccess{get;}publicProblem?Problem{get;}}The generic Result<T> includes a value on success:
publicstructResult<T>{publicboolIsSuccess{get;}publicT?Value{get;}publicProblem?Problem{get;}}Implements RFC 7807 Problem Details for HTTP APIs:
publicclassProblem{publicstringType{get;set;}publicstringTitle{get;set;}publicintStatusCode{get;set;}publicstringDetail{get;set;}publicDictionary<string,object>Extensions{get;set;}}Use built-in helpers to convert technical Problem payloads into UI-friendly messages:
varproblem=Problem.Create("RegistrationUnavailable","Service is temporarily unavailable",503);problem.ErrorCode="RegistrationUnavailable";// Default message resolution chain:// ErrorCode mapper -> Detail -> Title -> defaultMessage -> "An error occurred"varmessage=problem.ToDisplayMessage(defaultMessage:"Please try again later");varregistrationMessages=newDictionary<string,string>{["RegistrationUnavailable"]="Registration is currently unavailable.",["RegistrationBlocked"]="Registration is temporarily blocked.",["RegistrationInviteRequired"]="Registration requires an invitation code."};// 1) Dictionary overloadvarbyDictionary=problem.ToDisplayMessage(registrationMessages,defaultMessage:"Please try again later");// 2) Tuple mappings overloadvarbyTuples=problem.ToDisplayMessage("Please try again later",("RegistrationUnavailable","Registration is currently unavailable."),("RegistrationBlocked","Registration is temporarily blocked."),("RegistrationInviteRequired","Registration requires an invitation code."));// 3) Delegate overloadstaticstring?ResolveRegistrationMessage(stringcode)=>codeswitch{"RegistrationUnavailable"=>"Registration is currently unavailable.","RegistrationBlocked"=>"Registration is temporarily blocked.","RegistrationInviteRequired"=>"Registration requires an invitation code.",
_ =>null};varbyDelegate=problem.ToDisplayMessage(ResolveRegistrationMessage,defaultMessage:"Please try again later");// The same overloads are available for Result, Result<T> and CollectionResult<T>varresultMessage=Result.Fail(problem).ToDisplayMessage(registrationMessages,defaultMessage:"Please try again later");// Typed extension accessif(problem.TryGetExtension("retryAfter",outintretryAfterSeconds)){Console.WriteLine($"Retry after: {retryAfterSeconds}s");}usingManagedCode.Communication;// Creating Resultsvarsuccess=Result.Succeed();varfailure=Result.Fail("Operation failed");// Results with valuesvaruserResult=Result<User>.Succeed(newUser{Id=1,Name="John"});varnotFound=Result<User>.FailNotFound("User not found");// Validation errorsvarinvalid=Result.FailValidation(("email","Email is required"),("age","Age must be positive"));// From exceptionstry{// risky operation}catch(Exceptionex){varerror=Result.Fail(ex);}if(result.IsSuccess){// Handle success}if(result.IsFailed){// Handle failure}if(result.IsInvalid){// Handle validation errors}// Pattern matchingresult.Match(onSuccess:()=>Console.WriteLine("Success!"),onFailure: problem =>Console.WriteLine($"Failed: {problem.Detail}"));// Basic successResult.Succeed()Result<T>.Succeed(Tvalue)CollectionResult<T>.Succeed(T[]items,intpageNumber,intpageSize,inttotalItems)// From operationsResult.From(Actionaction)Result<T>.From(Func<T>func)Result<T>.From(Task<T>task)// Try pattern with exception catchingResult.Try(Actionaction)Result<T>.Try(Func<T>func)// Basic failuresResult.Fail()
Result.Fail(stringtitle)
Result.Fail(stringtitle,stringdetail)
Result.Fail(Problemproblem)
Result.Fail(Exceptionexception)// HTTP status failures
Result.FailNotFound(stringdetail)
Result.FailUnauthorized(stringdetail)
Result.FailForbidden(stringdetail)// Validation failures
Result.FailValidation(params(stringfield,stringmessage)[]errors)Result.Invalid(stringmessage)Result.Invalid(stringfield,stringmessage)// Enum-based failuresResult.Fail<TEnum>(TEnumerrorCode)where TEnum : Enum// Map: Transform the valueResult<int>ageResult=userResult.Map(user =>user.Age);// Bind: Chain operations that return ResultsResult<Order>orderResult=userResult.Bind(user =>GetUserCart(user.Id)).Bind(cart =>CreateOrder(cart));// Tap: Execute side effectsResult<User>result=userResult.Tap(user =>_logger.LogInfo($"Processing user {user.Id}")).Tap(user =>_cache.Set(user.Id,user));// Ensure: Add validationResult<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 predicateResult<User>filtered=userResult.Where(user =>user.IsActive,"User is not active");// FailIf: Conditional failureResult<Order>order=orderResult.FailIf(o =>o.Total<=0,"Order total must be positive");// OkIf: Must satisfy conditionResult<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:
publicResult<Order>ProcessOrder(intuserId){returnResult.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));}publicasyncTask<Result<Order>>ProcessOrderAsync(intuserId){returnawaitResult.From(()=>GetUserAsync(userId)).ThenAsync(user =>ValidateUserAsync(user)).ThenAsync(user =>GetUserCartAsync(user.Id)).ThenAsync(cart =>CreateOrderAsync(cart)).ThenAsync(order =>ProcessPaymentAsync(order)).ThenAsync(order =>SendConfirmationAsync(order));}varresult=awaitGetPrimaryService().CompensateAsync(async error =>{_logger.LogWarning($"Primary service failed: {error.Detail}");returnawaitGetFallbackService();}).CompensateWith(defaultValue);// Final fallback// Merge: Stop at first failurevarfirstFailureResult=Result.Merge(ValidateName(name),ValidateEmail(email),ValidateAge(age));// MergeAll: aggregate all failuresvarallFailuresResult=Result.MergeAll(ValidateName(name),ValidateEmail(email),ValidateAge(age));if(allFailuresResult.TryGetProblem(outvarproblem)){// 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(outvaraggregateProblem)&&aggregateProblem.TryGetExtension("errors",outProblem[]?originalErrors)){foreach(varerrorinoriginalErrors){Console.WriteLine($"{error.StatusCode}: {error.Title} - {error.Detail}");}}// Combine: Aggregate valuesvarcombined=Result.Combine(GetUserProfile(),GetUserSettings(),GetUserPermissions());// Returns CollectionResult<T>// CombineAll: aggregate failures while preserving original errorsvarcombinedAll=Result.CombineAll(GetUserProfile(),GetUserSettings(),GetUserPermissions());The library includes built-in support for command pattern with distributed idempotency:
// Basic commandpublicclassCreateOrderCommand:Command<Order>{publicCreateOrderCommand(stringorderId,Orderorder):base(orderId,"CreateOrder"){Value=order;UserId="user123";CorrelationId=Guid.NewGuid().ToString();}}// Command with metadatavarcommand=newCommand("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:
varoptions=newPaginationOptions(defaultPageSize:25,maxPageSize:100);varrequest=PaginationRequest.Create(skip:0,take:0,options);// take defaults to 25// Rich factory surface without duplicate overloadsvarpaginationCommand=PaginationCommand.Create(request,options).WithCorrelationId(Guid.NewGuid().ToString());// Apply to results without manually recalculating metadatavarpage=CollectionResult<Order>.Succeed(orders,paginationCommand.Value!,totalItems:275,options);// Use enum-based command types when desiredenumPaginationCommandType{ListCustomers}vartypedCommand=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 storebuilder.Services.AddSingleton<ICommandIdempotencyStore,InMemoryCommandIdempotencyStore>();// Or use Orleans-based storebuilder.Services.AddSingleton<ICommandIdempotencyStore,OrleansCommandIdempotencyStore>();// Service with idempotent operationspublicclassPaymentService{privatereadonlyICommandIdempotencyStore_idempotencyStore;publicasyncTask<Result<Payment>>ProcessPaymentAsync(ProcessPaymentCommandcommand){// Automatic idempotency - returns cached result if already executedreturnawait_idempotencyStore.ExecuteIdempotentAsync(command.Id,async()=>{// This code runs only once per command IDvarpayment=await_paymentGateway.ChargeAsync(command.Amount);await_repository.SavePaymentAsync(payment);returnResult<Payment>.Succeed(payment);},command.Metadata);}}// Automatic idempotency with Orleans grainspublicclassOrderGrain:Grain,IOrderGrain{privatereadonlyICommandIdempotencyStore_idempotencyStore;publicasyncTask<Result<Order>>CreateOrderAsync(CreateOrderCommandcommand){// Uses ICommandIdempotencyGrain internally for distributed coordinationreturnawait_idempotencyStore.ExecuteIdempotentAsync(command.Id,async()=>{// Guaranteed to execute only once across the clustervarorder=newOrder{/* ... */};awaitSaveOrderAsync(order);returnResult<Order>.Succeed(order);});}}publicenumCommandExecutionStatus{NotStarted,// Command hasn't been processedProcessing,// Currently being processedCompleted,// Successfully completedFailed,// Processing failedExpired// Result expired from cache}// Check command statusvarstatus=await_idempotencyStore.GetCommandStatusAsync("command-id");if(status==CommandExecutionStatus.Completed){varresult=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.
- Static command factories generate monotonic version 7 identifiers via
Guid.CreateVersion7()and stamp a UTC timestamp so commands can be sorted chronologically even when sharded. - Factory helpers never mutate the correlation or trace identifiers; callers opt in by supplying values through fluent
WithCorrelationId,WithTraceId, and similar extension methods that return the same command instance. - Metadata mirrors the trace/span identifiers for workload-specific diagnostics without coupling transport-level identifiers to payload annotations.
| 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. |
- Correlation IDs bundle every command spawned from a single business request. Assign them at ingress and keep the value st able across retries so dashboards can answer “what commands ran because of this call?”.
- Trace/Span IDs follow distributed tracing semantics. Commands avoid creating new traces and instead persist the ambient
A ctivityidentifiers through serialization so telemetry back-ends can stitch spans together. - Both identifier sets are serialized together, enabling pivots between business-level correlation and technical call graphs wit hout extra configuration.
- Use
Command.Create(...)/Command<T>.Create(...)(or the matchingFrom(...)helpers) to get a version 7 identifier and U TC timestamp automatically. - Read or generate correlation IDs from HTTP headers or upstream messages and apply them via
.WithCorrelationId(...)before d ispatching commands. - Capture
Activity.TraceId/Activity.SpanIdthrough.WithTraceId(...)and.WithSpanId(...)(and metadata counterparts) wh en bridging to queues, Orleans, or background pipelines. - Serialization tests verify the identifiers round-trip, so consumers can rely on receiving the same values they emitted.
- Factory unit tests ensure commands created through the helpers carry version 7 identifiers, UTC timestamps, and derived
Comma ndTypevalues for traceability. - Idempotency regression tests assert that concurrent callers reuse cached results and propagate failures consistently, preservi ng correlation integrity when retry storms occur.
The shared idempotency helpers (CommandIdempotencyExtensions), default in-memory store, and test coverage work together to pro
tect concurrency, caching, and retry behaviour across hosts.
- Deterministic status transitions.
ExecuteIdempotentAsynconly invokes the provided delegate after atomically claiming th e command, writes the result, and then flips the status toCompleted, so retries either reuse cached output or wait for the in -flight execution to finish. - Batch reuse of cached outputs. Batch helpers perform bulk status/result lookups and bypass execution for already completed
commands, even when cached results are
nullor default values. - Fine-grained locking in the memory store. Per-command
SemaphoreSliminstances eliminate global contention, and reference counting ensures locks are released once no callers use a key. - Concurrency regression tests. Dedicated unit tests confirm that concurrent callers share a single execution, failed primar
y runs surface consistent exceptions, and the final status ends up in
Failedwhen appropriate.
- Missing-result ambiguity. If a store reports
Completedbut the result entry expired, the extensions currently return the default value. Stores that can distinguish “missing” from “stored default” should overrideTryGetCachedResultAsyncto trigger a re-execution. - Wait semantics rely on polling. Adaptive polling keeps responsiveness reasonable, but distributed stores can swap in push- style notifications if tail latency becomes critical.
- Status retention policies. The memory store’s cleanup removes status and result after a TTL; other implementations must pr ovide similar hygiene to avoid unbounded growth while keeping enough history for retries.
- Document store-specific retention guarantees so callers can tune retry windows.
- Consider extending the store contract with a boolean flag (or sentinel wrapper) that differentiates cached
defaultvalues f rom missing entries. - Monitor lock-pool growth in long-lived applications and log keys that never release to diagnose misbehaving callers before me mory pressure builds up.
publicResult<User>CreateUser(CreateUserDtodto){// Collect all validation errorsvarerrors=newList<(stringfield,stringmessage)>();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())returnResult<User>.FailValidation(errors.ToArray());varuser=newUser{/* ... */};returnResult<User>.Succeed(user);}publicclassUserRepository{privatereadonlyAppDbContext_context;privatereadonlyILogger<UserRepository>_logger;publicasyncTask<Result<User>>GetByIdAsync(intid){try{varuser=await_context.Users.AsNoTracking().FirstOrDefaultAsync(u =>u.Id==id);if(user==null)returnResult<User>.FailNotFound($"User {id} not found");returnResult<User>.Succeed(user);}catch(Exceptionex){_logger.LogError(ex,"Database error getting user {UserId}",id);returnResult<User>.Fail(ex);}}publicasyncTask<CollectionResult<User>>GetPagedAsync(intpage,intpageSize,Expression<Func<User,bool>>?filter=null,Expression<Func<User,object>>?orderBy=null){try{// Build query with IQueryable for efficient SQL generationIQueryable<User>query=_context.Users.AsNoTracking();// Apply filter if providedif(filter!=null)query=query.Where(filter);// Apply orderingquery=orderBy!=null?query.OrderBy(orderBy):query.OrderBy(u =>u.Id);// Get total count - generates COUNT(*) SQL queryvartotalItems=awaitquery.CountAsync();if(totalItems==0)returnCollectionResult<User>.Succeed(Array.Empty<User>(),page,pageSize,0);// Get page of data - generates SQL with OFFSET and FETCHvarusers=awaitquery.Skip((page-1)*pageSize).Take(pageSize).ToArrayAsync();returnCollectionResult<User>.Succeed(users,page,pageSize,totalItems);}catch(Exceptionex){_logger.LogError(ex,"Database error in GetPagedAsync");returnCollectionResult<User>.Fail(ex);}}// Example with complex querypublicasyncTask<CollectionResult<UserDto>>SearchUsersAsync(stringsearchTerm,intpage,intpageSize){try{varquery=_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 efficiencyvartotalItems=awaitquery.CountAsync();// Project to DTO and paginate - single SQL queryvarusers=awaitquery.OrderBy(u =>u.Name).Skip((page-1)*pageSize).Take(pageSize).Select(u =>newUserDto{Id=u.Id,Name=u.Name,Email=u.Email,LastLoginDate=u.LastLoginDate}).ToArrayAsync();returnCollectionResult<UserDto>.Succeed(users,page,pageSize,totalItems);}catch(Exceptionex){_logger.LogError(ex,"Search failed for term: {SearchTerm}",searchTerm);returnCollectionResult<UserDto>.Fail(ex);}}}publicclassOrderService{publicasyncTask<Result<Order>>CreateOrderAsync(CreateOrderDtodto){// Validate inputvarvalidationResult=ValidateOrderDto(dto);if(validationResult.IsFailed)returnvalidationResult;// Get uservaruserResult=await_userRepo.GetByIdAsync(dto.UserId);if(userResult.IsFailed)returnResult<Order>.Fail(userResult.Problem);// Check permissionsvaruser=userResult.Value;if(!user.CanCreateOrders)returnResult<Order>.FailForbidden("User cannot create orders");// Create orderreturnawaitResult.Try(async()=>{varorder=newOrder{UserId=user.Id,Items=dto.Items,Total=CalculateTotal(dto.Items)};await_orderRepo.SaveAsync(order);returnorder;});}}// 1. Install NuGet package// dotnet add package ManagedCode.Communication.AspNetCore// 2. Program.cs configurationvarbuilder=WebApplication.CreateBuilder(args);// Method 1: Simple configuration with auto-detection of environmentbuilder.AddCommunication();// ShowErrorDetails = IsDevelopment// Method 2: Custom configurationbuilder.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 hubsbuilder.Services.AddSignalR(options =>{options.AddCommunicationFilters();});varapp=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 |
AddCommunicationFilters() to ensure correct ordering. Manual registration may cause unexpected behavior.
[ApiController][Route("api/[controller]")]publicclassUsersController:ControllerBase{privatereadonlyIUserService_userService;[HttpGet("{id}")][ProducesResponseType(typeof(User),200)][ProducesResponseType(typeof(Problem),404)]publicasyncTask<Result<User>>GetUser(intid){returnawait_userService.GetUserAsync(id);}[HttpPost][ProducesResponseType(typeof(User),201)][ProducesResponseType(typeof(Problem),400)]publicasyncTask<Result<User>>CreateUser([FromBody]CreateUserDtodto){returnawait_userService.CreateUserAsync(dto);}[HttpGet][ProducesResponseType(typeof(CollectionResult<User>),200)]publicasyncTask<CollectionResult<User>>GetUsers([FromQuery]intpage=1,[FromQuery]intpageSize=10){returnawait_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 |
publicclassChatHub:Hub{publicasyncTask<Result<MessageDto>>SendMessage(stringuser,stringmessage){if(string.IsNullOrEmpty(message))returnResult<MessageDto>.FailValidation(("message","Message cannot be empty"));varmessageDto=newMessageDto{User=user,Message=message,Timestamp=DateTime.UtcNow};awaitClients.All.SendAsync("ReceiveMessage",user,message);returnResult<MessageDto>.Succeed(messageDto);}publicasyncTask<Result>JoinGroup(stringgroupName){if(string.IsNullOrEmpty(groupName))returnResult.FailValidation(("groupName","Group name is required"));awaitGroups.AddToGroupAsync(Context.ConnectionId,groupName);returnResult.Succeed();}}// Silo configurationvarbuilder=Host.CreateDefaultBuilder(args).UseOrleans(silo =>{silo.UseLocalhostClustering().UseOrleansCommunication();// Required for Result serialization});// Client configuration varclientBuilder=Host.CreateDefaultBuilder(args).UseOrleansClient(client =>{client.UseOrleansCommunication();// Required for Result serialization});That's it! The UseOrleansCommunication() extension automatically configures:
- Serialization for all Result types across grain boundaries
- Proper handling of Problem Details in distributed calls
- Support for CollectionResult with pagination
- Exception-to-failed-result conversion for grain methods returning
Task<Result>,Task<Result<T>>,Task<CollectionResult<T>>, and matchingValueTask<>forms - Structured error logging with the original exception object before a grain exception is converted to a failed Result, so observability backends keep the real stack trace
publicinterfaceIUserGrain:IGrainWithStringKey{Task<Result<UserState>>GetStateAsync();Task<Result>UpdateProfileAsync(UpdateProfileDtodto);Task<CollectionResult<Activity>>GetActivitiesAsync(intpage,intpageSize);}publicclassUserGrain:Grain,IUserGrain{privatereadonlyIPersistentState<UserState>_state;publicUserGrain([PersistentState("user")]IPersistentState<UserState>state){_state=state;}publicTask<Result<UserState>>GetStateAsync(){if(!_state.RecordExists)returnTask.FromResult(Result<UserState>.FailNotFound("User not found"));returnTask.FromResult(Result<UserState>.Succeed(_state.State));}publicasyncTask<Result>UpdateProfileAsync(UpdateProfileDtodto){if(!_state.RecordExists)returnResult.FailNotFound("User not found");// Validateif(string.IsNullOrEmpty(dto.DisplayName))returnResult.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();returnResult.Succeed();}publicasyncTask<CollectionResult<Activity>>GetActivitiesAsync(intpage,intpageSize){if(!_state.RecordExists)returnCollectionResult<Activity>.FailNotFound("User not found");// For real data, use a repository with Entity Frameworkvarrepository=GrainFactory.GetGrain<IActivityRepositoryGrain>(0);returnawaitrepository.GetUserActivitiesAsync(this.GetPrimaryKeyString(),page,pageSize);}}- Use structs:
ResultandResult<T>are value types (structs) to avoid heap allocation - Avoid boxing: Use generic methods to prevent boxing of value types
- Chain operations: Use railway-oriented programming to avoid intermediate variables
- Async properly: Use
ConfigureAwait(false)in library code - Cache problems: Reuse common Problem instances for frequent errors
The 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.
- Run the full suite:
dotnet test ManagedCode.Communication.Tests/ManagedCode.Communication.Tests.csproj - Generate lcov coverage:
dotnet test ManagedCode.Communication.Tests/ManagedCode.Communication.Tests.csproj /p:CollectCoverage=true /p:CoverletOutputFormat=lcov
Execution 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 | |
| HTTP Integration | ✅ Built-in | ❌ No | ❌ 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:
- Full-stack integration: ASP.NET Core + SignalR + Orleans
- Standardized errors: RFC 7807 Problem Details
- Pagination: Built-in collection results with paging
- Command pattern: Command infrastructure with idempotency
- Performance: Struct-based implementation for minimal overhead
// DO: Use Result for operations that can failpublicResult<User>GetUser(intid){varuser=_repository.FindById(id);returnuser!=null?Result<User>.Succeed(user):Result<User>.FailNotFound($"User {id} not found");}// DO: Chain operations using railway-oriented programmingpublicResult<Order>ProcessOrder(OrderDtodto){returnValidateOrder(dto).Then(CreateOrder).Then(CalculateTotals).Then(ApplyDiscounts).Then(SaveOrder);}// DO: Provide specific error informationpublicResultValidateEmail(stringemail){if(string.IsNullOrEmpty(email))returnResult.FailValidation(("email","Email is required"));if(!email.Contains("@"))returnResult.FailValidation(("email","Invalid email format"));returnResult.Succeed();}// DO: Use CollectionResult for paginated datapublicCollectionResult<Product>GetProducts(intpage,intpageSize){varproducts=_repository.GetPaged(page,pageSize);vartotal=_repository.Count();returnCollectionResult<Product>.Succeed(products,page,pageSize,total);}// DON'T: Throw exceptions from Result-returning methodspublicResult<User>GetUser(intid){if(id<=0)thrownewArgumentException("Invalid ID");// ❌ Don't throw// Instead:if(id<=0)returnResult<User>.FailValidation(("id","ID must be positive"));// ✅}// DON'T: Ignore Result valuesvarresult=UpdateUser(user);// ❌ Result ignoredDoSomethingElse();// Instead:varresult=UpdateUser(user);if(result.IsFailed)returnresult;// ✅ Handle the failure// DON'T: Mix Result and exceptionspublicasyncTask<User>GetUserMixed(intid){varresult=awaitGetUserAsync(id);if(result.IsFailed)thrownewException(result.Problem.Detail);// ❌ Mixing patternsreturnresult.Value;}// DON'T: Create generic error messagesreturnResult.Fail("Error");// ❌ Too vague// Instead:returnResult.Fail("User creation failed","Email already exists");// ✅// Domain ModelpublicclassProduct{publicintId{get;set;}publicstringName{get;set;}publicdecimalPrice{get;set;}publicintStock{get;set;}}// Service InterfacepublicinterfaceIProductService{Task<Result<Product>>GetByIdAsync(intid);Task<Result<Product>>CreateAsync(CreateProductDtodto);Task<Result>UpdateStockAsync(intid,intquantity);Task<CollectionResult<Product>>SearchAsync(stringquery,intpage,intpageSize);}// Service ImplementationpublicclassProductService:IProductService{privatereadonlyIProductRepository_repository;privatereadonlyILogger<ProductService>_logger;publicasyncTask<Result<Product>>GetByIdAsync(intid){returnawaitResult.Try(async()=>{varproduct=await_repository.FindByIdAsync(id);returnproduct??thrownewKeyNotFoundException($"Product {id} not found");}).CompensateAsync(async error =>{_logger.LogWarning("Product {Id} not found, checking archive",id);vararchived=await_repository.FindInArchiveAsync(id);returnarchived!=null?Result<Product>.Succeed(archived):Result<Product>.FailNotFound($"Product {id} not found");});}publicasyncTask<Result<Product>>CreateAsync(CreateProductDtodto){// ValidationvarvalidationResult=awaitValidateProductDto(dto);if(validationResult.IsFailed)returnResult<Product>.Fail(validationResult.Problem);// Check for duplicatesvarexisting=await_repository.FindByNameAsync(dto.Name);if(existing!=null)returnResult<Product>.Fail("Duplicate product",$"Product with name '{dto.Name}' already exists");// Create productvarproduct=newProduct{Name=dto.Name,Price=dto.Price,Stock=dto.InitialStock};await_repository.AddAsync(product);await_repository.SaveChangesAsync();returnResult<Product>.Succeed(product);}publicasyncTask<Result>UpdateStockAsync(intid,intquantity){returnawaitGetByIdAsync(id).Then(product =>{if(product.Stock+quantity<0)returnResult.Fail("Insufficient stock",$"Cannot reduce stock by {Math.Abs(quantity)}. Current stock: {product.Stock}");product.Stock+=quantity;returnResult.Succeed();}).ThenAsync(async()=>{await_repository.SaveChangesAsync();returnResult.Succeed();});}publicasyncTask<CollectionResult<Product>>SearchAsync(stringquery,intpage,intpageSize){try{var(products,total)=await_repository.SearchAsync(query,page,pageSize);returnCollectionResult<Product>.Succeed(products,page,pageSize,total);}catch(Exceptionex){_logger.LogError(ex,"Search failed for query: {Query}",query);returnCollectionResult<Product>.Fail(ex);}}privateasyncTask<Result>ValidateProductDto(CreateProductDtodto){varerrors=newList<(stringfield,stringmessage)>();if(string.IsNullOrWhiteSpace(dto.Name))errors.Add(("name","Product name is required"));elseif(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 validationif(!string.IsNullOrWhiteSpace(dto.Name)){varcategoryExists=await_repository.CategoryExistsAsync(dto.CategoryId);if(!categoryExists)errors.Add(("categoryId","Invalid category"));}returnerrors.Any()?Result.FailValidation(errors.ToArray()):Result.Succeed();}}// Controller[ApiController][Route("api/[controller]")]publicclassProductsController:ControllerBase{privatereadonlyIProductService_productService;[HttpGet("{id}")]publicasyncTask<Result<Product>>Get(intid){returnawait_productService.GetByIdAsync(id);}[HttpPost]publicasyncTask<Result<Product>>Create([FromBody]CreateProductDtodto){returnawait_productService.CreateAsync(dto);}[HttpPatch("{id}/stock")]publicasyncTask<Result>UpdateStock(intid,[FromBody]UpdateStockDtodto){returnawait_productService.UpdateStockAsync(id,dto.Quantity);}[HttpGet("search")]publicasyncTask<CollectionResult<Product>>Search([FromQuery]stringq,[FromQuery]intpage=1,[FromQuery]intpageSize=20){returnawait_productService.SearchAsync(q,page,pageSize);}}publicclassOrderProcessingService{publicasyncTask<Result<Order>>ProcessOrderAsync(ProcessOrderCommandcommand){// Complete order processing pipelinereturnawaitResult// 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()=>awaitCheckInventoryAsync(command.Items))// Reserve inventory.ThenAsync(async()=>awaitReserveInventoryAsync(command.Items))// Create order.ThenAsync(async()=>awaitCreateOrderAsync(command))// Process payment.ThenAsync(async order =>awaitProcessPaymentAsync(order,command.PaymentMethod))// Send confirmation.ThenAsync(async order =>awaitSendOrderConfirmationAsync(order))// Handle any failures.CompensateAsync(async problem =>{_logger.LogError("Order processing failed: {Problem}",problem.Detail);// Rollback inventory reservationawaitReleaseInventoryAsync(command.Items);// Notify userawait_notificationService.NotifyOrderFailedAsync(command.UserId,problem.Detail);returnResult<Order>.Fail(problem);});}privateasyncTask<Result>CheckInventoryAsync(List<OrderItem>items){varunavailable=newList<string>();foreach(variteminitems){varstock=await_inventoryService.GetStockAsync(item.ProductId);if(stock<item.Quantity){unavailable.Add($"{item.ProductName}: requested {item.Quantity}, available {stock}");}}returnunavailable.Any()?Result.Fail("Insufficient inventory",string.Join("; ",unavailable)):Result.Succeed();}}publicUserGetUser(intid){if(id<=0)thrownewArgumentException("Invalid ID");varuser=_repository.FindById(id);if(user==null)thrownewNotFoundException($"User {id} not found");if(!user.IsActive)thrownewInvalidOperationException("User is not active");returnuser;}// Usagetry{varuser=GetUser(id);// Process user}catch(ArgumentExceptionex){// Handle validation error}catch(NotFoundExceptionex){// Handle not found}catch(Exceptionex){// Handle other errors}publicResult<User>GetUser(intid){if(id<=0)returnResult<User>.FailValidation(("id","ID must be positive"));varuser=_repository.FindById(id);if(user==null)returnResult<User>.FailNotFound($"User {id} not found");if(!user.IsActive)returnResult<User>.Fail("User inactive","User account is not active");returnResult<User>.Succeed(user);}// Usagevarresult=GetUser(id);result.Match(onSuccess: user =>{/* Process user */},onFailure: problem =>{if(result.IsInvalid){// Handle validation error}elseif(problem.StatusCode==404){// Handle not found}else{// Handle other errors}});- Start with new code: Implement Result pattern in new features
- Wrap existing methods: Use
Result.Try()to wrap exception-throwing code - Update interfaces: Change return types from
TtoResult<T> - Convert controllers: Update API endpoints to return Result types
- Remove try-catch blocks: Replace with Result pattern handling
// Step 1: Wrap existing codepublicResult<User>GetUserSafe(intid){returnResult.Try(()=>GetUserUnsafe(id));}// Step 2: Gradually refactor internalspublicResult<User>GetUserRefactored(intid){// Refactored implementation without exceptions}// Step 3: Update consumerspublicasyncTask<IActionResult>GetUser(intid){varresult=await_service.GetUserRefactored(id);returnresult.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.
- Issues: GitHub Issues
- Source Code: GitHub Repository
- Inspired by F# and Rust Result types
- Railway-oriented programming concepts
- RFC 7807 Problem Details for HTTP APIs
- Built for seamless integration with Microsoft Orleans
- Optimized for ASP.NET Core applications