Skip to main content
The ErrorOrX.Generators package provides a Roslyn source generator that converts ErrorOr<T> handlers into ASP.NET Core Minimal API endpoints.

What Gets Generated

// You write:
[Get("/todos/{id}")]
public static ErrorOr<Todo> GetById(Guid id) => _db.Find(id) ?? Error.NotFound();

// Generator produces:
app.MapGet("/todos/{id}", Invoke_Ep1)
    .WithName("MyApp_TodoEndpoints_GetById");

private static async Task Invoke_Ep1(HttpContext ctx)
{
    var __result = await Invoke_Ep1_Core(ctx);
    await __result.ExecuteAsync(ctx);
}

private static Task<IResult> Invoke_Ep1_Core(HttpContext ctx)
{
    Guid id = Guid.Parse((string)ctx.Request.RouteValues["id"]!);
    var result = TodoEndpoints.GetById(id);
    if (result.IsError) return Task.FromResult(ToProblem(result.Errors));
    return Task.FromResult(TypedResults.Ok(result.Value));
}

Route Attributes

AttributeHTTP Method
[Get("/path")]GET
[Post("/path")]POST
[Put("/path")]PUT
[Patch("/path")]PATCH
[Delete("/path")]DELETE

Parameter Binding

The generator infers parameter sources based on HTTP method and type.

Binding Priority

PriorityConditionBinding
1Explicit attribute ([FromBody], [FromServices], etc.)As specified
2Special types (HttpContext, CancellationToken)Auto-detected
3Parameter name matches route {param}Route
4Primitive type not in routeQuery
5Interface typeService
6Abstract typeService
7Service naming patternService
8POST/PUT/PATCH + complex typeBody
9GET/DELETE + complex typeError EOE025
10FallbackService

Service Type Detection

These patterns are detected as service types:
ITodoService      // Interface with Service suffix
IUserRepository   // Interface with Repository suffix
TodoHandler       // *Handler
TodoManager       // *Manager
ConfigProvider    // *Provider
TodoFactory       // *Factory
HttpClient        // *Client
AppDbContext      // *Context with Db

Complex Type on GET/DELETE

Complex types on GET/DELETE require explicit binding:
// ⚠️ EOE025: Ambiguous parameter binding
[Get("/todos")]
public static ErrorOr<List<Todo>> Search(SearchFilter filter) => ...

// ✅ Explicit query binding
[Get("/todos")]
public static ErrorOr<List<Todo>> Search([FromQuery] SearchFilter filter) => ...

// ✅ Or use AsParameters
[Get("/todos")]
public static ErrorOr<List<Todo>> Search([AsParameters] SearchFilter filter) => ...

Interface Types with [ReturnsError]

Without [ReturnsError], your OpenAPI spec is incomplete.When an endpoint delegates to a service method, the generator can only see the return type ErrorOr<T> - it has no idea what errors that method might return. Your Swagger UI shows only 200 OK and generic 500, while your API actually returns 404, 400, 403… Generated API clients miss error handling for real responses.

The Problem

public interface ITodoService
{
    // Generator sees: ErrorOr<Todo>
    // Generator doesn't know: this can return NotFound, Validation errors
    ErrorOr<Todo> GetByIdAsync(Guid id, CancellationToken ct);
}

[Get("/todos/{id}")]
public static Task<ErrorOr<Todo>> GetById(Guid id, ITodoService svc, CancellationToken ct) =>
    svc.GetByIdAsync(id, ct);

// ❌ Generated OpenAPI: only 200 OK, 500 Internal Server Error
// ❌ Swagger UI: misleading - doesn't show 404 is possible
// ❌ Generated clients: no 404 handling code

The Solution

Declare possible errors on the interface contract:
public interface ITodoService
{
    [ReturnsError(ErrorType.NotFound, "Todo.NotFound")]
    ErrorOr<Todo> GetByIdAsync(Guid id, CancellationToken ct);
}

// ✅ Generated OpenAPI: 200 OK, 404 Not Found, 500 Internal Server Error
// ✅ Swagger UI: shows all response codes with ProblemDetails schema
// ✅ Generated clients: proper error handling for each status code
The generator reads [ReturnsError] and includes the corresponding TypedResult in the Results<...> union:
// Generated
Results<Ok<Todo>, NotFound<ProblemDetails>>

Multiple Error Types

Real-world operations have multiple failure modes. Declare them all:
public interface IOrderService
{
    [ReturnsError(ErrorType.NotFound, "Order.NotFound")]
    [ReturnsError(ErrorType.Forbidden, "Order.AccessDenied")]
    [ReturnsError(ErrorType.Conflict, "Order.AlreadyShipped")]
    ErrorOr<Order> ShipAsync(Guid orderId, CancellationToken ct);
}

[Post("/orders/{orderId}/ship")]
public static Task<ErrorOr<Order>> Ship(Guid orderId, IOrderService svc, CancellationToken ct) =>
    svc.ShipAsync(orderId, ct);

// Generated: Results<Ok<Order>, NotFound<ProblemDetails>, ForbiddenHttpResult, Conflict<ProblemDetails>>
Now your OpenAPI spec is accurate: clients know shipping can fail with 404 (order doesn’t exist), 403 (not authorized), or 409 (already shipped).

Middleware Attributes

The generator emits middleware fluent calls since the wrapper delegate loses original method attributes.
This is security-critical. ASP.NET Core only sees attributes on the delegate passed to MapGet()/MapPost(). Since ErrorOrX generates a wrapper method, the original method’s attributes are invisible to ASP.NET. The generator MUST emit equivalent fluent calls.

Authorization

AttributeGenerated Call
[Authorize].RequireAuthorization()
[Authorize("Policy")].RequireAuthorization("Policy")
[Authorize("P1")] [Authorize("P2")].RequireAuthorization("P1", "P2")
[AllowAnonymous].AllowAnonymous()
Multiple [Authorize] attributes with different policies are accumulated and emitted as a single call:
[Get("/admin/secrets")]
[Authorize("AdminPolicy")]
[Authorize("AuditPolicy")]
public static ErrorOr<string> GetSecrets() => "top-secret";

// Generated: .RequireAuthorization("AdminPolicy", "AuditPolicy")
[AllowAnonymous] overrides [Authorize]. When both are present, only .AllowAnonymous() is emitted.

Rate Limiting

AttributeGenerated Call
[EnableRateLimiting("policy")].RequireRateLimiting("policy")
[DisableRateLimiting].DisableRateLimiting()
[DisableRateLimiting] overrides [EnableRateLimiting]. When both are present, only .DisableRateLimiting() is emitted.

Output Caching

AttributeGenerated Call
[OutputCache].CacheOutput()
[OutputCache(PolicyName = "x")].CacheOutput("x")
[OutputCache(Duration = 60)].CacheOutput(p => p.Expire(TimeSpan.FromSeconds(60)))

CORS

AttributeGenerated Call
[EnableCors].RequireCors()
[EnableCors("Policy")].RequireCors("Policy")
[DisableCors](disables CORS)

Combining Multiple Middleware

All middleware attributes can be combined on a single endpoint:
[Get("/api/data")]
[Authorize("ApiPolicy")]
[EnableRateLimiting("standard")]
[OutputCache(PolicyName = "ApiCache")]
[EnableCors("Production")]
public static ErrorOr<Data> GetData() => ...

// Generated:
// app.MapGet("/api/data", Invoke_Ep0)
//     .RequireAuthorization("ApiPolicy")
//     .RequireRateLimiting("standard")
//     .CacheOutput("ApiCache")
//     .RequireCors("Production")
//     .WithName("Api_GetData")
//     .WithTags("Api");

JSON Context Generation

Roslyn source generators cannot see output from other generators. If ErrorOrX generates a JsonSerializerContext, the System.Text.Json source generator will NOT process it, causing runtime errors in Native AOT. You MUST create your own JsonSerializerContext.

Default Behavior

When ErrorOrGenerateJsonContext is false (default):
// Generated ErrorOrJsonContext.g.cs
[JsonSourceGenerationOptions(
    PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(Todo))]
[JsonSerializable(typeof(ProblemDetails))]
[JsonSerializable(typeof(HttpValidationProblemDetails))]
internal partial class ErrorOrJsonContext : JsonSerializerContext { }

With Custom Context

Disable generation and use your own:
<PropertyGroup>
  <ErrorOrGenerateJsonContext>false</ErrorOrGenerateJsonContext>
</PropertyGroup>
The generator emits a helper file with copy-paste attributes:
// ErrorOrJsonContext.MissingTypes.g.cs
// Add these to your JsonSerializerContext:
// [JsonSerializable(typeof(Microsoft.AspNetCore.Mvc.ProblemDetails))]
// [JsonSerializable(typeof(Microsoft.AspNetCore.Http.HttpValidationProblemDetails))]

MSBuild Properties

PropertyDefaultPurpose
ErrorOrGenerateJsonContextfalseGenerate JSON serialization context
ErrorOrGenerateJsonContext is disabled by default because generated JSON contexts cannot be processed by System.Text.Json’s source generator. See the warning above.

AOT Compatibility

The generator produces AOT-compatible code:
  1. No (Delegate) cast - Uses typed MapGet/MapPost
  2. Wrapper pattern - Returns Task, not Task<Results<...>>
  3. Explicit ExecuteAsync - Handles response serialization
// Wrapper: matches RequestDelegate (HttpContext → Task)
private static async Task Invoke_Ep1(HttpContext ctx)
{
    var __result = await Invoke_Ep1_Core(ctx);
    await __result.ExecuteAsync(ctx);  // Writes response
}

// Core: typed for OpenAPI documentation
private static Task<IResult> Invoke_Ep1_Core(HttpContext ctx)
{
    // Handler logic
}

Service Registration

The builder pattern follows ASP.NET Core conventions (like AddRazorComponents()):
// Program.cs
builder.Services.AddErrorOrEndpoints()
    .UseJsonContext<AppJsonSerializerContext>()
    .WithCamelCase()
    .WithIgnoreNulls();

Available Methods

MethodPurpose
UseJsonContext<TContext>()Register AOT-compatible JSON context
WithCamelCase(bool)Use camelCase for JSON properties (default)
WithIgnoreNulls(bool)Ignore null values in JSON (default)
Calling MapErrorOrEndpoints() without AddErrorOrEndpoints() throws an InvalidOperationException with a clear error message.

Endpoint Mapping

MapErrorOrEndpoints() returns an IEndpointConventionBuilder for global configuration:
// Apply conventions to ALL ErrorOr endpoints
app.MapErrorOrEndpoints()
    .RequireAuthorization()           // All endpoints require auth
    .RequireRateLimiting("api")       // Rate limiting for all
    .RequireCors("Production")        // CORS policy
    .WithGroupName("v1");             // OpenAPI grouping
This follows ASP.NET Core patterns like MapRazorComponents().

API Versioning

Full API versioning support with Asp.Versioning.Http:
[ApiVersion("1.0")]
[ApiVersion("2.0")]
public static class TodoEndpoints
{
    [Get("/todos")]
    public static ErrorOr<List<Todo>> GetAll() => ...

    [MapToApiVersion("2.0")]
    [Get("/todos/{id}")]
    public static ErrorOr<Todo> GetByIdV2(Guid id) => ...
}

// Version-neutral
[ApiVersionNeutral]
public static class HealthEndpoints
{
    [Get("/health")]
    public static ErrorOr<HealthStatus> Check() => ...
}

API Versioning Guide

Complete guide to API versioning including version formats, generated code, service registration, and diagnostics (EOE050-EOE055).