The Controller-Free Revolution (And Why You Might Actually Like It)
Remember when building a REST API in .NET meant creating a controller file, adding attributes to every method, and somehow ending up with more ceremony than a royal wedding? Those days are fading faster than your motivation on a Monday morning. Welcome to the world of Minimal APIs—where you can craft production-ready REST services with less code, fewer files, and genuinely less headache. If you’ve been stuck in the traditional controller-based pattern for years, the mere thought of change might trigger some cognitive dissonance. But hear me out: Minimal APIs aren’t just a gimmick. They’re a thoughtfully designed approach to building HTTP APIs with minimal dependencies, making them absolutely ideal for microservices and applications that value lean, focused architecture. The beauty of this approach? You can get a functioning API up and running with just a handful of lines of code. No massive project scaffolding. No unnecessary layers. Just you, your business logic, and the HTTP endpoints you actually need.
What Makes Minimal APIs Different?
Think of Minimal APIs as the minimalist design movement applied to .NET development. While traditional controllers require you to create separate class files, decorate everything with attributes, and maintain a certain structural formality, Minimal APIs let you define your entire endpoint logic inline. Here’s what makes them genuinely compelling:
- Reduced cognitive overhead: Your endpoint logic lives where you define it, not scattered across controller classes and action methods
- Lightning-fast prototyping: You’re not fighting boilerplate; you’re building features
- Perfect for microservices: When you need something lean and focused, Minimal APIs deliver exactly that
- Modern .NET features: .NET 10 brings built-in validation, route grouping, and OpenAPI integration straight out of the box The real kicker? You’re not sacrificing robustness for simplicity. Production-grade error handling, proper HTTP status codes, and comprehensive API documentation are all within arm’s reach.
Setting Up Your First Minimal API: The 30-Second Version
Let’s cut through the noise and get something running. Using the dotnet CLI is genuinely the fastest path forward—the default template already gives you a minimal API structure.
dotnet new webapi -n RobustApiService
cd RobustApiService
That’s it. You’ve got your project. If you’re using Visual Studio, the process is similar; you’ll just want to strip out any controller boilerplate that gets generated by default. Now let’s create something that actually does something. Here’s the foundation of a production-grade API setup:
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// Add database context
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseInMemoryDatabase("AppDatabase"));
// Enable API exploration for documentation
builder.Services.AddEndpointsApiExplorer();
// Add OpenAPI (Swagger) documentation
builder.Services.AddOpenApiDocument(config =>
{
config.DocumentName = "RobustAPI";
config.Title = "Robust Service API v1";
config.Version = "v1";
});
var app = builder.Build();
// Serve OpenAPI documentation
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
// Your endpoints go here
var api = app.MapGroup("/api/items")
.WithName("Items")
.WithOpenApi();
app.Run();
Notice something? No controllers. No action methods. Just pure, focused configuration. We’re using MapGroup to organize our endpoints—more on that game-changer in a moment.
Mastering Route Groups: Organization Without The Mess
If you’ve ever looked at a massive controller class and thought “this thing needs serious organization,” route groups are your answer. Route groups let you logically bundle endpoints together, apply shared conventions, and keep your code organized without creating unnecessary file hierarchies. It’s organization that actually makes sense:
var todoItems = app.MapGroup("/api/todos")
.WithName("Todo Items")
.WithOpenApi()
.RequireAuthorization(); // Apply to entire group
// GET all todos
todoItems.MapGet("/", GetAllTodos)
.WithName("GetAllTodos")
.WithOpenApi();
// GET todo by ID
todoItems.MapGet("/{id}", GetTodoById)
.WithName("GetTodoById")
.WithOpenApi();
// POST new todo
todoItems.MapPost("/", CreateTodo)
.WithName("CreateTodo")
.WithOpenApi()
.Accepts<CreateTodoRequest>("application/json")
.Produces<TodoItemResponse>(StatusCodes.Status201Created);
// PUT update todo
todoItems.MapPut("/{id}", UpdateTodo)
.WithName("UpdateTodo")
.WithOpenApi();
// DELETE todo
todoItems.MapDelete("/{id}", DeleteTodo)
.WithName("DeleteTodo")
.WithOpenApi()
.Produces(StatusCodes.Status204NoContent);
See what’s happening here? All your todo-related endpoints are grouped together. Authorization applies to the entire group. Documentation metadata is consistent. This is organization with purpose, not ceremony.
Building CRUD Operations: The Real Deal
Let’s create a full CRUD implementation for something concrete. We’ll build endpoints for a product catalog:
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public decimal Price { get; set; }
public int Stock { get; set; }
public DateTime CreatedAt { get; set; }
}
public class ProductDbContext : DbContext
{
public DbSet<Product> Products => Set<Product>();
public ProductDbContext(DbContextOptions<ProductDbContext> options)
: base(options) { }
}
public class CreateProductRequest
{
public required string Name { get; set; }
public string Description { get; set; } = string.Empty;
public decimal Price { get; set; }
public int Stock { get; set; }
}
public class ProductResponse
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public decimal Price { get; set; }
public int Stock { get; set; }
public DateTime CreatedAt { get; set; }
}
// Handlers
async Task<IResult> GetAllProducts(ProductDbContext db)
{
var products = await db.Products
.AsNoTracking()
.Select(p => new ProductResponse
{
Id = p.Id,
Name = p.Name,
Description = p.Description,
Price = p.Price,
Stock = p.Stock,
CreatedAt = p.CreatedAt
})
.ToListAsync();
return Results.Ok(products);
}
async Task<IResult> GetProductById(int id, ProductDbContext db)
{
var product = await db.Products.FindAsync(id);
if (product is null)
return Results.NotFound(new { message = $"Product with ID {id} not found" });
return Results.Ok(new ProductResponse
{
Id = product.Id,
Name = product.Name,
Description = product.Description,
Price = product.Price,
Stock = product.Stock,
CreatedAt = product.CreatedAt
});
}
async Task<IResult> CreateProduct(CreateProductRequest request, ProductDbContext db)
{
// Built-in validation in .NET 10
if (string.IsNullOrWhiteSpace(request.Name))
return Results.BadRequest(new { error = "Product name is required" });
if (request.Price <= 0)
return Results.BadRequest(new { error = "Price must be greater than zero" });
var product = new Product
{
Name = request.Name,
Description = request.Description,
Price = request.Price,
Stock = request.Stock,
CreatedAt = DateTime.UtcNow
};
db.Products.Add(product);
await db.SaveChangesAsync();
return Results.Created($"/api/products/{product.Id}", new ProductResponse
{
Id = product.Id,
Name = product.Name,
Description = product.Description,
Price = product.Price,
Stock = product.Stock,
CreatedAt = product.CreatedAt
});
}
async Task<IResult> UpdateProduct(int id, CreateProductRequest request, ProductDbContext db)
{
var product = await db.Products.FindAsync(id);
if (product is null)
return Results.NotFound(new { message = $"Product with ID {id} not found" });
if (string.IsNullOrWhiteSpace(request.Name))
return Results.BadRequest(new { error = "Product name is required" });
product.Name = request.Name;
product.Description = request.Description;
product.Price = request.Price;
product.Stock = request.Stock;
await db.SaveChangesAsync();
return Results.NoContent();
}
async Task<IResult> DeleteProduct(int id, ProductDbContext db)
{
var product = await db.Products.FindAsync(id);
if (product is null)
return Results.NotFound(new { message = $"Product with ID {id} not found" });
db.Products.Remove(product);
await db.SaveChangesAsync();
return Results.NoContent();
}
// Wire up the endpoints
var products = app.MapGroup("/api/products")
.WithName("Products")
.WithOpenApi();
products.MapGet("/", GetAllProducts)
.WithName("GetAllProducts")
.Produces<List<ProductResponse>>(StatusCodes.Status200OK);
products.MapGet("/{id}", GetProductById)
.WithName("GetProductById")
.Produces<ProductResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
products.MapPost("/", CreateProduct)
.WithName("CreateProduct")
.Accepts<CreateProductRequest>("application/json")
.Produces<ProductResponse>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest);
products.MapPut("/{id}", UpdateProduct)
.WithName("UpdateProduct")
.Accepts<CreateProductRequest>("application/json")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound);
products.MapDelete("/{id}", DeleteProduct)
.WithName("DeleteProduct")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
This isn’t just functional—it’s enterprise-ready. You’ve got:
- Proper HTTP status codes for every scenario
- Input validation that actually protects your system
- Async operations throughout
- Auto-generated OpenAPI documentation
- Clear separation between request/response DTOs
- Database persistence with Entity Framework Core
The Endpoint Registration Pattern: Scale With Elegance
Here’s where things get interesting for larger applications. As your API grows, having all endpoints in a single file becomes… let’s say “unmanageable.” Enter the IEndpoint interface pattern, powered by reflection. Define a base interface:
public interface IEndpoint
{
void MapEndpoint(IEndpointRouteBuilder app);
}
Then create modular endpoint classes:
public class GetProductsEndpoint : IEndpoint
{
public void MapEndpoint(IEndpointRouteBuilder app)
{
app.MapGet("/api/products", GetAllProducts)
.WithName("GetAllProducts")
.WithOpenApi()
.Produces<List<ProductResponse>>(StatusCodes.Status200OK);
}
private static async Task<IResult> GetAllProducts(ProductDbContext db)
{
var products = await db.Products
.AsNoTracking()
.Select(p => new ProductResponse { /* ... */ })
.ToListAsync();
return Results.Ok(products);
}
}
public class CreateProductEndpoint : IEndpoint
{
public void MapEndpoint(IEndpointRouteBuilder app)
{
app.MapPost("/api/products", CreateProduct)
.WithName("CreateProduct")
.WithOpenApi()
.Accepts<CreateProductRequest>("application/json")
.Produces<ProductResponse>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest);
}
private static async Task<IResult> CreateProduct(
CreateProductRequest request,
ProductDbContext db)
{
// Implementation...
}
}
Register them automatically in your Program.cs:
// Register all endpoint implementations
var endpoints = typeof(Program).Assembly
.GetTypes()
.Where(t => typeof(IEndpoint).IsAssignableFrom(t) && !t.IsInterface)
.Select(Activator.CreateInstance)
.Cast<IEndpoint>();
foreach (var endpoint in endpoints)
{
endpoint.MapEndpoint(app);
}
Now your application scales beautifully. Each endpoint lives in its own file, making the codebase navigable even as your API grows to dozens of endpoints.
Automatic OpenAPI Documentation: The Gift That Keeps Giving
One of the genuine delights of Minimal APIs is how effortlessly you get professional API documentation. Remember when API docs were an afterthought—something that got out of sync with your code and became a source of frustration? With Minimal APIs, documentation is generated automatically from your endpoint definitions:
app.MapPost("/api/products", CreateProduct)
.WithName("CreateProduct")
.WithDescription("Creates a new product in the catalog")
.WithOpenApi()
.Accepts<CreateProductRequest>("application/json")
.Produces<ProductResponse>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.WithSummary("Create new product")
.WithTags("Products");
Navigate to /openapi/v1.json and you’ve got a fully compliant OpenAPI specification. Use Swagger UI (included in development) at /swagger/index.html and you’ve got an interactive playground where you and your API consumers can explore every endpoint.
This isn’t documentation as an afterthought—it’s documentation as a first-class citizen of your API design.
Validation: Built-In Robustness
.NET 10 brings integrated validation features that make defensive programming less tedious:
public class ProductValidator
{
public static ValidationResult ValidateCreateRequest(CreateProductRequest request)
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(request.Name))
errors.Add("Product name is required and cannot be empty");
if (request.Name.Length > 200)
errors.Add("Product name cannot exceed 200 characters");
if (request.Price < 0.01m)
errors.Add("Price must be at least $0.01");
if (request.Price > 999999.99m)
errors.Add("Price cannot exceed $999,999.99");
if (request.Stock < 0)
errors.Add("Stock quantity cannot be negative");
return errors.Count == 0
? ValidationResult.Success
: new ValidationResult(errors);
}
}
// In your endpoint
async Task<IResult> CreateProduct(CreateProductRequest request, ProductDbContext db)
{
var validationResult = ProductValidator.ValidateCreateRequest(request);
if (!validationResult.IsValid)
return Results.BadRequest(new { errors = validationResult.Errors });
// Proceed with creation...
}
Validation happens before your business logic even runs, protecting your data layer and keeping invalid data out of your database entirely.
The Mental Model: Understanding the Flow
This is the journey of every request through your Minimal API. Minimal APIs make this flow explicit and transparent—you’re not buried in controller logic or action filters trying to understand what’s actually happening.
Error Handling: The Boring Stuff That Matters
Here’s a production-grade exception handling middleware that plays nicely with Minimal APIs:
app.UseExceptionHandler(exceptionHandlerApp =>
{
exceptionHandlerApp.Run(async context =>
{
var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
context.Response.ContentType = "application/json";
context.Response.StatusCode = exception switch
{
ArgumentNullException => StatusCodes.Status400BadRequest,
InvalidOperationException => StatusCodes.Status409Conflict,
KeyNotFoundException => StatusCodes.Status404NotFound,
_ => StatusCodes.Status500InternalServerError
};
var response = new
{
status = context.Response.StatusCode,
message = exception?.Message ?? "An unexpected error occurred",
timestamp = DateTime.UtcNow
};
await context.Response.WriteAsJsonAsync(response);
});
});
Every exception gets caught, logged, and returned to the client in a consistent format. No surprises. No leaked stack traces in production. Just professional error handling.
Testing Your Minimal API: Keep It Simple
Testing Minimal APIs is straightforward because the logic isn’t hidden in controller layers:
[TestClass]
public class ProductEndpointsTests
{
private WebApplication _app = null!;
private HttpClient _client = null!;
[TestInitialize]
public void Setup()
{
var builder = WebApplication.CreateBuilder();
builder.Services.AddDbContext<ProductDbContext>(opt =>
opt.UseInMemoryDatabase(Guid.NewGuid().ToString()));
var app = builder.Build();
// Wire up endpoints...
_app = app;
_client = new HttpClient { BaseAddress = new Uri("http://localhost") };
}
[TestMethod]
public async Task GetProducts_ReturnsOkWithProducts()
{
var response = await _client.GetAsync("/api/products");
Assert.AreEqual(System.Net.HttpStatusCode.OK, response.StatusCode);
var content = await response.Content.ReadAsAsync<List<ProductResponse>>();
Assert.IsNotNull(content);
}
[TestMethod]
public async Task CreateProduct_WithInvalidData_ReturnsBadRequest()
{
var request = new CreateProductRequest { Name = "", Price = -10 };
var response = await _client.PostAsJsonAsync("/api/products", request);
Assert.AreEqual(System.Net.HttpStatusCode.BadRequest, response.StatusCode);
}
}
Your tests can be just as lean as your code. No mocking controller behavior—just real HTTP semantics.
Advanced Patterns: Authentication & Authorization
Minimal APIs integrate authentication and authorization as smoothly as everything else:
app.MapGet("/api/profile", GetUserProfile)
.WithName("GetUserProfile")
.WithOpenApi()
.RequireAuthorization()
.RequireAuthorization("AdminOnly");
async Task<IResult> GetUserProfile(ClaimsPrincipal user, UserDbContext db)
{
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (userId is null)
return Results.Unauthorized();
var userProfile = await db.Users.FirstOrDefaultAsync(u => u.Id == userId);
return userProfile is null
? Results.NotFound()
: Results.Ok(userProfile);
}
// Add authentication
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer();
builder.Services
.AddAuthorizationBuilder()
.AddPolicy("AdminOnly", policy =>
policy.RequireClaim(ClaimTypes.Role, "Admin"));
// Use authentication/authorization middleware
app.UseAuthentication();
app.UseAuthorization();
Your API’s security concerns are now right there in your endpoint definitions. No hidden filter logic. No confusion about what’s protected and what isn’t.
Real-World Considerations: Beyond The Happy Path
Building robust services means thinking beyond the obvious: Rate Limiting: Protect your API from being hammered:
builder.Services.AddRateLimiter(rateLimiterOptions =>
{
rateLimiterOptions.AddFixedWindowLimiter(policyName: "fixed", options =>
{
options.PermitLimit = 100;
options.Window = TimeSpan.FromMinutes(1);
});
});
app.UseRateLimiter();
app.MapPost("/api/products", CreateProduct)
.RequireRateLimiting("fixed");
Health Checks: Let your infrastructure know when something’s wrong:
builder.Services.AddHealthChecks()
.AddDbContextCheck<ProductDbContext>();
app.MapHealthChecks("/health");
CORS: If you’re building an API for external consumption:
builder.Services.AddCors(options =>
{
options.AddPolicy("PublicAPI", policy =>
{
policy
.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
app.UseCors("PublicAPI");
These aren’t optional—they’re part of the foundation of a service that can actually survive contact with the real world.
Performance: Less Overhead, More Throughput
Here’s the thing about Minimal APIs that doesn’t get mentioned enough: they’re fast. Without the reflection overhead of traditional controller-based routing, Minimal APIs process requests more efficiently. For microservices where every millisecond matters, this compounds into genuine business value. Add caching where it makes sense:
app.MapGet("/api/products/{id}", GetProductById)
.CacheOutput(c => c.Expire(TimeSpan.FromMinutes(5)))
.WithName("GetProductById")
.WithOpenApi();
Now repeated requests for the same product hit your response cache instead of your database. Your database thanks you. Your users thank you. Everyone wins.
Bringing It All Together: A Production Blueprint
Here’s what a production-ready Minimal API looks like when you’ve thought through all the pieces:
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
var builder = WebApplication.CreateBuilder(args);
// Services
builder.Services.AddDbContext<ProductDbContext>(opt =>
opt.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApiDocument(config =>
{
config.Title = "Product API";
config.Version = "v1.0.0";
});
builder.Services.AddHealthChecks()
.AddDbContextCheck<ProductDbContext>();
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("api", opt =>
{
opt.PermitLimit = 1000;
opt.Window = TimeSpan.FromMinutes(1);
});
});
builder.Services.AddCors(options =>
{
options.AddPolicy("ProductAPI", policy =>
{
policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader();
});
});
var app = builder.Build();
// Middleware
app.UseExceptionHandler();
app.UseRateLimiter();
app.UseCors("ProductAPI");
// Documentation
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
// Health
app.MapHealthChecks("/health");
// API
var products = app.MapGroup("/api/products")
.WithName("Products")
.WithOpenApi()
.RequireRateLimiting("api");
products.MapGet("/", GetAllProducts)
.WithName("GetAllProducts")
.CacheOutput(c => c.Expire(TimeSpan.FromMinutes(5)))
.Produces<List<ProductResponse>>();
products.MapGet("/{id}", GetProductById)
.WithName("GetProductById")
.Produces<ProductResponse>()
.Produces(StatusCodes.Status404NotFound);
products.MapPost("/", CreateProduct)
.WithName("CreateProduct")
.Accepts<CreateProductRequest>("application/json")
.Produces<ProductResponse>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest);
products.MapPut("/{id}", UpdateProduct)
.WithName("UpdateProduct")
.Accepts<CreateProductRequest>("application/json")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
products.MapDelete("/{id}", DeleteProduct)
.WithName("DeleteProduct")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
app.Run();
// Handlers
async Task<IResult> GetAllProducts(ProductDbContext db) =>
Results.Ok(await db.Products.AsNoTracking().ToListAsync());
async Task<IResult> GetProductById(int id, ProductDbContext db) =>
await db.Products.FindAsync(id) is Product product
? Results.Ok(product)
: Results.NotFound();
async Task<IResult> CreateProduct(CreateProductRequest req, ProductDbContext db)
{
if (string.IsNullOrWhiteSpace(req.Name) || req.Price <= 0)
return Results.BadRequest("Invalid product data");
var product = new Product
{
Name = req.Name,
Description = req.Description,
Price = req.Price,
Stock = req.Stock,
CreatedAt = DateTime.UtcNow
};
db.Products.Add(product);
await db.SaveChangesAsync();
return Results.Created($"/api/products/{product.Id}", product);
}
async Task<IResult> UpdateProduct(int id, CreateProductRequest req, ProductDbContext db)
{
var product = await db.Products.FindAsync(id);
if (product is null) return Results.NotFound();
product.Name = req.Name;
product.Description = req.Description;
product.Price = req.Price;
product.Stock = req.Stock;
await db.SaveChangesAsync();
return Results.NoContent();
}
async Task<IResult> DeleteProduct(int id, ProductDbContext db)
{
var product = await db.Products.FindAsync(id);
if (product is null) return Results.NotFound();
db.Products.Remove(product);
await db.SaveChangesAsync();
return Results.NoContent();
}
This is lean, focused, and genuinely production-ready. Every piece serves a purpose. Nothing is ceremony.
The Conclusion That Isn’t Really A Conclusion
Minimal APIs represent a fundamental shift in how we think about building HTTP services in .NET. They prove that you don’t need massive frameworks and elaborate patterns to build robust, scalable APIs. Sometimes less really is more. The beauty is that this isn’t a limitation—it’s a feature. By removing the layers of abstraction and boilerplate, you’re left with code that’s transparent, testable, and performant. Your team can understand it. Your infrastructure can scale it. Your users can consume it. Whether you’re building microservices, rebuilding legacy APIs, or starting something brand new, Minimal APIs deserve a serious look. The move away from controllers isn’t a trend—it’s evolution. Start small. Build something. See how it feels. I think you’ll be pleasantly surprised.
