Skip to content

Architecture and Core Concepts

This guide explains why Pragmatic.Result exists, how its pieces fit together, and how to choose the right type for each situation. Read this before diving into the API reference or integration guides.


Most .NET applications signal failure by throwing exceptions. This creates three cascading problems that get worse as codebases grow.

public async Task<User> GetUserAsync(int id)
{
var user = await _repository.FindByIdAsync(id);
if (user is null)
throw new NotFoundException($"User {id} not found");
if (!user.IsActive)
throw new BusinessRuleException("User is inactive");
return user;
}

Reading this method, you see it returns User. But it can also throw two different exceptions. Nothing in the signature tells you this. The caller has to read the implementation (or hope for documentation) to know what to catch.

Exceptions are expensive for expected outcomes

Section titled “Exceptions are expensive for expected outcomes”

“User not found” is not exceptional — it is a normal, expected business outcome. Yet the CLR allocates a full Exception object, captures a stack trace, and unwinds the call stack. On a hot API endpoint serving thousands of requests, this overhead is measurable.

// Every 404 allocates an Exception with a full stack trace
try
{
var user = await _userService.GetUserAsync(id);
return Ok(user);
}
catch (NotFoundException ex)
{
return NotFound(new ProblemDetails { Detail = ex.Message });
}
catch (BusinessRuleException ex)
{
return UnprocessableEntity(new ProblemDetails { Detail = ex.Message });
}

Nothing forces the caller to handle all failure modes. Miss a catch block and the exception propagates to a global handler, producing a generic 500 instead of a meaningful error response. The compiler cannot help you because exceptions are not part of the type system.

// Forgot to catch BusinessRuleException
// Result: 500 Internal Server Error instead of 422
try
{
var user = await _userService.GetUserAsync(id);
return Ok(user);
}
catch (NotFoundException ex)
{
return NotFound(new ProblemDetails { Detail = ex.Message });
}
// BusinessRuleException? Unhandled. Boom: 500.

The fundamental issue: exceptions are invisible in signatures, expensive for expected outcomes, and the compiler cannot enforce exhaustive handling.


Pragmatic.Result makes error paths visible in the type system. A method that can fail returns a Result<TValue, TError> — a discriminated union that is either a success with a value or a failure with a typed error. The compiler enforces that you handle both cases.

The same “get user” operation:

public async Task<Result<User, NotFoundError>> GetUserAsync(int id)
{
var user = await _repository.FindByIdAsync(id);
if (user is null)
return NotFoundError.Create("User", id);
return user; // Implicit conversion to Result
}

The return type Result<User, NotFoundError> tells the caller everything:

  • This method returns a User on success
  • It can fail with a NotFoundError
  • No exceptions to guess about

The caller is forced to handle both paths:

var result = await _userService.GetUserAsync(id);
// Option 1: Pattern matching (exhaustive)
var response = result.Match(
user => Ok(user),
error => NotFound(error));
// Option 2: Check-then-access
if (result.IsSuccess)
return Ok(result.Value);
return NotFound(result.Error);
// Option 3: TryGet pattern
if (result.TryGetValue(out var user))
return Ok(user);

What you get:

  • Type-safe errors — errors are part of the method signature, not hidden in throws documentation
  • Exhaustive handlingMatch() requires a handler for every error type, enforced at compile time
  • Zero allocation — all Result types are readonly struct; the wrapper itself never allocates
  • HTTP-ready — built-in error types map directly to HTTP status codes and RFC 7807 ProblemDetails
  • Railway-oriented programmingMap, Bind, Tap, OrElse for composing pipelines

Result<TValue, TError> is a readonly struct with two private fields and a boolean discriminator. At any point in time, exactly one of the two fields holds a meaningful value.

Result<User, NotFoundError>
┌──────────────────────────────────┐
│ IsSuccess = true │
│ _value = User { Id = 42 } │ ← Only this is populated
│ _error = default │
└──────────────────────────────────┘
Result<User, NotFoundError>
┌──────────────────────────────────┐
│ IsSuccess = false │
│ _value = default │
│ _error = NotFoundError { } │ ← Only this is populated
└──────────────────────────────────┘

You never call a constructor directly. Results are created through factory methods or implicit conversions:

// Factory methods (explicit)
var success = Result<User, NotFoundError>.Success(user);
var failure = Result<User, NotFoundError>.Failure(NotFoundError.Create("User", id));
// Implicit conversions (preferred -- less ceremony)
Result<User, NotFoundError> result = user; // Success
Result<User, NotFoundError> result = NotFoundError.Create("User", id); // Failure

The implicit conversion from TValue creates a success. The implicit conversion from TError creates a failure. This is why you can write return user; or return new NotFoundError(...) directly in methods that return Result<User, NotFoundError>.

Three patterns, from safest to most direct:

// 1. Match -- exhaustive, compiler-enforced
string message = result.Match(
user => $"Found: {user.Name}",
error => $"Not found: {error.EntityId}");
// 2. TryGet -- safe, no exceptions
if (result.TryGetValue(out var user))
Console.WriteLine(user.Name);
// 3. Direct access -- throws if wrong state
if (result.IsSuccess)
{
var user = result.Value; // Safe after guard
}

Accessing .Value on a failure throws InvalidOperationException. Accessing .Error on a success throws InvalidOperationException. The analyzer PRAG0001 warns when you access .Value without a guard check.

Results support C# deconstruction for terse code:

var (isSuccess, value, error) = result;
if (isSuccess)
Console.WriteLine(value!.Name);
// Two-element deconstruction
var (ok, user) = result;

Pragmatic.Result provides four core types, each designed for a specific use case.

The primary type. Returns a value on success or a single typed error on failure.

Result<User, NotFoundError> GetUser(int id);
Result<Order, ConflictError> PlaceOrder(OrderRequest request);

When to use: The operation has one logical failure mode, or all failures map to the same error type.

Multi-error variants generated by the source generator. Support 2 to 8 error types. Each error type gets its own Match parameter, enforcing exhaustive handling.

Result<User, NotFoundError, ForbiddenError> GetUser(int id, ClaimsPrincipal user);
var response = result.Match(
user => Ok(user),
notFound => NotFound(notFound), // Must handle NotFoundError
forbidden => Forbid()); // Must handle ForbiddenError

The SG generates these variants as readonly struct types with a byte discriminator index. They live in Pragmatic.Result namespace and are available automatically when you reference the package.

When to use: The operation can fail with different error types that have different HTTP status codes or different semantic meaning to the caller.

For operations that return nothing on success but can fail with a typed error.

VoidResult<ValidationError> ValidateAge(int age)
{
if (age < 0)
return new ValidationError("Age cannot be negative");
return VoidResult<ValidationError>.Success();
}
// In HTTP context: success = 204 No Content, failure = error status code

VoidResult<TError> has no .Value property. It has IsSuccess, IsFailure, .Error, TryGetError(), and Match().

When to use: DELETE operations, validation-only methods, fire-and-forget commands, any operation where success means “it worked, nothing to return.”

For optional values where absence is normal, not an error.

Maybe<string> FindSetting(string key)
{
return settings.TryGetValue(key, out var value)
? Maybe<string>.Some(value)
: Maybe<string>.None();
}
var theme = FindSetting("theme").GetValueOrDefault("light");

When to use: Cache lookups, optional configuration, TryParse patterns, dictionary lookups — any situation where “not found” is a normal outcome rather than an error condition.

A convenience variant where the error type is the Error base class. Useful when you do not need typed error discrimination.

Result<User> GetUser(int id); // Error is Error base class

When to use: Prototyping, or when the caller does not need to distinguish between error types.


QuestionType
Operation returns a value, one failure mode?Result<T, TError>
Operation returns a value, multiple distinct failure modes?Result<T, TError1, TError2, ...>
Operation returns nothing on success, can fail?VoidResult<TError>
Value is optional, absence is normal?Maybe<T>
Need aggregate errors from parallel operations?Result<T[], AggregateError> via CollectAll

The distinction is semantic:

ScenarioUseWhy
Cache lookupMaybe<T>Cache miss is normal, not an error
Database entity by IDResult<T, NotFoundError>404 is meaningful to the API caller
Optional config valueMaybe<T>Missing config = use default
Required external service callResult<T, DependencyError>Failure needs context (service name, retry)
Dictionary TryGetValueMaybe<T>Key absence is expected

Rule of thumb: If the caller needs to know why the value is absent, use Result. If absence is just “nothing there,” use Maybe.


All errors implement IError, a minimal interface with a single property:

public interface IError
{
string Code { get; } // "NOT_FOUND", "VALIDATION_ERROR", etc.
}

The framework provides a richer base class Error that adds HTTP status codes, localization support, transient error detection, and retry hints:

public abstract record Error : IError
{
public abstract string Code { get; } // "NOT_FOUND"
public abstract int StatusCode { get; } // 404
public virtual string Title => string.Empty; // "Not Found"
public virtual string MessageKey => ...; // "error.not.found" (for localization)
public virtual bool IsTransient => false; // true for retryable errors
public virtual TimeSpan? RetryAfter { get; } // Suggested retry delay
public virtual IReadOnlyDictionary<string, object>? Parameters { get; }
}
NeedImplementExample
Minimal error with just code + statusIErrorrecord InsufficientFundsError : IError
HTTP status, localization, retry supportError (inherit)record RateLimitError : Error
Standard HTTP error (404, 409, etc.)Use built-in typesNotFoundError, ConflictError

All built-in errors inherit from Error and live in the Pragmatic.Result.Http namespace.

Error TypeHTTP StatusCodeTypical Use
BadRequestError400BAD_REQUESTMalformed request, missing headers
UnauthorizedError401UNAUTHORIZEDMissing or invalid authentication
ForbiddenError403FORBIDDENAuthenticated but lacking permission
NotFoundError404NOT_FOUNDEntity not found
ConflictError409CONFLICTDuplicate, concurrency conflict
BusinessRuleError422BUSINESS_RULE_VIOLATIONBusiness rule prevents processing
InternalServerError500INTERNAL_ERRORUnhandled exception
DependencyError502/503/504DEPENDENCY_ERRORExternal service failure

Each type provides semantic factory methods:

// NotFoundError
NotFoundError.Create("User", userId);
NotFoundError.For<User>(userId);
NotFoundError.For<User, Guid>(userId);
// ConflictError
ConflictError.AlreadyExists("User", email);
ConflictError.ConcurrencyConflict("Order", orderId);
ConflictError.DuplicateKey("Email", email);
// BusinessRuleError
BusinessRuleError.InsufficientFunds(requested: 100m, available: 50m);
BusinessRuleError.LimitExceeded("order_items", limit: 10, requested: 15);
BusinessRuleError.Inactive("Account");
// DependencyError
DependencyError.Unavailable("PaymentService", retryAfter: TimeSpan.FromSeconds(30));
DependencyError.Timeout("InventoryService");
DependencyError.InvalidResponse("OrderService");
// UnauthorizedError
UnauthorizedError.InvalidCredentials();
UnauthorizedError.ExpiredToken();
UnauthorizedError.MissingToken();
// ForbiddenError
ForbiddenError.MissingPermission("orders.write", resource: "orders/123");
ForbiddenError.ActionDenied("delete", resource: "invoices");
// BadRequestError
BadRequestError.MalformedJson();
BadRequestError.MissingHeader("X-Api-Key");
// InternalServerError
InternalServerError.From(exception, includeDetails: env.IsDevelopment());

Lightweight custom error (implement IError directly):

public record InsufficientFundsError(decimal Requested, decimal Available) : IError
{
public string Code => "INSUFFICIENT_FUNDS";
public int StatusCode => 422;
}

Rich custom error (inherit from Error):

public sealed record RateLimitError : Error
{
public override string Code => "RATE_LIMITED";
public override int StatusCode => 429;
public override string Title => "Too Many Requests";
public override bool IsTransient => true;
public override TimeSpan? RetryAfter { get; init; } = TimeSpan.FromSeconds(60);
}

AggregateError collects multiple errors from parallel operations. It inherits from Error and contains a list of IError instances.

// From parallel results
var (userResult, orderResult) = await (GetUser(id), GetOrder(orderId));
var aggregate = AggregateError.From(userResult, orderResult);
if (aggregate is not null)
return aggregate; // Contains errors from both operations
// From a collection of results
var allOrNothing = results.CollectAll();
// All success -> Success(T[])
// Any failure -> Failure(AggregateError with all failures)

The StatusCode of an AggregateError is the highest status code among its contained errors (5xx outranks 4xx). IsTransient is true only if all contained errors are transient.


Implicit conversions reduce ceremony. Understanding which conversions exist prevents confusion.

FromToDirection
TValueResult<TValue, TError>Value to success
TErrorResult<TValue, TError>Error to failure
Result<TValue, TError>TValueSuccess to value (throws on failure)
// Value -> Success (safe, always works)
Result<User, NotFoundError> result = user;
// Error -> Failure (safe, always works)
Result<User, NotFoundError> result = NotFoundError.Create("User", id);
// Result -> Value (UNSAFE: throws InvalidOperationException if failure)
User user = result; // Only safe after IsSuccess check
FromToDirection
TErrorVoidResult<TError>Error to failure
VoidResult<TError>boolResult to boolean
// Error -> Failure
VoidResult<ValidationError> result = new ValidationError("Invalid");
// VoidResult -> bool (true if success)
if (result) { /* success */ }
FromToDirection
TMaybe<T>Value to Some
Maybe<T>boolOption to boolean
// Value -> Some
Maybe<string> option = "hello";
// Maybe -> bool (true if has value)
if (option) { /* has value */ }

Match() is the primary way to consume Result types. It enforces exhaustive handling — the compiler requires a handler for every possible outcome.

Result<User, NotFoundError> result = ...;
// Functional: returns a value
string message = result.Match(
user => $"Found: {user.Name}",
error => $"Not found: {error.EntityId}");
// Imperative: executes side effects
result.Match(
user => Console.WriteLine(user.Name),
error => logger.LogWarning("User {Id} not found", error.EntityId));

Each error type gets its own handler:

Result<Order, NotFoundError, ForbiddenError, ConflictError> result = ...;
var response = result.Match(
order => Ok(order),
notFound => NotFound(),
forbidden => Forbid(),
conflict => Conflict());
// Removing any handler is a compile error

No success value parameter — just a parameterless function:

VoidResult<ValidationError> result = ...;
string message = result.Match(
() => "Valid",
error => $"Invalid: {error.Message}");
Maybe<string> option = ...;
string display = option.Match(
value => $"Got: {value}",
() => "Nothing");

Map, Bind, and their async variants let you compose operations without nested if-else checks. If any step fails, subsequent steps are skipped and the error propagates.

Result<string, NotFoundError> result = GetUsername(id);
// Map transforms the value if success, passes through the error if failure
Result<int, NotFoundError> length = result.Map(name => name.Length);

Bind — chain Result-returning operations

Section titled “Bind — chain Result-returning operations”
Result<User, NotFoundError> user = GetUser(id);
// Bind chains another Result-returning operation
Result<Order, NotFoundError> order = user.Bind(u => GetLatestOrder(u.Id));

The difference: Map takes Func<T, TNew> (value in, value out). Bind takes Func<T, Result<TNew, TError>> (value in, Result out). Use Bind when the next step can also fail.

Result<User, NotFoundError> result = GetUser(id);
// Convert error type for a different context
Result<User, ApiError> mapped = result.MapError(e => new ApiError(e.Code));

Chain async operations in a single expression:

var result = await userService.GetByIdAsync(userId)
.MapAsync(user => enrichmentService.EnrichAsync(user))
.BindAsync(user => authService.CheckAccessAsync(user))
.TapAsync(user => auditService.LogAccessAsync(user))
.OnFailureAsync(error => alertService.NotifyAsync(error));

Each method in the chain:

MethodPurposeSignature
MapAsyncTransform success value (async)Func<T, Task<TNew>>
BindAsyncChain another Result-returning async operationFunc<T, Task<Result<TNew, TError>>>
TapAsyncSide effect on success (logging, metrics)Func<T, Task>
OnFailureAsyncSide effect on failureFunc<TError, Task>
EnsureAsyncValidate with async predicate, fail if falseFunc<T, Task<bool>>
OrElseAsyncRecover from failure with fallbackFunc<TError, Task<Result<T, TError>>>
MatchAsyncExtract final value from async chainBoth handlers

Convert existing patterns into Results:

// Nullable -> Result
User? user = await repository.FindByIdAsync(id);
var result = Result.FromNullable(user, NotFoundError.Create("User", id));
// Lazy error (factory only called on null)
var result = Result.FromNullable(user, () => NotFoundError.For<User>(id));
// Exception-throwing code -> Result
var result = Result.Try(
() => JsonSerializer.Deserialize<Order>(json)!,
ex => InternalServerError.From(ex));
// Async variant
var result = await Result.TryAsync(
async ct => await httpClient.GetFromJsonAsync<Order>(url, ct),
ex => DependencyError.Unavailable("OrderService"),
cancellationToken);

Work with collections of Results:

Result<User, NotFoundError>[] results = await Task.WhenAll(
ids.Select(id => userService.GetByIdAsync(id)));
// Extract successes only
IEnumerable<User> users = results.GetSuccesses();
// Extract failures only
IEnumerable<NotFoundError> errors = results.GetFailures();
// Split into both
var (users, errors) = results.Partition();
// All-or-nothing: all succeed or aggregate errors
var allOrNothing = results.CollectAll();
// Success -> Result<User[], AggregateError> with all users
// Failure -> Result<User[], AggregateError> with AggregateError containing all failures

The Pragmatic.Result source generator produces two categories of output.

The SG generates Result<TValue, TError1, TError2> through Result<TValue, TError1, ..., TError8> as readonly struct types. These are generated once and included in the package. Each variant has:

  • A byte _index discriminator (0 = success, 1-N = error type index)
  • Separate fields for each error type (no boxing)
  • Match() with N+1 parameters (success + one per error type)
  • TryGetValue() and TryGetError1() through TryGetErrorN()
  • Implicit conversions from TValue and each TError
  • IResultBase implementation for ASP.NET Core integration

The same pattern applies to VoidResult<TError1, TError2> through VoidResult<TError1, ..., TError8>.

When you define a domain-specific combination of error types used across multiple methods, [GenerateResult] generates a named Result type:

[GenerateResult(typeof(NotFoundError), typeof(ValidationError), typeof(ConflictError))]
public interface IUserOperationResult { }
// SG generates: UserOperationResult<TValue>
// - Same features as Result<TValue, NotFoundError, ValidationError, ConflictError>
// - Named type for better readability in method signatures

Generated files appear under obj/Debug/net10.0/generated/ in the IDE and are fully debuggable.


Pragmatic.Result is the error handling foundation for the entire Pragmatic ecosystem. Other modules consume and produce Result types.

ASP.NET Core (Pragmatic.Result.AspNetCore)

Section titled “ASP.NET Core (Pragmatic.Result.AspNetCore)”

Automatic conversion from Result types to HTTP responses:

  • Minimal APIs: WithResultHandling() on route groups converts Result<T, E> to 200 OK or ProblemDetails with the error’s status code
  • Controllers: ResultActionFilter does the same for controller actions returning Result types
  • VoidResult maps to 204 No Content on success
  • ProblemDetails follow RFC 7807, with error-specific extensions (entityType, entityId, etc.)

Entity Framework Core (Pragmatic.Result.EntityFrameworkCore)

Section titled “Entity Framework Core (Pragmatic.Result.EntityFrameworkCore)”

Query extensions that return Result types instead of null:

var result = await dbContext.Users.FindAsResultAsync<User, int>(id);
// Returns Result<User, NotFoundError> instead of User?

Endpoints declare error types in their base class generics. The SG generates HTTP status mapping and OpenAPI response schemas automatically:

[Endpoint(HttpVerb.Get, "/users/{id}")]
public partial class GetUser : Endpoint<UserDto, NotFoundError>
{
// SG generates: 200 OK (UserDto) + 404 Not Found (ProblemDetails)
}

DomainAction<T> and Mutation<T> use Result<T, IError> as their return type. The invoker pipeline handles error propagation through filters, validators, and processors.

ValidationError implements IError and is the standard error for input validation failures. The validation SG generates validators that return VoidResult<ValidationError>.

Repository methods return Result<T, NotFoundError>. Bulk operations use AggregateError for partial failure scenarios. SaveChangesAsResultAsync (from Pragmatic.Result.EFCore) converts DbUpdateException into typed database errors (DbConflictError, DbConstraintError, etc.).


Result types include built-in System.Text.Json converters via ResultJsonConverterFactory.

// ASP.NET Core
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.Converters.Add(new ResultJsonConverterFactory());
});
// Result<T, E> -- success
{ "isSuccess": true, "value": { "id": 1, "name": "Alice" } }
// Result<T, E> -- failure
{ "isSuccess": false, "error": { "code": "NOT_FOUND", "statusCode": 404 } }
// VoidResult<E> -- success
{ "isSuccess": true }
// VoidResult<E> -- failure
{ "isSuccess": false, "error": { "code": "VALIDATION_ERROR" } }
// Maybe<T> -- some
{ "hasValue": true, "value": "cached-data" }
// Maybe<T> -- none
null

All Result types are readonly struct and designed for zero allocation on the hot path.

Design decisionImpact
readonly struct for all typesNo heap allocation for the Result wrapper
[MethodImpl(AggressiveInlining)] on hot methodsJIT inlines IsSuccess, Value, Match, TryGetValue
Private constructors + static factoriesPrevents invalid states, enables optimization
byte index for multi-error variantsSingle byte discriminator instead of per-error bool fields
[DoesNotReturn] on throw helpersJIT eliminates dead code paths after guards
  • The Result struct itself: Never. It lives on the stack or inline in the containing object.
  • The payload (TValue, TError): Only if the type is a reference type (class, record class). Using readonly struct or record struct for errors makes the entire operation allocation-free.
  • Match with lambdas: May allocate a delegate if the lambda captures local variables. For zero-allocation Match, use TryGetValue/TryGetError or static lambdas.