Result<T> makes an operation's success or failure explicit. Failures carry an RFC 7807 Problem;
long-running operations report typed progress through CQRS streams. The library also provides railway
composition, reliable command execution, and ASP.NET Core, SignalR, and Orleans adapters for .NET 10.
- Installation
- Quick start
- Results and problems
- Static analysis guidance
- Collections and pagination
- Railway composition
- HTTP integration
- Commands and identity
- Reliable command execution
- CQRS streaming
- Orleans integration
- Serialization
- Logging and telemetry
- Registration reference
- Development and testing
Choose the package for the boundary you need. All packages target .NET 10 and use the same release version. Package links open the latest stable release on NuGet; badges update automatically.
| Package | Latest version | Downloads | Public API | Dependencies within this library |
|---|---|---|---|---|
| ManagedCode.Communication | Results, problems, collections, commands, execution, CQRS contracts and HTTP stream client, diagnostics | None | ||
| ManagedCode.Communication.Extensions | Railway operators, HTTP result clients, IHttpClientFactory resilience, OpenTelemetry registration helpers |
Core | ||
| ManagedCode.Communication.AspNetCore | Minimal API and MVC filters, SignalR filter, SSE transport, host logging setup | Core and Extensions | ||
| ManagedCode.Communication.Orleans | Native serialization surrogates, grain-call filters, Orleans idempotency and distributed limiter adapters | Core and AspNetCore |
Core and Extensions do not depend on ASP.NET Core. Orleans currently brings the AspNetCore package transitively. The core package uses Microsoft.Extensions caching/logging and System.Threading.RateLimiting; it does not require the OpenTelemetry SDK. Extensions adds the OpenTelemetry hosting and HTTP client registration helpers.
For a web application:
dotnet add package ManagedCode.Communication.AspNetCoreFor a worker, console, or browser client using railway operators:
dotnet add package ManagedCode.Communication.ExtensionsFor Orleans:
dotnet add package ManagedCode.Communication.OrleansA core-only application can install ManagedCode.Communication directly:
dotnet add package ManagedCode.CommunicationResults and problems work without dependency injection or logging registration:
using ManagedCode.Communication;
static Result<int> Divide(int dividend, int divisor)
{
if (divisor == 0)
return Result.FailValidation(("divisor", "Must not be zero."));
return Result<int>.Succeed(dividend / divisor);
}
static void PrintDivision(int dividend, int divisor)
{
var result = Divide(dividend, divisor);
if (result.IsFailed)
{
Console.WriteLine(result.Problem.Detail);
return;
}
Console.WriteLine(result.Value);
}
PrintDivision(12, 3);Use results for expected failures that callers can handle. Inspect the returned result: C# does not require you to handle it. Exceptions remain available for exceptional conditions; adapters can convert them into a failed result at a chosen boundary.
Choose the return type from what a successful operation promises:
| Contract | Use | Successful return |
|---|---|---|
| A command completed; there is no response data | Result |
Result.Succeed() |
| A query or command returns one value | Result<T> |
Result<T>.Succeed(value) with a non-null value |
| A list query returns zero or more items | CollectionResult<T> |
CollectionResult<T>.Succeed(items); an empty collection is valid |
| A failed operation needs structured details | Problem inside a failed result |
A failure factory, such as Result.FailNotFound(detail) |
Problem carries Type, Title, StatusCode, Detail, Instance, and extension fields.
Result saved = Result.Succeed();
Result<int> count = Result.Succeed(42);
Result<int> explicitCount = Result<int>.Succeed(42);
Result<int> implicitCount = 42;
Result missing = Result.FailNotFound("Order does not exist.");
Result<int> typedMissing = missing;
Result<int> unavailable = Problem.Create("Unavailable", "Try again later.", 503);A failed untyped Result or a Problem can become a typed failure. A successful untyped Result has no
value to supply to Result<T>; converting it to a typed result produces a failure. Create typed successes
from their actual values.
Result<T>.IsSuccess guarantees a non-null Value. Passing null to a typed success factory, or
replacing a successful payload with null in an initializer, throws ArgumentNullException; a nullable
C# type argument does not allow a successful null payload. Result<int>.Succeed() still carries zero,
while Result<string>.Succeed() has no valid default payload and throws. Prefer the explicit value overload
for typed successes. Result<string?>.Succeed((string?)null) and Result<int?>.Succeed((int?)null) also
throw; nullable type arguments do not change the success contract.
Result and Result<T> are structs. Their state is fixed after construction, but referenced payloads and
Problem objects remain mutable. Avoid changing a shared problem after returning or publishing it.
default(Result) is not a successful result; use a factory to state the intended outcome.
The static factory interfaces (IResultFactory<T>, IResultValueFactory<,>, ICommandFactory<T>, and
ICommandValueFactory<,>) support the shared factory surface. Custom implementations should reuse these
contracts rather than duplicate every factory overload.
Use IsFailed for a failure guard and preserve the incoming Problem when returning another result type:
static Result<int> GetTextLength(Result<string> text)
{
if (text.IsFailed)
return Result<int>.Fail(text.Problem);
return Result.Succeed(text.Value.Length);
}IsSuccess has [MemberNotNullWhen(true, nameof(Value))]; IsFailed has the corresponding
[MemberNotNullWhen(false, nameof(Value))]. On failure, both status properties establish a non-null
Problem. With nullable analysis enabled, the compiler understands both branches. After the guard above,
use text.Value directly: an additional Value is null, Value!, Value?.Length, or Value ?? ... is
unnecessary. Inside the failure branch, Problem is not null and Problem! are unnecessary too.
These guarantees concern the payload itself. A nullable property inside the payload, or a nullable element inside a collection, still needs its own domain check. Keep the guard and value access on the same result; reassigning it or checking a different result does not establish the guarantee.
Use non-generic Result for acknowledgements, completed mutations, and deliberately ignored events:
static Result ApplyEvent(bool applies, Action apply)
{
if (!applies)
return Result.Succeed(); // Ignoring this event is a successful outcome.
apply();
return Result.Succeed();
}This example lets exceptions propagate; use Result.From(apply) at a boundary where exceptions should
be converted into failures. Do not invent a nullable typed payload when callers only need the status.
A method returning Result<T> cannot return Result.Succeed() as a typed success: the implicit conversion
produces a failure because there is no value. The reverse conversion, Result<T> to Result, intentionally
discards the value while preserving success or failure.
When the query promises a specific object, absence is a domain failure:
static Result<string> RequireDisplayName(string? displayName)
{
if (displayName is null)
return Result.FailNotFound("Display name does not exist.");
return Result.Succeed(displayName);
}The non-generic failure above converts to a typed failure and preserves its problem. When forwarding a
failure from a different Result<T>, use Result<U>.Fail(source.Problem) inside source.IsFailed instead
of constructing a generic error and losing the original details.
If absence is part of a successful query's actual response, express it in a named domain response. For example, querying shipment state may succeed before the order has a shipment:
using ManagedCode.Communication;
public sealed record Shipment(string TrackingNumber);
public sealed record ShipmentState(Shipment? Shipment);
public static class ShipmentQueries
{
public static Result<ShipmentState> ReadShipmentState(Shipment? shipment) =>
Result.Succeed(new ShipmentState(shipment));
}The ShipmentState payload is always non-null. Its Shipment property can be null because that is an
explicit domain state; consumers inspect that property after checking the result. If no caller needs a
response at all, use non-generic Result instead. A list query uses an empty successful collection, as
shown in Collections and pagination.
Result<T>.IsEmpty means Value is null. It does not mean the operation succeeded with no data, and it
does not inspect string length or collection count. Every typed success has IsEmpty == false, including
Result<string>.Succeed(string.Empty) and Result<int[]>.Succeed([]). A failed result can still have a
non-null value, including a value-type default, so !IsEmpty is not proof of success. Check IsFailed
or IsSuccess to determine the outcome.
CollectionResult<T>.IsEmpty has a different, collection-specific meaning: the page contains zero items.
Check status first so a failed query is not mistaken for a successful empty page.
| Need | API | Behavior |
|---|---|---|
| Explicit success without data | Result.Succeed() |
Produces Result |
| Explicit success with data | Result.Succeed(value) or Result<T>.Succeed(value) |
Produces Result<T> and rejects a null payload |
| Configure a new payload | Result.Succeed<T>(instance => ...) |
Creates T with its public parameterless constructor, then configures it |
| Keep a known failure | Result<T>.Fail(problem) |
Preserves the supplied problem |
| Wrap a synchronous operation with no return value | Result.From(Action) |
Converts a thrown exception into a failure |
| Wrap a synchronous value | Result<T>.From(Func<T>) |
Wraps the returned value; converts exceptions into failures |
| Run a function already returning a result | Result<T>.From(Func<Result<T>>) |
Preserves the returned result without nesting it |
| Await a value or an existing result | Result<T>.From(Task<T>) / Result<T>.From(Task<Result<T>>) |
Wraps a value or preserves a result |
| Invoke and await a deferred operation | Result<T>.From(Func<Task<T>>) / Result<T>.From(Func<Task<Result<T>>>) |
Includes invocation inside the exception boundary |
| Preserve ValueTask shape | Corresponding ValueTask<T> / Func<ValueTask<T>> overloads, including existing-result variants |
Returns ValueTask<Result<T>> |
Result<int> parsed = Result<int>.From(() => int.Parse(text));
// LoadAsync returns Task<Order>; FindAsync returns Task<Result<Order>>.
Task<Result<Order>> loaded = Result<Order>.From(
() => repository.LoadAsync(orderId, cancellationToken), cancellationToken);
Task<Result<Order>> preserved = Result<Order>.From(
() => orders.FindAsync(orderId, cancellationToken), cancellationToken);Result.From<T>(...) provides the corresponding typed overloads too. Prefer a factory when the method
can throw before returning its task: From(service.CallAsync()) cannot catch an exception thrown while
evaluating that argument. Pass the operation's cancellation token into the operation itself. These general
From helpers convert caught exceptions, including cancellation, into failures; HTTP/CQRS adapters have
their own documented caller-cancellation behavior. From does not retry; use command execution when
retry, timeout, or idempotency is required.
var validation = Result.FailValidation(
("email", "Email is required."),
("name", "Name is required."));
var conflict = Result.FailInvalidState("Order is already paid.");
var unauthorized = Result.FailUnauthorized();
var forbidden = Result.FailForbidden();
var missing = Result.FailNotFound("Order does not exist.");
var serverFailure = Result.Fail("Order could not be saved", "The database is unavailable.");Primitive helpers cover nulls, invalid arguments, and range failures. Validation and argument failures use
400, invalid state uses 409, not found uses 404, unauthorized uses 401, forbidden uses 403, and a
general server failure uses 500. Use Problem.Create(...) when the domain needs a specific status or
machine-readable code:
var problem = Problem.Create("Payment declined", "Use another payment method.", 422);
problem.ErrorCode = "payment_declined";
problem.Extensions["provider"] = "payments";
return Result<Receipt>.Fail(problem);Fail(exception) and exception-catching From/Try helpers report the original exception immediately and
return problem data. They do not retain an exception or its stack trace inside the result. See
Logging and telemetry.
Use ToDisplayMessage to choose an application message by error code, with a default message when needed:
using ManagedCode.Communication.Results.Extensions;
var messages = new Dictionary<string, string>
{
["payment_declined"] = "Please choose another payment method."
};
var message = result.ToDisplayMessage(messages, defaultMessage: "The operation could not be completed.");Resolver delegates, dictionaries, key-value sequences, and tuple mappings are supported. Keep user-facing messages separate from operational details that may contain information unsuitable for a public response.
Enable C# nullable analysis (<Nullable>enable</Nullable>) so the compiler can use the result status
annotations. The published Communication packages currently do not include a dedicated usage analyzer.
The following are suggested rules for a future analyzer or an application's own rules:
| Pattern to diagnose | Correct usage or safe fix |
|---|---|
| A typed success receives a known null, including with nullable type arguments | Supply the real payload or deliberately change a status-only contract to Result; changing a contract needs review |
Parameterless Result<T>.Succeed() where default(T) is null |
Supply an explicit non-null payload or use Result for an acknowledgement |
| A value null check, conditional access, coalescing, or null-forgiving operator after proven success | Use the payload directly; only remove checks proven redundant by control flow |
A Problem null check or null-forgiving operator inside proven failure |
Use Problem directly |
!result.IsSuccess |
Prefer the equivalent result.IsFailed failure guard |
A status-only success implicitly converted to Result<T> |
Construct a typed success from its value or review the declared return contract |
A collection's compatibility Value member used for page access |
Prefer the non-null Collection member |
Rules must resolve Communication's actual symbols and track the same result through control flow.
They must preserve checks on nullable payload members and collection elements. A suppression such as
Value! is not evidence that success was checked. Likewise, separate LINQ lambdas do not share nullable
flow facts: Where(result => result.IsSuccess) does not make Value non-null inside a later Select.
Keep the check and use together when collecting successful values:
static List<string> CollectSuccessfulValues(IEnumerable<Result<string>> results)
{
var values = new List<string>();
foreach (var result in results)
{
if (result.IsFailed)
continue;
values.Add(result.Value);
}
return values;
}Do not automatically rewrite absence into an error, change return types, or discard an incoming problem. Those choices depend on the operation's domain contract.
Use an empty successful page when a list query has no matches:
using ManagedCode.Communication;
using ManagedCode.Communication.CollectionResultT;
static Result<int> CountItems(CollectionResult<string> page)
{
if (page.IsFailed)
return Result<int>.Fail(page.Problem);
return Result.Succeed(page.Collection.Length);
}
var emptyPage = CollectionResult<string>.Succeed();
var count = CountItems(emptyPage); // Successful count of zero.Collection is always non-null, including on default or failed results. Prefer it to the nullable-annotated
compatibility member Value. Collection availability and item count do not replace the status check;
nullable elements still require their own checks.
PaginationRequest stores Skip/Take and provides normalization, page conversion, clamping, and slicing.
PaginationCommand carries the request through the command contract.
using ManagedCode.Communication.CollectionResultT;
using ManagedCode.Communication.Commands;
var options = new PaginationOptions(defaultPageSize: 25, maxPageSize: 100);
var request = PaginationRequest.FromPage(pageNumber: 2, pageSize: 25, options);
var products = await repository.ReadAsync(request.Skip, request.Take, cancellationToken);
var total = await repository.CountAsync(cancellationToken);
return CollectionResult<Product>.Succeed(products, request, total, options);Supply the total number of items, not the number in the current page. Prefer normalized bounds before
reading data; PaginationRequest.Create(skip, take, options) normalizes incoming skip/take values, while
ToSlice(totalItems, options) returns the bounded offset and length for an in-memory collection.
Install Extensions and import ManagedCode.Communication.Extensions:
using ManagedCode.Communication;
using ManagedCode.Communication.Extensions;
var receipt = await LoadCartAsync(cartId)
.EnsureAsync(cart => !cart.IsEmpty, Problem.Validation(("cart", "Must contain an item.")))
.BindAsync(cart => ChargeAsync(cart.Total))
.Map(payment => payment.Receipt)
.TapAsync(receipt => logger.LogInformation("Issued {ReceiptId}", receipt.Id));Success-path operators skip their work when the incoming result failed and preserve its problem. Recovery
operators run on failure. Task and ValueTask receivers preserve their async shape, including result,
collection, conversion, and execution helpers.
| Operator | Purpose |
|---|---|
Map / MapAsync |
Transform a successful value |
Bind / Then |
Run a step returning another result |
Tap / Do |
Run a side effect while preserving the result |
Ensure |
Turn a failed predicate into a problem |
Match |
Project success and failure into a chosen output |
Compensate |
Recover using the incoming problem |
Else |
Supply an alternative on failure |
Finally |
Run after either outcome |
Map accepts a synchronous mapper even on an async receiver; MapAsync accepts an asynchronous mapper.
The async variants support matching Task and ValueTask delegates.
Aggregation lives on Result in Core and needs no Extensions reference:
var validation = Result.MergeAll(ValidateEmail(email), ValidateName(name));
var orders = Result.CombineAll(LoadOrder(firstId), LoadOrder(secondId));| Factory | Behavior |
|---|---|
Merge |
Return the first failure, or success when every input succeeds |
MergeAll |
Aggregate every failure |
Combine |
Collect success values, or return the first failure |
CombineAll |
Collect success values, or aggregate every failure |
When every failure is validation-related, the aggregate merges validation fields. Mixed failures retain the
original problems under Problem.Extensions["errors"] rather than disguising them as validation failures.
WithCommunicationResults() belongs to AspNetCore, in the MinimalApi namespace. Apply it to an endpoint
or a group:
using ManagedCode.Communication;
using ManagedCode.Communication.AspNetCore.Extensions;
using ManagedCode.Communication.AspNetCore.MinimalApi;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCommunicationAspNetCore(); // connect failure logging to the host
var app = builder.Build();
app.MapGet("/orders/{id}", async (Guid id, IOrderService orders) => await orders.FindAsync(id))
.WithCommunicationResults();
app.MapGroup("/orders")
.WithCommunicationResults()
.MapPost(string.Empty, async (CreateOrder request, IOrderService orders) =>
await orders.CreateAsync(request));
app.Run();The HTTP wire contract is a raw success payload or an RFC 7807 problem, not a serialized Result<T> envelope.
Minimal API successes map to 200 OK with a value or 204 No Content without one; failures use the problem's
status and fields. Native Microsoft.AspNetCore.Http.IResult responses pass through unchanged.
using ManagedCode.Communication.AspNetCore.Extensions;
builder.Services.AddControllers();
builder.Services.AddCommunication(); // host logging plus MVC filters
app.MapControllers();A controller can return Result or Result<T> directly. The filters convert results, model-validation
failures, and exceptions at the MVC boundary. To register only the filters, use
AddControllers(options => options.AddCommunicationFilters()) or services.AddCommunicationFilters();
choose one registration path.
UseCommunication() currently adds no middleware. It is not needed for these filters or Minimal API result
mapping. These registrations also do not install a global exception handler for arbitrary application code.
The Extensions package reads the same raw-payload/RFC 7807 wire contract:
using ManagedCode.Communication.Extensions.Http;
Result<OrderDto> order = await httpClient.SendForResultAsync<OrderDto>(
() => new HttpRequestMessage(HttpMethod.Get, $"/orders/{orderId}"),
cancellationToken);Use the success-projection overload for files, empty/optional bodies, or another non-JSON response:
var invoice = await httpClient.SendForResultAsync(
() => new HttpRequestMessage(HttpMethod.Get, $"/orders/{orderId}/invoice"),
static async (response, token) => await response.Content.ReadAsByteArrayAsync(token),
cancellationToken);The library still handles transport failures and RFC 7807 failures. Serialized result envelopes are rejected.
Plain-text remote errors remain supported. Connection failures produce 503 and client-side timeouts
produce 504; explicit caller cancellation propagates OperationCanceledException.
For command-aware reliability, supply the command, a fresh-request factory, and an execution runtime:
var response = await httpClient.SendForResultAsync<OrderDto, Command<OrderQuery>>(
command,
current => new HttpRequestMessage(HttpMethod.Get, $"/orders/{current.Value!.OrderId}"),
execution,
cancellationToken);The factory must create a new message for each attempt and preserve the same business idempotency key.
using ManagedCode.Communication.Extensions.Http;
services.AddHttpClient<CatalogClient>()
.AddCommunicationResilienceHandler(options =>
{
options.Execution.Retry.MaxRetries = 3;
options.Execution.CircuitBreaker.SamplingDuration = TimeSpan.FromMinutes(1);
});This handler uses native command execution and returns the final HttpResponseMessage. It honors
Retry-After and partitions circuit state by request authority. Automatic replay is limited to content-free
GET, HEAD, OPTIONS, and TRACE. Unsafe methods and requests with content pass through once; use an
explicit command/request-factory overload when the application has established that replay is safe.
ConfigureHttpClientDefaults(client => client.AddCommunicationResilienceHandler()) sets shared defaults.
RemoveCommunicationResilienceHandler() disables this handler for an individual client.
using ManagedCode.Communication.AspNetCore.Extensions;
builder.Services.AddSignalR(options => options.AddCommunicationHubFilter());The filter reports and converts exceptions for Result-returning hub methods. Streaming hub methods use the CQRS stream contract described below.
Command and Command<T> implement ICommand. They carry the operation identity, logical command type,
UTC timestamp, optional correlation/causation identifiers, user/session identifiers, and metadata.
using ManagedCode.Communication.Commands;
var command = Command.From("order.place", new PlaceOrder(cartId))
.WithCorrelationId(correlationId)
.WithCausationId(parentCommandId)
.WithUserId(userId)
.WithSessionId(sessionId)
.WithMetadata(metadata => metadata.Priority = CommandPriority.High);Factories generate a UUIDv7 CommandId and UTC timestamp. Supply the optional trailing commandId only
when preserving an externally supplied idempotency key or replaying an existing message. Keep that identity
stable across retries; a newly created command is a new operation.
var replayed = Command<PlaceOrder>.From(payload, idempotencyKey);Correlation, causation, trace, span, user, and session identifiers remain unset until the caller assigns them.
Use WithTraceId and WithSpanId to propagate an existing W3C activity context. CommandMetadata also
contains retry budget, timeout, priority, trace metadata, tags, and application extension fields.
Serialized actor identifiers are data, not proof of authority. Authorization, idempotency scopes, and limiter identity selectors must read trusted application context.
The native executor composes retry, cooperative timeouts, idempotency, circuit breaking, rate limiting, and
diagnostics around ICommand. It has no separate request envelope or external resilience-library dependency.
using ManagedCode.Communication.Commands;
using ManagedCode.Communication.Commands.Execution;
using ManagedCode.Communication.Commands.Extensions;
services.AddCommandExecution(options =>
{
options.Retry.Enabled = true;
options.Retry.MaxRetries = 3;
options.Retry.Delay = TimeSpan.FromMilliseconds(200);
options.Retry.BackoffType = RetryBackoffType.Exponential;
options.Retry.UseJitter = true;
options.Timeout.TotalTimeout = TimeSpan.FromSeconds(15);
options.Timeout.AttemptTimeout = TimeSpan.FromSeconds(5);
});
var executor = serviceProvider.GetRequiredService<ICommandExecutor>();
var command = Command.From("catalog.refresh", new RefreshCatalog(sourceId));
Result<Catalog> wrapped = await executor.ExecuteValueAsync(
command,
(current, token) => catalogService.RefreshAsync(current.Value!, token),
cancellationToken);
Result<Catalog> preserved = await executor.ExecuteResultAsync(
command,
(current, token) => catalogService.RefreshAsResultAsync(current.Value!, token),
cancellationToken);ExecuteValueAsync wraps raw values; ExecuteResultAsync preserves an existing result without nesting.
Task handlers produce Task results and ValueTask handlers produce ValueTask results. No-value handlers are
also supported. Without DI, use CommandExecutionRuntime with static CommandExecutor.ExecuteAsync or
Result<T>.ExecuteAsync entry points.
MaxRetries counts retries after the first attempt: 3 allows up to four physical attempts. Execution stops
on success, a permanent failure, cancellation, total timeout, or exhausted budget.
| Source | Default retry decision |
|---|---|
| Failed result | Status 408, 429, 500, 502, 503, or 504 |
| Exception | TimeoutException, HttpRequestException, or IOException |
| Caller cancellation | Never retry |
| Other failures | Do not retry unless an application predicate selects them |
Delay selection uses DelayGenerator first, then an authoritative HTTP/rate-limiter Retry-After, then
constant, linear, or exponential backoff. MaxDelay caps built-in backoff after jitter. Custom/hinted delays
are checked against MaxRetryAfter; a hint above that limit returns the current failure with
retryAfterExceedsMaximum = true instead of retrying before the hinted time.
ShouldRetry and ShouldRetryException customize the default decisions. ShouldRetryAsync replaces both
with a command-aware decision. OnRetry and OnRetriesExhausted observe retries; observer errors are logged
without replacing the handler's outcome. Predicate or delay-generator errors are infrastructure failures.
CommandMetadata.MaxRetries can lower the global budget. RetryCount carries retries consumed on previous
hops. Exhausted retryable failures expose retriesExhausted and retryAttempts in Problem.Extensions.
Disabling retry also disables its predicates, callbacks, and exhaustion metadata.
Total timeout covers the whole execution; attempt timeout covers one physical attempt. Cancellation is cooperative: handlers receive a cancellation token and are awaited even if they ignore it. This prevents a still-running side effect from outliving its limiter permit or idempotency claim. A late handler completion can therefore return a normal outcome after the timeout requested cancellation.
services.AddCommandExecution(options =>
{
options.CircuitBreaker.Enabled = true;
options.CircuitBreaker.MinimumThroughput = 20;
options.CircuitBreaker.FailureRatio = 0.5;
options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30);
options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(30);
});Use the circuit breaker's partition selector and failure predicates to match the dependency being protected. A single idempotency owner holds the retry sequence; each attempt then passes through timeout, circuit breaker, limiter, and handler. Limiter permits are reacquired for each attempt.
Register a store and provide trusted scope and immutable-request fingerprint selectors:
services.AddCommandIdempotency(); // process-local memory store
services.AddCommandExecution(options =>
{
options.Idempotency.ScopeSelector = _ => tenantContext.TenantId;
options.Idempotency.FingerprintSelector = command => requestHasher.Hash(command);
});A custom ICommandIdempotencyStore can be registered with AddCommandIdempotency<TStore>(). The atomic
record includes scope, operation identity, fingerprint, result contract, fenced owner, and terminal outcome.
Running claims are renewed while the handler is active; matching duplicate operations can reuse the outcome.
Cancellation, a crash window, a lost finalization response, or an expired running claim can leave an
Indeterminate outcome. The executor does not automatically repeat it. After independently establishing the
real outcome, call TryResolveIndeterminateAsync; reset with TryResetIndeterminateAsync only when replay
is known to be safe.
For external effects, propagate the same business idempotency key to the provider or coordinate an outbox/inbox. Retry and a local store cannot guarantee exactly-once effects across an uncoordinated external system.
Core registration adds no cleanup hosted service. Stores implementing ICommandIdempotencyMaintenance
can be maintained by the application. AspNetCore provides a hosted cleanup overload for such stores.
var limiter = PartitionedCommandRateLimiter.CreateFixedWindow(
command => trustedTenantAccessor.TenantId,
permitLimit: 100,
window: TimeSpan.FromMinutes(1),
queueLimit: 20);
services.AddCommandRateLimiter(limiter);CreateConcurrency, CreateSlidingWindow, and CreateTokenBucket provide the other local algorithms.
Factories support a permit-count selector. Factory-created limiters are owned/disposed by the adapter;
wrapping an application-owned PartitionedRateLimiter<ICommand> does not transfer ownership by default.
Lease cleanup failures do not rerun a handler that already succeeded. Distributed limiting uses the Orleans
adapter in Orleans integration.
A stream is IAsyncEnumerable<CqrsStreamChunk<TProgress, TResult>>. The contract contains optional progress
and one terminal result; the HTTP transport renders it as Server-Sent Events.
| Kind | Payload | Meaning |
|---|---|---|
Started |
Optional ProgressResult |
Execution has started |
Progress |
ProgressResult |
An intermediate update |
Completed |
Successful Final |
Terminal success |
Failed |
Failed Final containing a problem |
Terminal failure |
Sequence, EventId, and Message carry ordering and presentation data. SSE event names are cqrs-started,
cqrs-progress, cqrs-completed, and cqrs-failed; Kind is written as a string in the JSON payload.
using ManagedCode.Communication;
using ManagedCode.Communication.CQRS;
using ManagedCode.Communication.AspNetCore.Extensions;
builder.Services.AddCommunicationCqrs();
app.MapGet("/import", (CancellationToken cancellationToken) =>
CqrsStream.Create<ImportProgress, ImportReport>(async writer =>
{
await writer.StartedAsync(new ImportProgress(0));
for (var i = 1; i <= 10; i++)
{
await DoWorkAsync(writer.CancellationToken);
await writer.ProgressAsync(new ImportProgress(i * 10));
}
return Result<ImportReport>.Succeed(new ImportReport(10));
}, cancellationToken))
.WithCommunicationCqrsResults();CqrsStream.Create numbers chunks and converts the handler's returned result into a terminal chunk.
It converts nonfatal producer exceptions into failures. Fatal runtime exceptions propagate, including when
nested in an AggregateException. Original exceptions are reported immediately; public chunks contain
problem data rather than exception objects or stack traces.
For a handwritten iterator, use the Started, Progress, Completed, and Failed chunk factories, then
CqrsStream.Normalize when publishing through a transport that does not normalize automatically. The SSE
adapter normalizes by default: missing terminal results become CqrsStreamProblems.IncompleteStream, and
nonfatal enumeration failures become failed chunks. An exception before a handler returns its stream belongs
to the host's ordinary exception handling.
Early consumer disposal cancels and joins the producer, including its finally blocks, before disposal
completes. Cancellation callback failures do not skip that join. Handlers must cooperate with cancellation
to allow disposal to complete promptly.
The HTTP stream client lives in Core and requires no ASP.NET Core reference:
using ManagedCode.Communication.CQRS;
Result<ImportReport> report = await httpClient
.GetForCqrsStreamAsync<ImportProgress, ImportReport>("/import")
.ToResultAsync(progress => Console.WriteLine($"{progress.Percent}%"), cancellationToken);Use the two-parameter callback for awaited work, so an async lambda cannot bind to an Action<TProgress>:
var report = await stream.ToResultAsync(async (progress, token) =>
await SaveProgressAsync(progress, token), cancellationToken);The callback finishes before the next chunk is read. ToResultAsync handles terminal failures, interrupted
transports, and streams that end without a terminal chunk. Explicit caller cancellation propagates
OperationCanceledException, including cancellation while an incomplete source is finishing.
| API | Retains |
|---|---|
ToResultAsync([onProgress]) |
Terminal result, with optional live progress callback |
ToOutcomeAsync() |
Result, progress values, and all chunks |
AsCqrsStream() |
A normalized stream for continued chunk-by-chunk enumeration |
ToChunkListAsync() |
Exactly the received chunks; enumeration faults still propagate |
chunks.ToStreamResult() |
Interprets an already collected sequence as its final result |
ToOutcomeAsync collects the stream in memory; choose ToResultAsync for long streams when history is not
needed. The returned Task<Result<TResult>> can continue through async railway operators.
AddCommunicationCqrs registers shared server options and the MVC stream filter.
AddControllers(options => options.AddCommunicationCqrsFilters()) adds the MVC filter directly instead.
Minimal API endpoints use WithCommunicationCqrsResults, with optional per-endpoint options:
using ManagedCode.Communication.AspNetCore;
app.MapGet("/import", Handler)
.WithCommunicationCqrsResults(new CqrsStreamServerOptions
{
AssignSequenceNumbers = true,
EnsureTerminalChunk = true
});Both options default to true. When a handler dynamically chooses a stream or another HTTP response, keep
its return type as Microsoft.AspNetCore.Http.IResult and use the library-owned transport:
return CqrsStreamHttpResults.ServerSentEvents(updates);Sequence IDs support an application's replay protocol. Communication does not persist streams, retain a
replay history, or automatically resume from Last-Event-ID; durable execution/reconnection belongs to the
application.
var options = new CqrsStreamClientOptions
{
MaximumFrameBytes = 8 * 1024 * 1024,
MaximumFailureBodyBytes = 32 * 1024,
MaximumStreamBytes = 128L * 1024 * 1024,
MalformedChunkBehavior = CqrsMalformedChunkBehavior.EmitFailedChunk
};Defaults are a 16 MiB physical-frame limit and 64 KiB non-success-body limit; hard ceilings are 64 MiB and
1 MiB respectively. MaximumStreamBytes is optional and counts the entire successful body, including SSE
comments and delimiters. Configure an operation-appropriate total budget for bounded workloads.
Malformed-chunk handling supports EmitFailedChunk (default), Skip, or Throw. Bounds violations produce
stable transport failures. Valid bounded RFC 7807 failures retain their fields; invalid, oversized, or
plain-text failure bodies expose status and a safe detail without copying the raw body into the result.
All stream helpers accept IAsyncEnumerable, so the same reader works with SignalR, Orleans, or another
transport implementing that contract:
var report = await hubConnection
.StreamAsync<CqrsStreamChunk<ImportProgress, ImportReport>>("Import", cancellationToken)
.ToResultAsync(progress => Console.WriteLine(progress.Percent), cancellationToken);A SignalR server can return CqrsStream.Normalize(ImportAsync(token), cancellationToken: token).
A stream produced by Create already supplies the contract. ToResultAsync, ToOutcomeAsync, and
AsCqrsStream also normalize incoming streams; fatal runtime failures and caller cancellation retain their
exception semantics.
CQRS creation, normalization, enumeration, disposal, and progress callbacks preserve the caller's scheduler and synchronization context. Inside an Orleans grain, awaited progress callbacks therefore remain on that grain's scheduler:
var report = await otherGrain.StreamAsync().ToResultAsync(
async (progress, token) => await SaveProgressAsync(progress, token));using ManagedCode.Communication.Orleans.Extensions;
siloBuilder.UseOrleansCommunication();
clientBuilder.UseOrleansCommunication();These methods add incoming/outgoing grain-call filters and limiter options. Communication's native typed surrogates are registered through Orleans-generated metadata. This setup does not enable command execution, choose a grain storage serializer, or install JSON grain-storage converters.
Enable the execution adapter explicitly and configure named commandStore grain storage on the silo:
using ManagedCode.Communication.Orleans.Extensions;
using ManagedCode.Orleans.RateLimiting.Core.Extensions;
siloBuilder
.AddMemoryGrainStorage("commandStore") // use a durable provider for production recovery
.UseOrleansCommunication()
.UseOrleansCommandExecution(
execution =>
{
execution.Idempotency.ScopeSelector = _ => tenantContext.TenantId;
execution.Idempotency.FingerprintSelector = command => requestHasher.Hash(command);
},
limiter =>
{
limiter.PolicyName = static _ => "commands";
limiter.TenantId = _ => tenantContext.TenantId;
limiter.UserId = _ => tenantContext.UserId;
});
siloBuilder.Services.AddFixedWindowRateLimiterOptions("tenant-commands", options =>
{
options.PermitLimit = 1_000;
options.Window = TimeSpan.FromMinutes(1);
options.QueueLimit = 100;
});
siloBuilder.Services.AddOrleansRequestRateLimiting(options =>
options.AddTenant("tenant-commands", required: true));A client using the distributed executor calls clientBuilder.UseOrleansCommandExecution(...) as well.
ManagedCode.Orleans.RateLimiting owns distributed algorithms and durable leases; Communication owns the
ICommand/result adapter, execution orchestration, and diagnostics. User, tenant, role, resource, IP, and
metadata selectors default to unset values; supply selectors from trusted execution context.
ManagedCode.Communication.Orleans uses Orleans-generated serialization with typed surrogates for results,
problems, commands, pagination, collections, and CQRS chunks. Stable field IDs preserve success flags, typed
values (including failure payloads), problem fields, and command metadata.
Your application payloads crossing grain boundaries need their own generated serializers:
using Orleans;
using ManagedCode.Communication.CQRS;
[GenerateSerializer]
public sealed record ImportProgress([property: Id(0)] int Percent);
[GenerateSerializer]
public sealed record ImportReport([property: Id(0)] int Imported);
public interface IImportGrain : IGrainWithStringKey
{
IAsyncEnumerable<CqrsStreamChunk<ImportProgress, ImportReport>> ImportAsync();
}Communication does not register a JSON grain-storage adapter. Configure the normal silo/client integration
from Orleans integration and mark application models with [GenerateSerializer] and
stable [Id] members. Orleans owns serialization and deserialization at the boundary; application handlers
do not add manual serialization, payload sizing, or conversion algorithms.
The application selects its storage provider and framework storage serializer. Native
OrleansGrainStorageSerializer uses the same generated serialization as grain communication.
Existing persisted data must match the application-selected storage format. Changing serializer configuration does not migrate old records. Any cleanup or migration must target exact application-owned state.
Communication's application and HTTP JSON implementation uses System.Text.Json. Result, Result<T>,
and Problem provide their JSON converters through type attributes; ASP.NET Core and the HTTP client own
the transport serialization. The HTTP result filter emits a raw success payload or problem response.
HTTP JSON and Orleans native serialization are separate boundaries. Do not install an application JSON codec or manual conversion layer merely to transport Communication types through Orleans.
CQRS JSON defaults are available as the immutable CqrsStreamSerialization.Default. For source-generated
payload contracts, use CqrsStreamSerialization.WithPayloadContext(MyJsonContext.Default) in
CqrsStreamClientOptions.JsonSerializerOptions; it combines the payload context with contracts for the
transport types. Configure matching payload contexts through ASP.NET Core's HTTP JSON options on the server.
Core results work without registration. Without a configured logger, the internal logger emits no output.
For a web application, AddCommunicationAspNetCore() connects the host logger at startup; AddCommunication()
also adds MVC filters. A console application can configure it directly:
using ManagedCode.Communication.Logging;
using var loggerFactory = LoggerFactory.Create(logging => logging.AddConsole());
CommunicationLogger.Configure(loggerFactory);The core services.ConfigureCommunication(loggerFactory) overload also accepts an explicit factory.
There is no parameterless core ConfigureCommunication() registration.
Failures without an exception log at Warning with problem fields. Exception conversions log at Error with
the original exception and throw-site stack trace. Automatic factory diagnostics are deduplicated per
Problem object: wrapping the same instance does not report it as a new occurrence. New problems represent
new occurrences; successes and reading a result do not emit automatic failure diagnostics.
Install Extensions for registration helpers:
using ManagedCode.Communication.Extensions.Telemetry;
builder.AddCommunicationTelemetry();This connects the host logger and subscribes to Communication traces and metrics. In Aspire, call it in each
service alongside that service's existing AddServiceDefaults(). Calling it only in AppHost does not collect
signals from child processes. The application still chooses its exporters, sampling, and export intervals.
To add instrumentation to an existing OpenTelemetry setup:
builder.Services.AddOpenTelemetry().WithCommunication();Or subscribe to individual providers:
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing.AddCommunicationInstrumentation())
.WithMetrics(metrics => metrics.AddCommunicationInstrumentation());Individual provider subscriptions do not configure logging. Without the helpers, subscribe directly to
CommunicationTelemetry.SourceName through AddSource and AddMeter.
| Signal | Purpose |
|---|---|
communication.result.failure activity event |
Automatic factory failure attached to the current activity |
communication.result.created.failures counter |
Distinct factory failures, including attempts later recovered |
communication.result.failures counter |
Explicit boundary failures and final failed command executions |
communication.exceptions counter |
Exceptions converted into problems |
| Command execution instruments | Attempt/total duration, retry, timeout, idempotency, circuit, limiter, queue, and outcome signals |
Automatic factory events preserve the parent operation's status and actual HTTP response code, since a caller can recover from a failure. Explicit boundary reports and final failed operations can mark the activity as Error. Factory failures do not create a synthetic root/dependency trace. Valid serialized W3C trace/span identifiers can become command execution's remote parent; actor IDs are not metric tags.
using ManagedCode.Communication.Telemetry;
var result = LoadOrder(orderId).Report(logger);
var order = await CommunicationDiagnostics.TrackAsync(
"orders.place", () => orderService.PlaceAsync(cart), logger);Track/TrackAsync convert thrown exceptions into failed results and report the original exception.
Problem.Create(exception) alone builds problem data; report at the catch site if using it directly:
catch (Exception exception)
{
var problem = Problem.Create(exception);
CommunicationDiagnostics.ReportFailure(logger, problem, exception);
return Result<Order>.Fail(problem);
}ASP.NET Core, SignalR, and Orleans filters report exceptions they convert. Runtime results and problems never retain exceptions for later logging.
Default exception-to-status mapping is a heuristic. Server defects such as InvalidOperationException,
NotSupportedException, and NullReferenceException map to 500; domain-specific mappings can be registered
once at startup:
using ManagedCode.Communication.Helpers;
ExceptionStatusCodeMap.Map<OrderNotFoundException>(HttpStatusCode.NotFound);Mapping walks the exception type hierarchy and uses the most specific registration. Packages include portable PDBs and symbol packages; debug symbols do not enable telemetry providers or exporters.
Use only the registrations required by your application. Plain result factories, railway operators, CQRS contracts, and HTTP clients do not require a host registration.
| Boundary | Entry point | Effect |
|---|---|---|
| Core logging | ConfigureCommunication(loggerFactory) |
Connect an explicit logger factory |
| Commands | AddCommandExecution(...) |
Register native executor/runtime and options |
| Idempotency | AddCommandIdempotency() / <TStore>() |
Register local/custom atomic store; no Core hosted cleanup |
| Limiting | AddCommandRateLimiter(limiter) |
Register a selected command limiter |
| ASP.NET Core logging | AddCommunicationAspNetCore() |
Connect host logging at startup |
| MVC | AddCommunication() |
Host logging and MVC result/validation/exception filters |
| MVC filters only | AddCommunicationFilters() |
Register filters without host logging |
| Minimal API | WithCommunicationResults() |
Map results to raw success/RFC 7807 HTTP responses |
| CQRS server | AddCommunicationCqrs(...) |
Shared stream options and MVC stream filter |
| CQRS Minimal API | WithCommunicationCqrsResults(...) |
Render a stream as SSE |
| SignalR | AddCommunicationHubFilter() on hub options |
Convert Result-returning hub exceptions |
| Orleans | UseOrleansCommunication() on silo/client |
Grain-call filters and limiter options; native generated surrogates |
| Orleans execution | UseOrleansCommandExecution(...) |
Distributed idempotency/limiting and executor; silo requires commandStore |
| HTTP resilience | AddCommunicationResilienceHandler(...) |
Native execution around safe HTTP replays |
| Telemetry | AddCommunicationTelemetry() |
Host logger and trace/metric subscriptions |
The repository uses TUnit, Microsoft.Testing.Platform, and Shouldly. global.json selects native
Microsoft.Testing.Platform mode, so use --project or --solution, with runner arguments passed directly.
dotnet restore ManagedCode.Communication.slnx
dotnet build -c Release ManagedCode.Communication.slnx
dotnet test --project ManagedCode.Communication.Tests/ManagedCode.Communication.Tests.csproj --configuration Release --no-build --output NormalFor the complete solution, the CI-equivalent command is:
dotnet test --solution ManagedCode.Communication.slnx --configuration Release --no-build --output NormalCollect Cobertura coverage:
dotnet test --solution ManagedCode.Communication.slnx --configuration Release --coverage --coverage-output coverage.cobertura.xml --coverage-output-format cobertura --output NormalRun the existing performance benchmarks when evaluating allocation or throughput changes:
dotnet run -c Release --project ManagedCode.Communication.BenchmarkResults are structs and the JSON converters handle the result envelope directly; payload shape, collection materialization, logging, and the transport still affect allocations. Use benchmark evidence for a specific path rather than assuming an entire operation allocates nothing.
New APIs should have success/failure regressions at their public boundary. Orleans tests cover native
round-trips, HTTP tests cover raw success/RFC 7807 contracts, and CQRS tests cover terminal outcomes,
cancellation, disposal, malformed input, and bounds. Shared assertion helpers are in
ManagedCode.Communication.Tests/TestHelpers.
Package version is owned by Version and PackageVersion in Directory.Build.props. The Release workflow
on main builds, tests, packs, publishes to NuGet, and creates the GitHub release/tag. A completed build does
not establish publication: verify the intended package is downloadable from NuGet.
See AGENTS.md for repository conventions, GitHub Issues for support, and the source repository for contributions. The project is licensed under MIT.