UnionGenerator.AspNetCore 0.1.0

dotnet add package UnionGenerator.AspNetCore --version 0.1.0
                    
NuGet\Install-Package UnionGenerator.AspNetCore -Version 0.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="UnionGenerator.AspNetCore" Version="0.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="UnionGenerator.AspNetCore" Version="0.1.0" />
                    
Directory.Packages.props
<PackageReference Include="UnionGenerator.AspNetCore" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add UnionGenerator.AspNetCore --version 0.1.0
                    
#r "nuget: UnionGenerator.AspNetCore, 0.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package UnionGenerator.AspNetCore@0.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=UnionGenerator.AspNetCore&version=0.1.0
                    
Install as a Cake Addin
#tool nuget:?package=UnionGenerator.AspNetCore&version=0.1.0
                    
Install as a Cake Tool

UnionGenerator.AspNetCore

Stop tedious if-else chains for HTTP error handling. Automatically map error types to status codes (404, 422, 409, etc.) with one-line integration—no manual mapping required.

❓ Why This Package?

The Problem

Without convention-based mapping, ASP.NET Core error handling is verbose and repetitive:

// ❌ Manual status code mapping (every endpoint)
[HttpGet("{id}")]
public IActionResult GetUser(int id)
{
    var result = _service.GetUser(id);
    
    if (result.IsNotFound)
        return NotFound(result.Error);
    
    if (result.IsValidationError)
        return UnprocessableEntity(result.Error);
    
    if (result.IsConflict)
        return Conflict(result.Error);
    
    return Ok(result.Data); // ← Easy to forget one case!
}

// And repeat this 50+ times across your API...

Problems:

  • Boilerplate repeated in every endpoint
  • Easy to accidentally return wrong status code
  • Hard to refactor error types (must update everywhere)
  • Error type intent not obvious to developers

The Solution

// ✅ One line handles ALL status codes automatically
[HttpGet("{id}")]
public IActionResult GetUser(int id)
{
    return _service.GetUser(id).ToActionResult();
}

The framework automatically maps based on error type:

  • NotFoundError → 404
  • ValidationError → 422
  • ConflictError → 409
  • BadRequestError → 400
  • Custom errors → Custom status code via [UnionStatusCode(code)] attribute

🚀 Quick Start (2 minutes)

1. Install & Setup

dotnet add package UnionGenerator.AspNetCore
// Program.cs
builder.Services.AddUnionResultHandling();

2. Define Error Types

// Errors/NotFoundError.cs
[UnionStatusCode(404)]
public class NotFoundError 
{ 
    public string Message { get; set; }
}

// Errors/ValidationError.cs
[UnionStatusCode(422)]
public class ValidationError 
{ 
    public Dictionary<string, string[]> Errors { get; set; }
}

3. Use in Controller

[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    [HttpGet("{id}")]
    public IActionResult GetUser(int id)
    {
        var result = _service.GetUser(id);
        return result.ToActionResult(); // ✨ Status codes auto-mapped!
    }
}

That's it! Status codes are automatically mapped based on error types.


📚 Features

Convention-Based Status Code Mapping

Automatically infer HTTP status codes from error types without manual mapping:

// Automatic detection with @UnionStatusCode attribute
[UnionStatusCode(404)]
public class NotFoundError { }

[UnionStatusCode(409)]
public class ConflictError { }

[UnionStatusCode(422)]
public class ValidationError { }

Priority Chain (evaluated in order):

  1. [UnionStatusCode] Attribute (100) - Fastest, explicit
  2. StatusCode Property (75) - public int StatusCode { get; }
  3. Naming Pattern (50) - NotFound → 404, BadRequest → 400
  4. ProblemDetails (50) - IProblemDetailsError types

🔧 Core Components

AttributeBasedConvention

Fastest path: Explicit status code via attribute.

[UnionStatusCode(404)]
public class NotFoundError { }

// Registry lookup: O(1), cached, 20,000x faster than reflection

Performance: ~1-10 ns after first use (cached)


🔑 Core Method: ToActionResult()

The ToActionResult() extension method converts a Result<TSuccess, TError> into an IActionResult with automatic status code mapping.

Behavior

// Success case → 200 OK with response body
var result = Result<User, UserError>.Ok(user);
result.ToActionResult(); // Returns: Ok(user) — Status 200

// Error case → Inferred status code + error response
var result = Result<User, UserError>.Error(new NotFoundError { Message = "..." });
result.ToActionResult(); // Returns: NotFound(error) — Status 404 (from attribute)

How It Determines Status Code

Method evaluates error type in order (first match wins):

Priority Method Example Result
1 [UnionStatusCode] Attribute [UnionStatusCode(404)] Status from attribute
2 StatusCode Property public int StatusCode { get; set; } Status from property
3 Type Name Pattern NotFoundError Pattern-based: 404
4 Default Unknown error type 500 (Internal Server Error)

Real-World Example

// Three different error types, three different status codes—one line of code
[HttpGet("{id}")]
public IActionResult GetUser(int id)
{
    var result = _service.GetUser(id);
    
    return result switch
    {
        { IsSuccess: true, Data: var user } => Ok(user),
        _ => result.ToActionResult() // Handles all error cases
    };
}

// Or even simpler (if you always return Result<T, E>):
[HttpGet("{id}")]
public IActionResult GetUser(int id)
    => _service.GetUser(id).ToActionResult();

What If Error Type Isn't Recognized?

If the error type doesn't match any convention:

public class CustomApplicationError { } // No attribute, no property, unknown name

var result = Result<T, CustomApplicationError>.Error(error);
result.ToActionResult(); // → 500 Internal Server Error (safe default)

Recommendation: Always use [UnionStatusCode(code)] for custom errors to be explicit.


PropertyBasedConvention

Flexible approach: Status code in property.

public class CustomError
{
    public int StatusCode => 418; // I'm a teapot!
}

// Supported property names: StatusCode, Status, HttpStatusCode

Performance: First lookup cached, subsequent O(1)


NameBasedConvention

Convention-driven: Status code inferred from type name.

public class UserNotFoundError { } // → 404
public class ValidationError { }    // → 400
public class ConflictError { }      // → 409
public class BadRequestError { }    // → 400

Supported patterns:

  • *NotFound* → 404
  • *BadRequest* → 400
  • *Validation* → 400
  • *Unauthorized* → 401
  • *Forbidden* → 403
  • *Conflict* → 409
  • *UnprocessableEntity* → 422
  • *TooManyRequests* → 429
  • *InternalServerError* → 500

📋 Structured Logging

Enable diagnostics with zero allocation when disabled:

// Program.cs
builder.Services.AddUnionResultHandling(options =>
{
    options.LoggingOptions.LogErrorDetails = true;
    options.LoggingOptions.LogConventionInference = true;
});

// In controller/service
var logger = HttpContext.RequestServices.GetRequiredService<UnionResultLogger>();
logger.LogErrorCase("NotFoundError", 404, "AttributeBased");

Log Output:

[Info] Union error case processed. Type: NotFoundError, StatusCode: 404, Convention: AttributeBased

Configuration Options:

new UnionLoggingOptions
{
    LogSuccessResults = false,        // Don't spam logs
    LogErrorDetails = true,           // Always log errors
    LogConventionInference = true,    // Debug convention behavior
    MinimumLevel = LogLevel.Information
}

🎯 Common Use Cases

Case 1: Simple GET with 404

[HttpGet("{id}")]
public IActionResult GetUser(int id)
{
    var result = _service.GetUser(id); // Returns Result<User, NotFoundError>
    return result.ToActionResult();     // Auto-maps to 404 if error
}

// Error type
[UnionStatusCode(404)]
public class NotFoundError { public string Message { get; set; } }

Case 2: Validation with 422

[HttpPost]
public IActionResult CreateUser(CreateUserDto dto)
{
    var result = _service.CreateUser(dto); // Returns Result<User, ValidationError>
    return result.ToActionResult();         // Auto-maps to 422 if validation fails
}

// Error type
[UnionStatusCode(422)]
public class ValidationError 
{ 
    public Dictionary<string, string[]> Errors { get; set; }
}

Case 3: Multiple Error Types

[HttpPost("{id}/role")]
public IActionResult AssignRole(int id, string role)
{
    var result = _service.AssignRole(id, role);
    return result.ToActionResult(); // Handles all error types automatically
}

// Error types (each with own status code)
[UnionStatusCode(404)]
public class UserNotFoundError { }

[UnionStatusCode(400)]
public class InvalidRoleError { }

[UnionStatusCode(409)]
public class RoleAlreadyAssignedError { }

🔍 Compile-Time Analyzers

Detect common mistakes at build time:

UG4010: Union Not Mapped to IActionResult

// ❌ Warning: Union type returned without mapping
public Result<User, NotFoundError> GetUser(int id)
{
    return _service.GetUser(id);
}

// ✅ Correct
public IActionResult GetUser(int id)
{
    return _service.GetUser(id).ToActionResult();
}

UG4011: Error Case Lacks Status Code

// ❌ Warning: No status code defined
public class CustomError { }

// ✅ Correct
[UnionStatusCode(400)]
public class CustomError { }
// ℹ️ Info: Inferred by convention, but explicit is better
public class NotFoundError { } // Convention infers 404

// ✅ Better
[UnionStatusCode(404)]
public class NotFoundError { }

⚡ Performance

Benchmarks

Operation Time vs Reflection
Status code lookup (cached) 5-10 ns 20,000x faster
Convention resolution (first) ~100 µs Reflection-based
Convention resolution (cached) ~5 ns 20,000x faster
Logging disabled 10 ns 1,000x faster

Optimization Tips

  1. Use [UnionStatusCode] attribute for known error types (fastest)
  2. Define errors once, reuse everywhere (cached after first lookup)
  3. Let convention infer for standard patterns (NotFound, BadRequest, etc.)
  4. Disable success logging in production (reduces noise)

🧪 Testing

Test your union result handlers:

[Fact]
public async Task GetUser_WithValidId_ReturnsOk()
{
    // Arrange
    var service = new Mock<IUserService>();
    service.Setup(s => s.GetUser(1))
        .ReturnsAsync(Result<User>.Success(new User { Id = 1, Name = "John" }));
    
    var controller = new UsersController(service.Object);

    // Act
    var result = controller.GetUser(1) as OkObjectResult;

    // Assert
    Assert.NotNull(result);
    Assert.Equal(200, result.StatusCode);
}

[Fact]
public async Task GetUser_WithInvalidId_ReturnsNotFound()
{
    // Arrange
    var service = new Mock<IUserService>();
    service.Setup(s => s.GetUser(999))
        .ReturnsAsync(Result<User>.Error(new NotFoundError { Message = "User not found" }));
    
    var controller = new UsersController(service.Object);

    // Act
    var result = controller.GetUser(999) as ObjectResult;

    // Assert
    Assert.NotNull(result);
    Assert.Equal(404, result.StatusCode); // Convention mapped automatically!
}

🛠️ Advanced Configuration

Custom Convention

// Define custom convention
public class MyCustomConvention : IStatusCodeConvention
{
    public int Priority => 80; // Between Property (75) and ProblemDetails (50)

    public bool TryGetStatusCode(object error, out int statusCode)
    {
        if (error is ICustomError customError)
        {
            statusCode = customError.HttpStatus;
            return true;
        }
        statusCode = 0;
        return false;
    }
}

// Register
services.AddUnionResultHandling(options =>
{
    options.CustomConventions.Add(new MyCustomConvention());
});

Custom Logging

// Inject logger
public class UsersController : ControllerBase
{
    private readonly UnionResultLogger _logger;

    public UsersController(UnionResultLogger logger)
    {
        _logger = logger;
    }

    [HttpGet("{id}")]
    public IActionResult GetUser(int id)
    {
        var result = _service.GetUser(id);
        
        if (result.IsError)
        {
            _logger.LogErrorCase(result.Error.GetType().Name, 404, "AttributeBased");
        }
        
        return result.ToActionResult();
    }
}

📖 Best Practices

✅ DO

  • Define error types with explicit [UnionStatusCode] attribute
  • Keep error types focused and single-purpose
  • Use descriptive error type names
  • Log errors for diagnostics
  • Document expected error codes in API documentation

❌ DON'T

  • Mix success and error logic
  • Return raw Result<T, E> from controllers (use ToActionResult())
  • Create error types without status codes
  • Rely solely on convention for non-standard patterns
  • Log success cases in production (causes spam)

🔗 Integration Examples

With Minimal APIs

app.MapGet("/users/{id}", (int id, IUserService service) =>
    service.GetUser(id).ToActionResult())
    .Produces<User>(200)
    .Produces<NotFoundError>(404)
    .WithName("GetUser");

With MediatR

[HttpPost]
public async Task<IActionResult> CreateUser(CreateUserCommand command)
{
    var result = await _mediator.Send(command);
    return result.ToActionResult(); // Works with MediatR commands returning Result<T, E>
}

With OpenAPI/Swagger

[HttpGet("{id}")]
[ProducesResponseType(typeof(User), 200)]
[ProducesResponseType(typeof(NotFoundError), 404)]
public IActionResult GetUser(int id)
{
    return _service.GetUser(id).ToActionResult();
}

📊 Comparison

Without UnionGenerator.AspNetCore

// Manual mapping everywhere
public IActionResult GetUser(int id)
{
    var user = _service.GetUser(id);
    if (user == null)
        return NotFound(new { message = "User not found" });
    return Ok(user);
}

public IActionResult CreateUser(CreateUserDto dto)
{
    var validation = _validator.Validate(dto);
    if (!validation.IsValid)
        return UnprocessableEntity(new { errors = validation.Errors });
    var user = _service.CreateUser(dto);
    return CreatedAtAction(nameof(GetUser), new { id = user.Id }, user);
}

// ... repeated 100 times across your codebase

With UnionGenerator.AspNetCore

// Convention-based, consistent
public IActionResult GetUser(int id)
    => _service.GetUser(id).ToActionResult();

public IActionResult CreateUser(CreateUserDto dto)
    => _service.CreateUser(dto).ToActionResult();

// Define once
[UnionStatusCode(404)]
public class NotFoundError { }

[UnionStatusCode(422)]
public class ValidationError { }

// Used everywhere automatically

📊 Structured Logging & Observability

Built-In Logging Middleware

UnionGenerator includes a production-grade middleware that logs all union result responses with structured information. This provides immediate visibility into error patterns and response times.

Quick Setup (1 line)
// Program.cs
var builder = WebApplication.CreateBuilder(args);

// ... configure services ...

var app = builder.Build();

// Add middleware for structured result logging (minimal overhead)
app.UseMiddleware<UnionResultLoggingMiddleware>();

// ... rest of middleware pipeline ...
app.UseRouting();
app.MapControllers();

app.Run();

That's it! Every HTTP response with a union error is now logged automatically.

What Gets Logged?

The middleware logs with this structured template:

UnionResult: Path={Path} Method={Method} Status={Status} Elapsed={Elapsed}ms Case={Case}

Log Output Examples

Success Response (2xx)
[Debug] UnionResult: Path=/api/users Method=GET Status=200 Elapsed=12ms Case=Success
Not Found Error (404)
[Warning] UnionResult: Path=/api/users/999 Method=GET Status=404 Elapsed=8ms Case=NotFound
Validation Error (422)
[Warning] UnionResult: Path=/api/users Method=POST Status=422 Elapsed=15ms Case=ValidationError
Conflict (409)
[Warning] UnionResult: Path=/api/items Method=POST Status=409 Elapsed=10ms Case=Conflict
Server Error (500)
[Warning] UnionResult: Path=/api/data Method=GET Status=500 Elapsed=45ms Case=InternalServerError

Log Fields Explained

Field Meaning Example
Path Request URL path /api/users/123
Method HTTP verb GET, POST, PUT, DELETE
Status HTTP status code 200, 404, 422, 500
Elapsed Response time in milliseconds 15
Case Union case name (inferred from status) NotFound, ValidationError, Conflict

Conditional Logging Levels

  • Success (2xx): Logged at Debug level (low noise in production)
  • Client Errors (4xx): Logged at Warning level (actionable issues)
  • Server Errors (5xx): Logged at Warning level (critical for monitoring)
  • Unknown Status: Logged as Error({statusCode}) for diagnostics

Status Code to Case Name Mapping

The middleware automatically infers case names from HTTP status codes:

Status Case Name
200-299 Success
400 BadRequest
401 Unauthorized
403 Forbidden
404 NotFound
409 Conflict
422 ValidationError
429 TooManyRequests
500 InternalServerError
502 BadGateway
503 ServiceUnavailable
Other Error(code)

Sharing Metrics via HttpContext

The middleware automatically measures request elapsed time. You can access metrics in downstream code:

[HttpGet("{id}")]
public IActionResult GetUser(int id)
{
    var result = _service.GetUser(id);
    
    // Metrics are available in HttpContext.Items
    // (The middleware captures them automatically)
    
    return result.ToActionResult();
}

Future enhancement: Response metrics will be stored in HttpContext.Items["UnionResultMetrics"] for custom processing.

Real-World Example: Logging with Application Insights

// Program.cs
builder.Services.AddApplicationInsightsTelemetry();

var app = builder.Build();

// Middleware will automatically log to Application Insights
app.UseMiddleware<UnionResultLoggingMiddleware>();

app.MapControllers();

app.Run();

Result: All error responses appear in Application Insights Logs with structured fields.

Query example:

customEvents
| where name startswith "UnionResult"
| where properties.Status >= 400
| summarize count() by properties.Case
| render barchart

Disabling Debug Logging (Production)

In production, you may want to suppress Debug-level logs to reduce noise:

// Program.cs
if (app.Environment.IsProduction())
{
    app.Logger.LogLevel = LogLevel.Information; // Filters out Debug logs
}

Or configure in appsettings.json:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "UnionGenerator.AspNetCore.Middleware": "Warning"
    }
  }
}

Performance Characteristics

  • Middleware Overhead: ~1-2ms per request (dominated by response stream buffering)
  • Logging (Disabled): 0 allocations, ~0.1µs
  • Logging (Enabled): 1-2 allocations, ~10-50µs
  • No Impact on Success Cases: Debug-level logging disabled in most production configurations

Troubleshooting

Problem: Logs not appearing

Solution:

  1. Verify middleware is registered: app.UseMiddleware<UnionResultLoggingMiddleware>();
  2. Check logging level in appsettings.json (must not filter out Debug/Warning)
  3. Ensure logging provider is configured (Console, Debug, etc.)
// Ensure logging is wired up
builder.Logging.AddConsole();           // or AddDebug(), AddApplicationInsights(), etc.
builder.Logging.SetMinimumLevel(LogLevel.Debug);

Problem: Performance impact

Recommendation:

  • Keep middleware; overhead is minimal (1-2ms per request)
  • Set minimum log level to Information in production
  • Use sampling in Application Insights if log volume is high

🐛 Troubleshooting

Status Code Not Mapping

Problem: Error returns 500 instead of expected status code

Solution:

  1. Ensure error type has [UnionStatusCode] attribute
  2. Or has int StatusCode { get; } property
  3. Or matches naming pattern (NotFound, BadRequest, etc.)
  4. Check analyzer warnings (UG4011)

Logging Not Appearing

Problem: No error logs in console

Solution:

// Enable error logging
services.AddUnionResultHandling(options =>
{
    options.LoggingOptions.LogErrorDetails = true;
});

// Ensure logging provider is configured
builder.Logging.AddConsole(); // or AddDebug(), etc.

Type Not Found in Convention

Problem: Compiler can't find convention classes

Solution:

// Add using statement
using UnionGenerator.AspNetCore.Conventions;
using UnionGenerator.AspNetCore.Logging;
using UnionGenerator.AspNetCore.Extensions;

📚 Resources


📄 License

MIT License - See LICENSE file for details


✨ Summary

Feature Benefit
Convention-Based Mapping 95% less boilerplate
20,000x Performance Cached attribute lookup
Compile-Time Analysis Catch errors at build time
Zero Allocation Logging Production-ready
DI-First Design Testable, flexible

Start now: Add [UnionStatusCode] to your error types and call .ToActionResult() in controllers. Done! 🚀

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on UnionGenerator.AspNetCore:

Package Downloads
UnionGenerator.FluentValidation

FluentValidation integration for UnionGenerator. Automatically converts validation failures to structured ProblemDetails errors with field-level error mapping.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.1.0 119 1/21/2026

Initial release: ASP.NET Core integration with automatic ProblemDetails conversion and ActionResult support.