Skip to main content

ASP.NET Core Integration

The UnionGenerator.AspNetCore package provides seamless integration with ASP.NET Core, enabling you to build type-safe Web APIs with automatic ProblemDetails generation and HTTP response mapping.

Overview

This integration package bridges the gap between discriminated unions and HTTP responses, making it easy to:

  • Convert union types to appropriate HTTP responses
  • Generate RFC 7807 ProblemDetails automatically
  • Use unions in controller actions and minimal APIs
  • Handle validation errors consistently
  • Map domain errors to HTTP status codes

Installation

dotnet add package UnionGenerator.AspNetCore

Requirements:

  • .NET 6.0 or later
  • ASP.NET Core 6.0 or later
  • UnionGenerator package

Quick Start

Define Your Union

using UnionGenerator;

[GenerateUnion]
public partial record UserResult
{
public static partial UserResult Success(User user);
public static partial UserResult NotFound(string userId);
public static partial UserResult ValidationError(List<string> errors);
}

Use in Controller

[ApiController]
[Route("api/users")]
public class UsersController : ControllerBase
{
[HttpGet("{id}")]
public IActionResult GetUser(string id)
{
var result = _userService.GetUser(id);

return result.Match(
success => Ok(success.User),
notFound => NotFound(new ProblemDetails
{
Title = "User not found",
Detail = $"User '{notFound.UserId}' does not exist"
}),
validationError => BadRequest(validationError.Errors)
);
}
}

Use in Minimal APIs

app.MapGet("/api/users/{id}", (string id, IUserService service) =>
{
var result = service.GetUser(id);

return result.Match(
success => Results.Ok(success.User),
notFound => Results.NotFound(new { Error = $"User {notFound.UserId} not found" }),
validationError => Results.BadRequest(new { Errors = validationError.Errors })
);
});

Core Features

Result to HTTP Response Mapping

Convert unions directly to HTTP responses:

[GenerateUnion]
public partial record ApiResult<T>
{
public static partial ApiResult<T> Ok(T data);
public static partial ApiResult<T> NotFound(string resource);
public static partial ApiResult<T> BadRequest(string message);
public static partial ApiResult<T> Unauthorized();
}

[HttpGet("{id}")]
public IActionResult GetItem(int id)
{
return _service.GetItem(id).Match(
ok => Ok(ok.Data),
notFound => NotFound(new { Error = notFound.Resource }),
badRequest => BadRequest(new { Error = badRequest.Message }),
unauthorized => Unauthorized()
);
}

ProblemDetails Integration

Generate RFC 7807 compliant error responses:

public static class ResultExtensions
{
public static IActionResult ToProblemDetails<T>(this ApiResult<T> result)
{
return result.Match(
ok => new OkObjectResult(ok.Data),
notFound => new NotFoundObjectResult(new ProblemDetails
{
Status = 404,
Title = "Resource not found",
Detail = notFound.Resource,
Type = "https://tools.ietf.org/html/rfc7231#section-6.5.4"
}),
badRequest => new BadRequestObjectResult(new ProblemDetails
{
Status = 400,
Title = "Bad request",
Detail = badRequest.Message
}),
unauthorized => new UnauthorizedObjectResult(new ProblemDetails
{
Status = 401,
Title = "Unauthorized"
})
);
}
}

CRUD Operations

Complete example with all operations:

[GenerateUnion]
public partial record UserOperationResult
{
public static partial UserOperationResult Created(User user);
public static partial UserOperationResult Updated(User user);
public static partial UserOperationResult Deleted();
public static partial UserOperationResult NotFound();
public static partial UserOperationResult ValidationError(Dictionary<string, string[]> errors);
public static partial UserOperationResult Conflict(string message);
}

[ApiController]
[Route("api/users")]
public class UsersController : ControllerBase
{
[HttpPost]
public IActionResult Create(CreateUserDto dto)
{
return _service.CreateUser(dto).Match(
created => CreatedAtAction(nameof(Get), new { id = created.User.Id }, created.User),
updated => throw new InvalidOperationException(),
deleted => throw new InvalidOperationException(),
notFound => throw new InvalidOperationException(),
validationError => BadRequest(new ValidationProblemDetails(validationError.Errors)),
conflict => Conflict(new { Error = conflict.Message })
);
}

[HttpGet("{id}")]
public IActionResult Get(int id)
{
return _service.GetUser(id).Match(
created => Ok(created.User),
updated => Ok(updated.User),
deleted => throw new InvalidOperationException(),
notFound => NotFound(),
validationError => throw new InvalidOperationException(),
conflict => throw new InvalidOperationException()
);
}

[HttpPut("{id}")]
public IActionResult Update(int id, UpdateUserDto dto)
{
return _service.UpdateUser(id, dto).Match(
created => throw new InvalidOperationException(),
updated => Ok(updated.User),
deleted => throw new InvalidOperationException(),
notFound => NotFound(),
validationError => BadRequest(new ValidationProblemDetails(validationError.Errors)),
conflict => Conflict(new { Error = conflict.Message })
);
}

[HttpDelete("{id}")]
public IActionResult Delete(int id)
{
return _service.DeleteUser(id).Match(
created => throw new InvalidOperationException(),
updated => throw new InvalidOperationException(),
deleted => NoContent(),
notFound => NotFound(),
validationError => throw new InvalidOperationException(),
conflict => Conflict(new { Error = conflict.Message })
);
}
}

Validation Errors

Handle validation errors with ValidationProblemDetails:

[GenerateUnion]
public partial record CreateUserResult
{
public static partial CreateUserResult Success(User user);
public static partial CreateUserResult ValidationFailed(Dictionary<string, string[]> errors);
}

[HttpPost]
public IActionResult CreateUser(CreateUserDto dto)
{
return _service.CreateUser(dto).Match(
success => CreatedAtAction(nameof(GetUser), new { id = success.User.Id }, success.User),
validationFailed => BadRequest(new ValidationProblemDetails(validationFailed.Errors)
{
Title = "Validation failed",
Detail = "One or more validation errors occurred"
})
);
}

File Upload

Handle file uploads with unions:

[GenerateUnion]
public partial record FileUploadResult
{
public static partial FileUploadResult Uploaded(string fileId, long size);
public static partial FileUploadResult TooLarge(long maxSize);
public static partial FileUploadResult InvalidFormat(string[] allowedFormats);
}

[HttpPost("upload")]
public IActionResult Upload(IFormFile file)
{
return _service.Upload(file).Match(
uploaded => Ok(new { FileId = uploaded.FileId, Size = uploaded.Size }),
tooLarge => BadRequest(new ProblemDetails
{
Title = "File too large",
Detail = $"Maximum size is {tooLarge.MaxSize} bytes"
}),
invalidFormat => BadRequest(new ProblemDetails
{
Title = "Invalid format",
Detail = $"Allowed: {string.Join(", ", invalidFormat.AllowedFormats)}"
})
);
}

Best Practices

Consistent Error Types

Define standard error types for your API:

[GenerateUnion]
public partial record ApiError
{
public static partial ApiError NotFound(string resource, string id);
public static partial ApiError ValidationFailed(Dictionary<string, string[]> errors);
public static partial ApiError Unauthorized();
public static partial ApiError Forbidden(string reason);
public static partial ApiError Conflict(string message);
public static partial ApiError ServerError(Exception exception);
}

Extension Methods

Create reusable extension methods:

public static class ApiErrorExtensions
{
public static IActionResult ToActionResult(this ApiError error)
{
return error.Match(
notFound => CreateNotFound(notFound),
validationFailed => CreateValidationError(validationFailed),
unauthorized => new UnauthorizedResult(),
forbidden => CreateForbidden(forbidden),
conflict => CreateConflict(conflict),
serverError => CreateServerError(serverError)
);
}

private static IActionResult CreateNotFound(ApiError.NotFoundCase notFound) =>
new NotFoundObjectResult(new ProblemDetails
{
Status = 404,
Title = "Not found",
Detail = $"{notFound.Resource} with ID '{notFound.Id}' not found"
});

// ... other methods
}

Middleware Integration

Handle exceptions globally:

public class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;

public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
var error = ApiError.ServerError(ex);
var result = error.ToActionResult();

context.Response.StatusCode = GetStatusCode(result);
await context.Response.WriteAsJsonAsync(GetProblemDetails(result));
}
}
}

Key Takeaways

Match method provides natural conversion to HTTP responses
ProblemDetails ensures RFC 7807 compliance
Type safety eliminates magic status codes
Consistent patterns across all endpoints
Works with both controllers and minimal APIs