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
Attribute HTTP 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
Priority Condition Binding 1 Explicit attribute ([FromBody], [FromServices], etc.) As specified 2 Special types (HttpContext, CancellationToken) Auto-detected 3 Parameter name matches route {param} Route 4 Primitive type not in route Query 5 Interface type Service 6 Abstract type Service 7 Service naming pattern Service 8 POST/PUT/PATCH + complex type Body 9 GET/DELETE + complex type Error EOE025 10 Fallback Service
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
Attribute Generated 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
Attribute Generated Call [EnableRateLimiting("policy")].RequireRateLimiting("policy")[DisableRateLimiting].DisableRateLimiting()
[DisableRateLimiting] overrides [EnableRateLimiting]. When both are
present, only .DisableRateLimiting() is emitted.
Output Caching
Attribute Generated Call [OutputCache].CacheOutput()[OutputCache(PolicyName = "x")].CacheOutput("x")[OutputCache(Duration = 60)].CacheOutput(p => p.Expire(TimeSpan.FromSeconds(60)))
CORS
Attribute Generated 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
Property Default Purpose 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:
No (Delegate) cast - Uses typed MapGet/MapPost
Wrapper pattern - Returns Task, not Task<Results<...>>
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
Method Purpose 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).