Documentation

adrs/014-typedresults-type-safe-responses.md

ADR-014: TypedResults for Type-Safe Responses

Status

Accepted

Context

Minimal APIs in .NET can return various types from endpoint handlers, including raw objects, IResult implementations, or custom types. Without a consistent approach, APIs can become inconsistent in their response types and status codes. Additionally, OpenAPI documentation generation works better with strongly-typed responses.

Key challenges:

  • Inconsistent response types across endpoints
  • Lack of compile-time safety for HTTP status codes
  • Poor OpenAPI/Swagger documentation without explicit types
  • Difficulty in testing without known response types
  • Implicit status code determination can be confusing

Decision

We will use TypedResults and the Results<T1, T2, ...> pattern for all minimal API endpoint responses to ensure type safety and consistent HTTP responses.

Pattern:

private static Results<Ok<Product>, NotFound, BadRequest<string>> GetProduct(
    [FromRoute] int id,
    [FromServices] IProductService service)
{
    // Return type explicitly declares all possible outcomes
    if (id <= 0)
        return TypedResults.BadRequest("Invalid product ID");
        
    var product = service.GetById(id);
    
    return product is not null 
        ? TypedResults.Ok(product)
        : TypedResults.NotFound();
}

Consequences

Positive

  • Type Safety: Compile-time checking of all response types
  • OpenAPI Generation: Automatic, accurate API documentation
  • Consistency: Standardized response patterns across all endpoints
  • IntelliSense: IDE support for available response types
  • Testing: Known response types make testing easier
  • Self-Documenting: Return signature shows all possible responses

Negative

  • Verbosity: Return types can become long with multiple outcomes
  • Learning Curve: Developers must understand Results<> pattern
  • Limited Results: Maximum of 6 result types in Results<>
  • Boilerplate: More explicit code compared to returning raw objects

Neutral

  • Explicit Status Codes: Must explicitly specify HTTP status codes
  • No Implicit Conversion: Cannot return raw objects directly
  • Pattern Matching: Often used with pattern matching for handling

Implementation Notes

Common Response Patterns

Single Success Response

private static Ok<List<Product>> GetAllProducts(
    [FromServices] IProductService service)
{
    var products = service.GetAll();
    return TypedResults.Ok(products);
}

Success or Not Found

private static Results<Ok<Product>, NotFound> GetProductById(
    [FromRoute] int id,
    [FromServices] IProductService service)
{
    var product = service.GetById(id);
    return product is not null 
        ? TypedResults.Ok(product)
        : TypedResults.NotFound();
}

Create with Validation

private static async Task<Results<Created<Product>, ValidationProblem>> CreateProduct(
    [FromBody] CreateProductRequest request,
    [FromServices] IValidator<CreateProductRequest> validator,
    [FromServices] IProductService service)
{
    var validation = await validator.ValidateAsync(request);
    if (!validation.IsValid)
        return TypedResults.ValidationProblem(validation.ToDictionary());
        
    var product = await service.CreateAsync(request);
    return TypedResults.Created($"/api/products/{product.Id}", product);
}

Multiple Error Scenarios

private static async Task<Results<Ok<Product>, NotFound, Conflict, BadRequest<string>>> UpdateProduct(
    [FromRoute] int id,
    [FromBody] UpdateProductRequest request,
    [FromServices] IProductService service)
{
    if (id != request.Id)
        return TypedResults.BadRequest("ID mismatch");
        
    var existing = await service.GetByIdAsync(id);
    if (existing is null)
        return TypedResults.NotFound();
        
    if (await service.IsDuplicateName(request.Name, id))
        return TypedResults.Conflict();
        
    var updated = await service.UpdateAsync(request);
    return TypedResults.Ok(updated);
}

TypedResults Factory Methods

Common TypedResults methods:

// 2xx Success
TypedResults.Ok(value)                    // 200
TypedResults.Created(uri, value)           // 201
TypedResults.Accepted(value)               // 202
TypedResults.NoContent()                   // 204

// 4xx Client Errors
TypedResults.BadRequest(error)             // 400
TypedResults.Unauthorized()                 // 401
TypedResults.Forbidden()                    // 403
TypedResults.NotFound()                     // 404
TypedResults.Conflict()                     // 409
TypedResults.UnprocessableEntity(error)    // 422
TypedResults.ValidationProblem(errors)     // 400 with validation

// 5xx Server Errors
TypedResults.InternalServerError()         // 500 (avoid using)
TypedResults.StatusCode(500)               // Generic status code

OpenAPI Documentation

TypedResults automatically generates accurate OpenAPI specs:

/api/products/{id}:
  get:
    responses:
      '200':
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Product'
      '404':
        description: Not Found
      '400':
        content:
          text/plain:
            schema:
              type: string

Testing with TypedResults

[Test]
public async Task GetProduct_WhenExists_ReturnsOk()
{
    // Arrange
    var service = Substitute.For<IProductService>();
    service.GetById(1).Returns(new Product { Id = 1 });
    
    // Act
    var result = GetProduct(1, service);
    
    // Assert
    result.Result.Should().BeOfType<Ok<Product>>();
    var okResult = (Ok<Product>)result.Result;
    okResult.Value.Id.Should().Be(1);
}

Error Response Consistency

Standardize error responses:

public record ProblemDetails(string Title, string Detail, int Status);

private static BadRequest<ProblemDetails> BadRequestProblem(string detail)
{
    return TypedResults.BadRequest(new ProblemDetails(
        Title: "Bad Request",
        Detail: detail,
        Status: 400
    ));
}

Handling File Responses

private static Results<FileContentHttpResult, NotFound> DownloadFile(
    [FromRoute] int id,
    [FromServices] IFileService service)
{
    var file = service.GetFile(id);
    
    return file is not null
        ? TypedResults.File(file.Content, file.ContentType, file.Name)
        : TypedResults.NotFound();
}

Best Practices

  1. Always use TypedResults factory methods instead of creating IResult manually
  2. Order result types by likelihood: Most common first
  3. Limit to 3-4 result types when possible for readability
  4. Use ValidationProblem for validation errors with details
  5. Avoid InternalServerError: Let exception middleware handle 500s
  6. Be specific with error types: BadRequest vs BadRequest
  • ADR-013: Minimal APIs with Static Classes (endpoint structure)
  • ADR-017: OpenAPI Metadata Enrichment (documentation generation)
  • ADR-021: Microsoft Kiota for API Client Generation (client generation from types)