Home C# / .NET Minimal APIs in ASP.NET Core — Build Fast, Lean REST Endpoints

Minimal APIs in ASP.NET Core — Build Fast, Lean REST Endpoints

In Plain English 🔥
Imagine you run a food truck. A full restaurant needs a host stand, a maître d', printed menus, linen tablecloths, and a reservations system — even if you're only selling tacos. A food truck skips all that and just hands you a taco through a window. Minimal APIs are ASP.NET Core's food truck: you get exactly what you need (an HTTP endpoint that responds to requests) without setting up a full restaurant (controllers, action filters, routing attributes, view engines). When all you need is a taco, don't build a restaurant.
⚡ Quick Answer
Imagine you run a food truck. A full restaurant needs a host stand, a maître d', printed menus, linen tablecloths, and a reservations system — even if you're only selling tacos. A food truck skips all that and just hands you a taco through a window. Minimal APIs are ASP.NET Core's food truck: you get exactly what you need (an HTTP endpoint that responds to requests) without setting up a full restaurant (controllers, action filters, routing attributes, view engines). When all you need is a taco, don't build a restaurant.

REST APIs power almost every app you use today — from the weather widget on your phone to the checkout button on an e-commerce site. In the .NET world, the traditional way to build those APIs meant spinning up controllers, wiring up attributes, following the MVC folder structure, and writing a surprising amount of code before a single byte reached a client. For large, team-built enterprise apps that structure is genuinely useful. But for microservices, lightweight backend-for-frontend services, rapid prototypes, or serverless functions, it's overkill that slows you down.

Minimal APIs, introduced in .NET 6 and significantly matured in .NET 7 and .NET 8, let you define an entire HTTP endpoint in a single line of C#. No controller class. No [HttpGet] attribute. No routing conventions to memorise. The framework infers everything it can and gets out of your way. The result is a Program.cs file that reads almost like plain English, compiles faster, and starts up faster — which matters enormously in containerised, auto-scaling environments where cold-start time costs real money.

By the end of this article you'll know exactly when to choose Minimal APIs over controller-based APIs (and vice versa), how to structure a real-world Minimal API with dependency injection, validation, error handling, and OpenAPI docs, and the gotchas that will bite you if you don't know about them. We'll build a working Product Catalogue API from scratch — one section at a time — so every piece of code you see connects to a real need.

Your First Minimal API — What's Actually Happening Under the Hood

Before we build something real, let's understand what the framework is actually doing when you write a Minimal API endpoint. When you call app.MapGet(), you're registering a route handler directly with the endpoint routing middleware. There's no controller class being instantiated, no action method being discovered via reflection at startup, and no IActionResult being unwrapped. The lambda you pass IS the handler — it runs directly.

This matters because it changes the performance profile. Controller-based APIs use model binding and action filters that add overhead on every request. Minimal API handlers use a source-generated parameter binder (from .NET 7 onward) that's resolved at compile time. That means faster startup and slightly lower per-request overhead — which adds up at scale.

The builder/app split in Program.cs is also worth understanding. WebApplication.CreateBuilder() sets up the DI container and configuration. builder.Build() locks it in and returns the WebApplication. Everything you call on app after that is middleware or endpoint registration. Order matters here — we'll hit that in the Gotchas section.

MinimalApiBasics.cs · CSHARP
1234567891011121314151617181920212223242526272829303132333435363738394041424344
// Program.cs — a complete, runnable Minimal API
// Requires: dotnet new web -o ProductApi
// No controllers, no separate Startup.cs, no folder structure required.

var builder = WebApplication.CreateBuilder(args);

// Register services into the DI container BEFORE calling Build()
builder.Services.AddEndpointsApiExplorer(); // Enables OpenAPI endpoint discovery
builder.Services.AddSwaggerGen();           // Wires up Swagger UI

var app = builder.Build();

// Middleware runs in the order you register it — this matters!
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();    // Serves the OpenAPI JSON spec at /swagger/v1/swagger.json
    app.UseSwaggerUI(); // Serves the browser UI at /swagger
}

// MapGet registers a GET handler for the route "/"
// The lambda IS the handler — no controller needed
app.MapGet("/", () => "Product Catalogue API is running!");

// MapGet with a route parameter — {id} is captured and injected automatically
// ASP.NET Core infers the int type from the parameter name and type annotation
app.MapGet("/products/{id}", (int id) =>
{
    // In a real app this would call a service; here we prove the binding works
    return Results.Ok(new { ProductId = id, Name = $"Product #{id}", Price = 9.99m });
});

// MapPost accepts a JSON body — the [FromBody] attribute is inferred automatically
// The record type is deserialized from the request body using System.Text.Json
app.MapPost("/products", (CreateProductRequest request) =>
{
    // Results.Created sets HTTP 201 and the Location header — correct REST semantics
    return Results.Created($"/products/42", new { Id = 42, request.Name, request.Price });
});

app.Run();

// Define your request/response models — keep them close to where they're used
// Using records gives you immutability and value equality for free
public record CreateProductRequest(string Name, decimal Price);
▶ Output
// GET /
Product Catalogue API is running!

// GET /products/7
{
"productId": 7,
"name": "Product #7",
"price": 9.99
}

// POST /products { "name": "Widget", "price": 4.99 }
// HTTP 201 Created
// Location: /products/42
{
"id": 42,
"name": "Widget",
"price": 4.99
}
🔥
Why Records?Using C# record types for request/response models isn't just style — records are immutable by default, serialize cleanly with System.Text.Json, and give you structural equality without writing Equals() overrides. They're the idiomatic choice for Minimal API DTOs.

Structuring a Real-World Minimal API — Dependency Injection and Route Groups

The single-file demo looks tidy at 30 lines. At 300 lines it becomes a maintenance nightmare. The good news is Minimal APIs have a clean answer: Route Groups and extension methods. You split your endpoints across feature files and group them under a shared route prefix — without giving up any of the performance benefits.

Route Groups (added in .NET 7) let you call app.MapGroup('/products') and then register all product-related endpoints on that group object. The prefix is applied automatically, middleware like authentication can be attached to the group, and your Program.cs stays clean.

Dependency injection works exactly as you'd expect — you declare your service as a parameter in the handler lambda and ASP.NET Core resolves it from the DI container. There's no constructor injection because there's no class, but the result is the same. Typed parameters from the route, query string, body, and DI container are all resolved by position and type — the framework figures out where each one comes from.

This pattern scales well. You get one feature file per domain entity, a clean Program.cs, and full access to the DI container. It's the pattern used in production Minimal API services today.

ProductEndpoints.cs · CSHARP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
// ── ProductEndpoints.cs ──────────────────────────────────────────────────────
// Extension method that groups all /products endpoints in one place.
// Program.cs calls app.MapProductEndpoints() — keeping the entry file clean.

using Microsoft.AspNetCore.Http.HttpResults; // Typed Results — strongly-typed return types

public static class ProductEndpoints
{
    public static void MapProductEndpoints(this WebApplication app)
    {
        // RouteGroupBuilder applies the prefix AND any shared middleware to every endpoint below
        var group = app.MapGroup("/products")
                       .WithTags("Products")       // Groups endpoints in Swagger UI
                       .WithOpenApi();             // Generates OpenAPI metadata automatically

        group.MapGet("/",    GetAllProducts);
        group.MapGet("/{id:int}", GetProductById); // :int constrains the route — 404 on non-int
        group.MapPost("/",   CreateProduct);
        group.MapPut("/{id:int}",    UpdateProduct);
        group.MapDelete("/{id:int}", DeleteProduct);
    }

    // Handler methods are static — no allocations for a class instance per request
    // IProductService is resolved from DI; int id comes from the route segment
    static async Task<Ok<IEnumerable<Product>>> GetAllProducts(
        IProductService productService)
    {
        var products = await productService.GetAllAsync();
        return TypedResults.Ok(products); // TypedResults gives compile-time HTTP status verification
    }

    static async Task<Results<Ok<Product>, NotFound>> GetProductById(
        int id,
        IProductService productService)
    {
        var product = await productService.GetByIdAsync(id);

        // Pattern match on null — returns 404 if not found, 200 with body if found
        return product is null
            ? TypedResults.NotFound()
            : TypedResults.Ok(product);
    }

    static async Task<Created<Product>> CreateProduct(
        CreateProductRequest request,       // Bound from JSON request body
        IProductService productService,
        LinkGenerator linkGenerator,        // Built-in service to generate URLs
        HttpContext httpContext)
    {
        var created = await productService.CreateAsync(request);

        // Generate the Location header pointing to the new resource — correct REST
        var location = linkGenerator.GetPathByName(httpContext, "GetProductById",
                                                   new { id = created.Id });
        return TypedResults.Created(location, created);
    }

    static async Task<Results<NoContent, NotFound>> UpdateProduct(
        int id,
        UpdateProductRequest request,
        IProductService productService)
    {
        var success = await productService.UpdateAsync(id, request);
        return success ? TypedResults.NoContent() : TypedResults.NotFound();
    }

    static async Task<Results<NoContent, NotFound>> DeleteProduct(
        int id,
        IProductService productService)
    {
        var success = await productService.DeleteAsync(id);
        return success ? TypedResults.NoContent() : TypedResults.NotFound();
    }
}

// ── Program.cs (the clean version) ──────────────────────────────────────────
var builder = WebApplication.CreateBuilder(args);

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

// Register your domain services — Minimal APIs use the SAME DI system as controllers
builder.Services.AddScoped<IProductService, ProductService>();

var app = builder.Build();

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

app.UseHttpsRedirection();

// One line per feature area — Program.cs stays readable at any scale
app.MapProductEndpoints();

app.Run();

// ── Domain models (simplified for clarity) ───────────────────────────────────
public record Product(int Id, string Name, decimal Price, int StockCount);
public record CreateProductRequest(string Name, decimal Price, int StockCount);
public record UpdateProductRequest(string Name, decimal Price, int StockCount);

public interface IProductService
{
    Task<IEnumerable<Product>> GetAllAsync();
    Task<Product?> GetByIdAsync(int id);
    Task<Product>  CreateAsync(CreateProductRequest request);
    Task<bool>     UpdateAsync(int id, UpdateProductRequest request);
    Task<bool>     DeleteAsync(int id);
}
▶ Output
// GET /products
HTTP 200 OK
[
{ "id": 1, "name": "Widget", "price": 4.99, "stockCount": 100 },
{ "id": 2, "name": "Gadget", "price": 19.99, "stockCount": 45 }
]

// GET /products/99 (non-existent)
HTTP 404 Not Found

// GET /products/abc (route constraint :int fails)
HTTP 404 Not Found ← route never matched, no handler runs

// POST /products
HTTP 201 Created
Location: /products/3
{ "id": 3, "name": "Sprocket", "price": 2.49, "stockCount": 200 }
⚠️
Pro Tip: Use TypedResults, Not ResultsTypedResults.Ok() returns a strongly-typed result object. This means your return type is part of the method signature, which lets ASP.NET Core generate accurate OpenAPI schemas without any extra attributes. If you use the untyped Results.Ok(), the schema shows up as 'any' in Swagger. TypedResults also makes your handlers unit-testable — you can assert the exact HTTP status in a unit test without spinning up a test server.

Validation, Error Handling, and Filters — Keeping Handlers Thin

One criticism of Minimal APIs is that validation logic can creep into handlers, making them fat and hard to test. The mature answer is Endpoint Filters — the Minimal API equivalent of Action Filters in MVC. A filter wraps your handler, runs before and after it, and can short-circuit the pipeline by returning early.

For validation, the community-standard library FluentValidation pairs beautifully with a small generic filter. You register your validators in DI, write one filter, and every endpoint that opts in gets automatic 400 responses with structured error details — no validation code inside any handler.

For global error handling, UseExceptionHandler middleware catches unhandled exceptions and returns a structured Problem Details response (RFC 7807) instead of a naked 500. This is critical for APIs because clients need machine-readable error shapes.

The pattern below shows both working together. Notice how the handler itself stays clean — it trusts that by the time it runs, the input is valid. That separation of concerns is what makes Minimal APIs maintainable at scale.

ValidationAndErrorHandling.cs · CSHARP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
// ── ValidationFilter.cs ─────────────────────────────────────────────────────
// A generic endpoint filter that validates any request model using FluentValidation.
// Add NuGet: FluentValidation.AspNetCore

using FluentValidation;

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

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

    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        // Find the argument that matches our request type in the handler's parameter list
        var request = context.Arguments
                             .OfType<TRequest>()
                             .FirstOrDefault();

        if (request is null)
            return Results.BadRequest("Request body is missing.");

        var validationResult = await _validator.ValidateAsync(request);

        if (!validationResult.IsValid)
        {
            // Return a structured 400 with all validation errors — client-friendly
            var errors = validationResult.Errors
                .GroupBy(e => e.PropertyName)
                .ToDictionary(
                    group => group.Key,
                    group => group.Select(e => e.ErrorMessage).ToArray()
                );

            return Results.ValidationProblem(errors); // Returns RFC 7807 Problem Details
        }

        // Validation passed — call the next filter or the actual handler
        return await next(context);
    }
}

// ── CreateProductRequestValidator.cs ─────────────────────────────────────────
public class CreateProductRequestValidator : AbstractValidator<CreateProductRequest>
{
    public CreateProductRequestValidator()
    {
        RuleFor(r => r.Name)
            .NotEmpty().WithMessage("Product name is required.")
            .MaximumLength(100).WithMessage("Name cannot exceed 100 characters.");

        RuleFor(r => r.Price)
            .GreaterThan(0).WithMessage("Price must be greater than zero.");

        RuleFor(r => r.StockCount)
            .GreaterThanOrEqualTo(0).WithMessage("Stock count cannot be negative.");
    }
}

// ── How to wire it up in ProductEndpoints.cs ─────────────────────────────────
// Apply the filter to only the POST endpoint — surgical, not global
group.MapPost("/", CreateProduct)
     .AddEndpointFilter<ValidationFilter<CreateProductRequest>>();
     // That's it. The handler stays clean.

// ── Program.cs additions for global error handling ───────────────────────────
builder.Services.AddProblemDetails(); // Registers the RFC 7807 Problem Details formatter

// Register all FluentValidation validators from the current assembly automatically
builder.Services.AddValidatorsFromAssemblyContaining<CreateProductRequestValidator>();

// ...

var app = builder.Build();

// UseExceptionHandler MUST come before endpoint mapping
// In .NET 8+, this one-liner handles all unhandled exceptions as Problem Details
app.UseExceptionHandler();
app.UseStatusCodePages(); // Turns empty 404/405 responses into Problem Details too

// ── Testing the validation (curl example) ────────────────────────────────────
// curl -X POST https://localhost:5001/products \
//      -H 'Content-Type: application/json' \
//      -d '{"name": "", "price": -5, "stockCount": -1}'
▶ Output
// POST /products with invalid body
HTTP 400 Bad Request
Content-Type: application/problem+json

{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Name": ["Product name is required."],
"Price": ["Price must be greater than zero."],
"StockCount": ["Stock count cannot be negative."]
}
}

// Unhandled exception (e.g., DB connection dropped)
HTTP 500 Internal Server Error
Content-Type: application/problem+json

{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
"title": "An error occurred while processing your request.",
"status": 500
}
⚠️
Watch Out: Filter Registration OrderEndpoint filters run in the order you chain .AddEndpointFilter() calls. If you add an authentication filter AND a validation filter, put authentication first. Validating a request body before confirming the caller is authenticated wastes CPU and can leak information about your schema to unauthenticated users.

Minimal APIs vs Controller-Based APIs — When to Choose Which

This is the question that matters most in a real project, and the honest answer is: it depends on what you're building, not on personal preference. Minimal APIs genuinely win for microservices, internal tooling APIs, BFF (Backend for Frontend) layers, and any scenario where startup time, memory footprint, and simplicity are priorities. Controller-based APIs still win for large team codebases where conventions enforce consistency, for APIs that need complex action filters, for apps already built on MVC, and anywhere OData or API versioning via the established libraries is required.

The most important thing to understand is that this isn't an either/or per project. In .NET 6+ you can mix both in the same application. You might use Minimal APIs for your health check endpoint and lightweight query endpoints, while your complex write operations live in a controller with full model validation pipelines.

From .NET 7 onward, the feature gap has closed significantly. Minimal APIs now support filters, output caching, rate limiting, authentication, authorisation, and OpenAPI — the things that kept teams on controllers. The remaining gap is tooling: Swagger attribute decoration and complex versioning scenarios are still smoother on controllers today.

AuthAndRateLimiting.cs · CSHARP
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
// Demonstrating auth, rate limiting, and output caching on Minimal API endpoints
// These show Minimal APIs can handle production concerns — not just toy examples

// ── Program.cs service registrations ─────────────────────────────────────────
builder.Services.AddAuthentication().AddJwtBearer(); // JWT auth in one line
builder.Services.AddAuthorization();

builder.Services.AddOutputCache(options =>
{
    // Define a named cache policy — endpoints opt in by name
    options.AddPolicy("ProductCachePolicy", policyBuilder =>
        policyBuilder.Expire(TimeSpan.FromSeconds(30))
                     .Tag("products")); // Tag lets us invalidate by group later
});

builder.Services.AddRateLimiter(options =>
{
    // Fixed window: max 100 requests per minute per IP address
    options.AddFixedWindowLimiter("ApiRateLimit", limiterOptions =>
    {
        limiterOptions.PermitLimit = 100;
        limiterOptions.Window = TimeSpan.FromMinutes(1);
        limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        limiterOptions.QueueLimit = 5; // Buffer up to 5 excess requests, then 503
    });

    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});

// ── app middleware — ORDER MATTERS ────────────────────────────────────────────
app.UseAuthentication(); // Must come before UseAuthorization
app.UseAuthorization();
app.UseOutputCache();
app.UseRateLimiter();

// ── Endpoint with all production concerns attached ───────────────────────────
var group = app.MapGroup("/products")
               .WithTags("Products")
               .RequireAuthorization()        // Every endpoint in this group needs a valid JWT
               .RequireRateLimiting("ApiRateLimit"); // Apply rate limit to the whole group

// This specific endpoint also uses output caching — expensive DB read cached for 30s
group.MapGet("/", async (IProductService productService) =>
    {
        var products = await productService.GetAllAsync();
        return TypedResults.Ok(products);
    })
    .CacheOutput("ProductCachePolicy"); // Only GET /products is cached, not the whole group

// Cache invalidation when a product is created
group.MapPost("/", async (
    CreateProductRequest request,
    IProductService productService,
    IOutputCacheStore cacheStore,          // Inject the cache store directly
    CancellationToken cancellationToken) =>
{
    var created = await productService.CreateAsync(request);

    // Invalidate all cached responses tagged "products" so GET / reflects the new item
    await cacheStore.EvictByTagAsync("products", cancellationToken);

    return TypedResults.Created($"/products/{created.Id}", created);
})
.AddEndpointFilter<ValidationFilter<CreateProductRequest>>();
▶ Output
// GET /products (first call — cache miss, hits DB)
HTTP 200 OK
Cache-Control: public, max-age=30
[ ... products ... ]

// GET /products (within 30s — cache hit, no DB call)
HTTP 200 OK
Age: 12
[ ... products ... ] ← served from memory in ~0.1ms

// GET /products (101st request in 1 minute from same IP)
HTTP 429 Too Many Requests
Retry-After: 43
🔥
Interview Gold: The Startup Performance DifferenceIn benchmarks with a simple CRUD API, Minimal APIs typically start 20-40% faster than the equivalent controller-based API because they skip MVC's controller discovery and action descriptor population at startup. In AWS Lambda or Azure Container Apps where cold starts are billed, this translates directly to cost savings.
Feature / AspectMinimal APIsController-Based APIs
Boilerplate to get started~5 lines in Program.csController class + attributes + routing config
Startup timeFaster — no controller discoverySlower — full MVC pipeline initialisation
Memory footprintLower — fewer middleware componentsHigher — MVC infrastructure always loaded
Dependency injectionParameters injected into lambda directlyConstructor injection in controller class
Filters / middlewareEndpoint Filters (.AddEndpointFilter())Action Filters + Global Filters via MvcOptions
OpenAPI / Swagger supportFull support via .WithOpenApi()Full support via [ProducesResponseType] attributes
Unit testability of handlersExcellent with TypedResultsGood — controller methods are regular methods
Route groupingMapGroup() with shared prefix + middlewareController base route + [Route] attribute
API versioningSupported but less mature toolingMature ecosystem (Asp.Versioning.Http)
Team convention enforcementRelies on agreed patterns + extension methodsController conventions enforce structure automatically
Best suited forMicroservices, BFFs, serverless, internal toolsLarge team APIs, complex enterprise apps, MVC migrations

🎯 Key Takeaways

  • Minimal APIs skip MVC's controller discovery at startup — that's not laziness, it's a real performance win that matters in cold-start serverless environments.
  • TypedResults over Results — always. It locks in the return type at compile time, generates accurate OpenAPI schemas, and makes your handlers unit-testable without a test server.
  • The MapGroup() + extension method pattern is what keeps Minimal APIs maintainable beyond 10 endpoints. Endpoint registration belongs in feature files, not Program.cs.
  • Minimal APIs and controller-based APIs can coexist in the same ASP.NET Core application. Choosing one doesn't mean abandoning the other — migrate incrementally or use both where each shines.

⚠ Common Mistakes to Avoid

  • Mistake 1: Registering services AFTER calling builder.Build() — The symptom is an InvalidOperationException at startup: 'Cannot modify ServiceCollection after application has been built.' Fix: all builder.Services.Add*() calls must happen before var app = builder.Build(). Think of Build() as sealing the DI container — nothing gets in after that.
  • Mistake 2: Using Results instead of TypedResults for handlers you want to unit test — The symptom is that your unit test can't assert the HTTP status code without casting to IStatusCodeHttpResult, and your OpenAPI spec shows the response type as 'any' in Swagger. Fix: switch to TypedResults.Ok(), TypedResults.NotFound(), etc. The return type becomes part of the method signature, enabling compile-time checks and accurate schema generation.
  • Mistake 3: Putting all endpoints directly in Program.cs as the app grows — The symptom is a 500-line Program.cs that nobody wants to touch. There's no compile error, just mounting pain. Fix: use the MapGroup() + extension method pattern from the start. Create one static class per domain entity (e.g., ProductEndpoints, OrderEndpoints), each with a MapXxxEndpoints(this WebApplication app) extension method. Program.cs stays under 30 lines regardless of how many endpoints you add.

Interview Questions on This Topic

  • QWhat's the difference between Results and TypedResults in Minimal APIs, and why does it matter for OpenAPI documentation and unit testing?
  • QHow do you apply authentication and rate limiting to a group of Minimal API endpoints without duplicating code on each one — and what's the correct middleware order in Program.cs?
  • QA colleague says Minimal APIs can't scale to a production app with 50+ endpoints because everything ends up in Program.cs. How do you respond, and what pattern would you show them?

Frequently Asked Questions

Can Minimal APIs in ASP.NET Core replace controller-based APIs completely?

For most new projects, yes — especially microservices and lightweight APIs. Minimal APIs now support auth, rate limiting, output caching, OpenAPI, and filters. The remaining gap is in complex API versioning scenarios and very large team codebases where MVC conventions enforce consistency automatically. You can also mix both styles in one app.

How do you add Swagger/OpenAPI documentation to a Minimal API?

Call builder.Services.AddEndpointsApiExplorer() and builder.Services.AddSwaggerGen() in the service registration section. Then call app.UseSwagger() and app.UseSwaggerUI() in the middleware section. Use .WithOpenApi() on your MapGroup() or individual endpoints, and switch to TypedResults to get accurate response schemas generated automatically.

Where do I put validation logic in a Minimal API if there are no model validators auto-registered like in MVC?

Use Endpoint Filters — create a generic ValidationFilter that implements IEndpointFilter, inject your FluentValidation IValidator into it, and attach it to endpoints with .AddEndpointFilter>(). Register your validators with builder.Services.AddValidatorsFromAssemblyContaining(). This keeps handlers clean and validation reusable.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousPattern Matching in C#Next →gRPC with ASP.NET Core
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged