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
}
}
Related ADRs
- 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)