Documentation

adrs/013-minimal-apis-static-classes.md

ADR-013: Minimal APIs with Static Classes

Status

Accepted

Context

With the adoption of .NET 6+ minimal APIs, we need a consistent pattern for organizing and defining API endpoints. Traditional controller-based APIs use classes with dependency injection through constructors, but minimal APIs offer more flexibility in how endpoints are structured and organized.

Key considerations:

  • Code organization and discoverability
  • Dependency injection patterns
  • Testability and maintainability
  • Separation of concerns
  • Consistency across all services
  • Developer experience and clarity

Decision

We will use static classes with static methods to define and organize minimal API endpoints. Each component service will have a primary static API class that contains all endpoint definitions and their handler methods.

Pattern:

public static class CoreDataApi
{
    public static void MapCoreDataEndpoints(this IEndpointRouteBuilder endpoints)
    {
        // Endpoint definitions
    }

    private static Results<Ok<T>, BadRequest<string>> HandlerMethod(
        [FromRoute] int id,
        [FromServices] ServiceClass service)
    {
        // Handler implementation
    }
}

Consequences

Positive

  • Organization: All endpoints for a component in one place
  • Discoverability: Easy to find all API surface area
  • No State: Static classes prevent accidental state sharing
  • Performance: No instantiation overhead
  • Clarity: Clear separation between endpoint definition and handling
  • Simplicity: No constructor complexity or lifetime management

Negative

  • Testing: Cannot mock static classes directly
  • Dependency Injection: Must use method injection via [FromServices]
  • Code Reuse: Cannot use inheritance for common functionality
  • Large Files: API class can become large for complex services

Neutral

  • Different Paradigm: Shift from OOP controller pattern
  • Method Injection: Dependencies injected per method call
  • No Middleware: Endpoint-specific middleware must be configured inline

Implementation Notes

Basic Structure

namespace Acsis.Dynaplex.Engines.CoreData;

/// <summary>
/// API endpoints for the CoreData component
/// </summary>
public static class CoreDataApi
{
    /// <summary>
    /// Maps all CoreData endpoints to the application
    /// </summary>
    public static void MapCoreDataEndpoints(this IEndpointRouteBuilder endpoints)
    {
        var group = endpoints.MapGroup("/api/core-data")
            .WithTags("Core Data")
            .RequireAuthorization();

        group.MapGet("/languages", GetAllLanguages)
            .WithName("GetLanguages")
            .WithDisplayName("Get all languages");

        group.MapGet("/languages/{id}", GetLanguageById)
            .WithName("GetLanguage")
            .WithDisplayName("Get language by ID");

        group.MapPost("/languages", CreateLanguage)
            .WithName("CreateLanguage")
            .WithDisplayName("Create new language");
    }

    private static async Task<Results<Ok<List<Language>>, NotFound>> GetAllLanguages(
        [FromServices] CoreDataDb db)
    {
        var languages = await db.Languages.ToListAsync();
        return TypedResults.Ok(languages);
    }

    private static async Task<Results<Ok<Language>, NotFound>> GetLanguageById(
        [FromRoute] int id,
        [FromServices] CoreDataDb db)
    {
        var language = await db.Languages.FindAsync(id);
        return language is not null
            ? TypedResults.Ok(language)
            : TypedResults.NotFound();
    }

    private static async Task<Results<Created<Language>, ValidationProblem>> CreateLanguage(
        [FromBody] CreateLanguageRequest request,
        [FromServices] CoreDataDb db,
        [FromServices] IValidator<CreateLanguageRequest> validator)
    {
        var validation = await validator.ValidateAsync(request);
        if (!validation.IsValid)
            return TypedResults.ValidationProblem(validation.ToDictionary());

        var language = new Language
        {
            Code = request.Code,
            Name = request.Name
        };

        db.Languages.Add(language);
        await db.SaveChangesAsync();

        return TypedResults.Created($"/api/core-data/languages/{language.Id}", language);
    }
}

Service Registration

In Program.cs:

var app = builder.Build();

// Map component endpoints
app.MapCoreDataEndpoints();
app.MapIdentityEndpoints();
app.MapCatalogEndpoints();

Dependency Injection Pattern

Always use [FromServices] attribute:

private static async Task<Ok<Data>> GetData(
    [FromServices] IDataService service,  // Injected per request
    [FromServices] ILogger<Program> logger,
    [FromRoute] int id)
{
    logger.LogInformation("Getting data for {Id}", id);
    var data = await service.GetByIdAsync(id);
    return TypedResults.Ok(data);
}

Complex Handlers

For complex logic, delegate to service classes:

private static async Task<Results<Ok<Asset>, NotFound, BadRequest<string>>> UpdateAsset(
    [FromRoute] int id,
    [FromBody] UpdateAssetRequest request,
    [FromServices] IAssetService service)
{
    // Minimal API handler just coordinates
    var result = await service.UpdateAssetAsync(id, request);

    return result.Match<Results<Ok<Asset>, NotFound, BadRequest<string>>>(
        success: asset => TypedResults.Ok(asset),
        notFound: () => TypedResults.NotFound(),
        invalid: error => TypedResults.BadRequest(error)
    );
}

Testing Strategy

Test through the service layer:

[Test]
public async Task GetLanguages_ReturnsAllLanguages()
{
    // Arrange
    using var factory = new WebApplicationFactory<Program>();
    var client = factory.CreateClient();

    // Act
    var response = await client.GetAsync("/api/core-data/languages");

    // Assert
    response.StatusCode.Should().Be(HttpStatusCode.OK);
    var languages = await response.Content.ReadFromJsonAsync<List<Language>>();
    languages.Should().NotBeEmpty();
}

Organization for Large APIs

Split into multiple static classes by domain:

public static class CoreDataApi
{
    public static void MapCoreDataEndpoints(this IEndpointRouteBuilder endpoints)
    {
        endpoints.MapLanguageEndpoints();
        endpoints.MapConfigurationEndpoints();
        endpoints.MapUtilityEndpoints();
    }
}

internal static class LanguageEndpoints
{
    public static void MapLanguageEndpoints(this IEndpointRouteBuilder endpoints)
    {
        // Language-specific endpoints
    }
}
  • ADR-014: TypedResults for Type-Safe Responses (return types)
  • ADR-015: Extension Methods for Endpoint Registration (registration pattern)
  • ADR-016: Endpoint Grouping and Tagging Strategy (organization)
  • ADR-017: OpenAPI Metadata Enrichment (documentation)