Building High-Performance Minimal APIs in .NET 8

Introduction

Minimal APIs in .NET 8 provide a simplified way to build HTTP APIs with minimal dependencies and ceremony. This article explores how to leverage Minimal APIs for building high-performance web services.

Getting Started

Basic Setup

Create a new Minimal API project:

dotnet new webapi -minimal -n MinimalApiDemo
cd MinimalApiDemo

Simple Endpoint

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.MapGet("/hello", () => "Hello, World!");

app.Run();

Organizing Endpoints

Using Extension Methods

Keep your Program.cs clean by organizing endpoints:

public static class ProductEndpoints
{
    public static void MapProductEndpoints(this WebApplication app)
    {
        var products = app.MapGroup("/api/products")
            .WithTags("Products")
            .WithOpenApi();

        products.MapGet("/", GetAllProducts);
        products.MapGet("/{id}", GetProductById);
        products.MapPost("/", CreateProduct);
        products.MapPut("/{id}", UpdateProduct);
        products.MapDelete("/{id}", DeleteProduct);
    }

    private static async Task<IResult> GetAllProducts(
        IProductRepository repository,
        int? page = 1,
        int? pageSize = 10)
    {
        var products = await repository.GetAllAsync(page.Value, pageSize.Value);
        return Results.Ok(products);
    }

    private static async Task<IResult> GetProductById(
        int id,
        IProductRepository repository)
    {
        var product = await repository.GetByIdAsync(id);
        return product is not null
            ? Results.Ok(product)
            : Results.NotFound();
    }

    private static async Task<IResult> CreateProduct(
        CreateProductRequest request,
        IProductRepository repository,
        IValidator<CreateProductRequest> validator)
    {
        var validationResult = await validator.ValidateAsync(request);
        if (!validationResult.IsValid)
            return Results.ValidationProblem(validationResult.ToDictionary());

        var product = new Product
        {
            Name = request.Name,
            Price = request.Price,
            Description = request.Description
        };

        var id = await repository.CreateAsync(product);
        return Results.Created($"/api/products/{id}", new { id });
    }
}

Request Validation

Using FluentValidation

public class CreateProductRequestValidator : AbstractValidator<CreateProductRequest>
{
    public CreateProductRequestValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty()
            .MaximumLength(100);

        RuleFor(x => x.Price)
            .GreaterThan(0)
            .LessThanOrEqualTo(1000000);

        RuleFor(x => x.Description)
            .MaximumLength(500);
    }
}

// Registration
builder.Services.AddScoped<IValidator<CreateProductRequest>, CreateProductRequestValidator>();

Custom Validation Filter

public class ValidationFilter<T> : IEndpointFilter where T : class
{
    private readonly IValidator<T> _validator;

    public ValidationFilter(IValidator<T> validator)
    {
        _validator = validator;
    }

    public async ValueTask<object?> InvokeAsync(
        EndpointFilterContext context,
        EndpointFilterDelegate next)
    {
        var argument = context.Arguments
            .SingleOrDefault(x => x?.GetType() == typeof(T));

        if (argument is T typedArgument)
        {
            var validationResult = await _validator.ValidateAsync(typedArgument);
            if (!validationResult.IsValid)
            {
                return Results.ValidationProblem(validationResult.ToDictionary());
            }
        }

        return await next(context);
    }
}

// Usage
app.MapPost("/api/products", CreateProduct)
    .AddEndpointFilter<ValidationFilter<CreateProductRequest>>();

Authentication and Authorization

JWT Authentication

// Configuration
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = builder.Configuration["Jwt:Issuer,
            ValidAudience = builder.Configuration["Jwt:Audience,
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key))
        };
    });

builder.Services.AddAuthorization();

// Protected endpoints
app.MapGet("/api/secure/data", () => "Secret data!")
    .RequireAuthorization();

app.MapPost("/api/admin/action", () => "Admin action executed!")
    .RequireAuthorization("AdminOnly");

Custom Authorization Policies

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("AdminOnly", policy =>
        policy.RequireRole("Admin"));

    options.AddPolicy("MinimumAge", policy =>
        policy.Requirements.Add(new MinimumAgeRequirement(18)));
});

// Custom requirement and handler
public class MinimumAgeRequirement : IAuthorizationRequirement
{
    public int MinimumAge { get; }

    public MinimumAgeRequirement(int minimumAge)
    {
        MinimumAge = minimumAge;
    }
}

public class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        MinimumAgeRequirement requirement)
    {
        var dateOfBirthClaim = context.User.FindFirst("DateOfBirth");

        if (dateOfBirthClaim is null)
        {
            return Task.CompletedTask;
        }

        var dateOfBirth = DateTime.Parse(dateOfBirthClaim.Value);
        var age = DateTime.Today.Year - dateOfBirth.Year;

        if (age >= requirement.MinimumAge)
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

Performance Optimization

Response Caching

builder.Services.AddResponseCaching();

app.UseResponseCaching();

app.MapGet("/api/cached-data", () => new { timestamp = DateTime.UtcNow })
    .CacheOutput(policy => policy.Expire(TimeSpan.FromMinutes(5)));

Rate Limiting

builder.Services.AddRateLimiter(options =>
{
    options.AddFixedWindowLimiter("fixed", config =>
    {
        config.PermitLimit = 10;
        config.Window = TimeSpan.FromMinutes(1);
        config.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        config.QueueLimit = 5;
    });

    options.AddSlidingWindowLimiter("sliding", config =>
    {
        config.PermitLimit = 100;
        config.Window = TimeSpan.FromMinutes(1);
        config.SegmentsPerWindow = 4;
        config.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        config.QueueLimit = 10;
    });
});

app.UseRateLimiter();

app.MapGet("/api/limited", () => "Rate limited endpoint")
    .RequireRateLimiting("fixed");

Error Handling

Global Exception Handler

app.UseExceptionHandler(exceptionHandlerApp =>
{
    exceptionHandlerApp.Run(async context =>
    {
        context.Response.StatusCode = StatusCodes.Status500InternalServerError;
        context.Response.ContentType = "application/json";

        var exceptionHandlerFeature = context.Features.Get<IExceptionHandlerFeature>();

        if (exceptionHandlerFeature != null)
        {
            var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
            logger.LogError(exceptionHandlerFeature.Error
  - Unhandled exception occurred");

            await context.Response.WriteAsJsonAsync(new
            {
                StatusCode = context.Response.StatusCode,
                Message = "An error occurred while processing your request."
            });
        }
    });
});

Problem Details

builder.Services.AddProblemDetails(options =>
{
    options.CustomizeProblemDetails = context =>
    {
        context.ProblemDetails.Extensions.Add("traceId", Activity.Current?.Id ?? context.HttpContext.TraceIdentifier);
        context.ProblemDetails.Extensions.Add("timestamp", DateTimeOffset.UtcNow);
    };
});

Testing Minimal APIs

Integration Testing

public class ProductApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;
    private readonly HttpClient _client;

    public ProductApiTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory;
        _client = _factory.CreateClient();
    }

    [Fact]
    public async Task GetProducts_ReturnsSuccessStatusCode()
    {
        // Act
        var response = await _client.GetAsync("/api/products");

        // Assert
        response.EnsureSuccessStatusCode();
        var content = await response.Content.ReadAsStringAsync();
        Assert.NotEmpty(content);
    }

    [Fact]
    public async Task CreateProduct_WithValidData_ReturnsCreated()
    {
        // Arrange
        var product = new CreateProductRequest
        {
            Name = "Test Product",
            Price = 99.99m,
            Description = "Test Description"
        };

        // Act
        var response = await _client.PostAsJsonAsync("/api/products", product);

        // Assert
        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
        Assert.Contains("/api/products/", response.Headers.Location?.ToString());
    }
}

Conclusion

Minimal APIs in .NET 8 provide a powerful and performant way to build web APIs with less ceremony and boilerplate. By following these patterns and best practices, you can create maintainable, scalable, and high-performance APIs that meet modern application requirements.

Comments