Minimal APIs Introduction
Understanding Minimal APIs architecture and when to use them
Minimal APIs in ASP.NET with .NET 10
Master Minimal APIs in ASP.NET with free flashcards and hands-on coding practice. This lesson covers endpoint creation, routing, parameter binding, and dependency injectionβessential concepts for building lightweight, high-performance web APIs in .NET 10. Whether you're creating microservices or simple RESTful APIs, Minimal APIs offer a streamlined alternative to traditional controller-based approaches.
Welcome to Minimal APIs π»
Minimal APIs represent a paradigm shift in how we build web APIs with ASP.NET. Introduced in .NET 6 and refined through .NET 10, they eliminate the ceremonial boilerplate of controllers, allowing you to define endpoints directly in your Program.cs file with just a few lines of code.
π€ Did you know? The Minimal APIs approach was inspired by modern frameworks like Express.js and Flask, bringing a more functional programming style to ASP.NET while maintaining type safety and performance.
Why Minimal APIs? π―
Traditional controller-based APIs require multiple files, attributes, and class hierarchies. Minimal APIs offer:
- Reduced ceremony: No need for controller classes, action methods, or
[ApiController]attributes - Better performance: Fewer allocations and faster startup times
- Simpler architecture: Perfect for microservices and small APIs
- Modern syntax: Leverages C# language features like top-level statements and lambda expressions
- Lower barrier to entry: New developers can understand the entire API flow more easily
π‘ When to Use Minimal APIs
| Use Minimal APIs When | Use Controllers When |
|---|---|
| Building microservices | Large, complex applications |
| Simple CRUD operations | Need advanced filters/conventions |
| Rapid prototyping | Heavy OOP design patterns |
| Learning ASP.NET | Team prefers MVC structure |
| Performance-critical APIs | Extensive middleware pipelines |
Core Concepts: Building Your First Minimal API ποΈ
1. The WebApplication Builder Pattern
Every Minimal API starts with the WebApplication builder. This replaces the traditional Startup.cs and Program.cs separation:
var builder = WebApplication.CreateBuilder(args);
// Register services here
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Configure middleware and endpoints here
app.UseHttpsRedirection();
app.MapGet("/", () => "Hello World!");
app.Run();
Key components:
WebApplication.CreateBuilder(args): Creates and configures the builderbuilder.Services: The dependency injection container for registering servicesbuilder.Build(): Constructs theWebApplicationinstanceapp.Map*(): Methods for defining HTTP endpointsapp.Run(): Starts the web server and begins listening for requests
2. HTTP Verb Mapping Methods π£οΈ
Minimal APIs provide dedicated methods for each HTTP verb:
| Method | HTTP Verb | Common Use |
|---|---|---|
MapGet | GET | Retrieve data |
MapPost | POST | Create new resources |
MapPut | PUT | Update entire resource |
MapPatch | PATCH | Partial resource update |
MapDelete | DELETE | Remove resources |
Each method follows the pattern: app.MapVerb(pattern, handler)
3. Route Patterns and Parameters π
Route templates define the URL structure for your endpoints. Parameters are captured using curly braces:
app.MapGet("/products/{id}", (int id) =>
{
return Results.Ok(new { ProductId = id, Name = "Sample Product" });
});
Route constraints ensure parameters meet specific criteria:
// id must be an integer
app.MapGet("/products/{id:int}", (int id) => { /* ... */ });
// Multiple constraints
app.MapGet("/items/{id:int:min(1)}", (int id) => { /* ... */ });
// Optional parameters
app.MapGet("/search/{term?}", (string? term) => { /* ... */ });
| Constraint | Example | Description |
|---|---|---|
:int | {id:int} | Must be integer |
:guid | {id:guid} | Must be GUID format |
:min(n) | {age:min(18)} | Minimum value |
:length(n) | {code:length(5)} | Exact length |
:regex(expr) | {zip:regex(^\d{{5}}$)} | Pattern match |
4. Parameter Binding Sources π
ASP.NET automatically binds parameters from various sources:
Route values (captured from URL):
app.MapGet("/users/{userId}", (int userId) => { /* ... */ });
Query strings (from URL ?key=value):
app.MapGet("/search", (string query, int page = 1) => { /* ... */ });
// Matches: /search?query=laptop&page=2
Request body (JSON deserialization):
app.MapPost("/products", (Product product) => { /* ... */ });
Services (from dependency injection):
app.MapGet("/data", (IDataService dataService) =>
{
return dataService.GetAll();
});
Special types:
HttpContext: Access the full request contextHttpRequest: The incoming requestHttpResponse: The outgoing responseCancellationToken: For async cancellationClaimsPrincipal: The authenticated user
5. Return Types and Results π¦
Minimal API handlers can return various types:
Implicit conversion (automatic status codes):
app.MapGet("/simple", () => "Hello"); // 200 OK with text
app.MapGet("/object", () => new { Message = "Hi" }); // 200 OK with JSON
Results helpers (explicit control):
app.MapGet("/explicit", () => Results.Ok(new { Data = "value" }));
app.MapGet("/created", () => Results.Created("/items/123", new { Id = 123 }));
app.MapGet("/notfound", () => Results.NotFound());
app.MapGet("/badrequest", () => Results.BadRequest("Invalid data"));
IResult interface for custom responses:
app.MapGet("/custom", () =>
{
return Results.Json(
new { Status = "success" },
statusCode: 200
);
});
6. Dependency Injection Integration π
Services registered in the DI container are automatically injected:
// Register service
builder.Services.AddScoped<IProductService, ProductService>();
// Use in endpoint
app.MapGet("/products", (IProductService service) =>
{
return service.GetAll();
});
π§ Memory Device - "RSBD": Route parameters come from the String (URL), Body parameters come from Body (JSON), DI parameters come from Dependency injection.
7. Endpoint Grouping and Organization ποΈ
MapGroup creates logical groupings with shared routes and configuration:
var productsGroup = app.MapGroup("/api/products");
productsGroup.MapGet("/", () => "All products");
productsGroup.MapGet("/{id}", (int id) => $"Product {id}");
productsGroup.MapPost("/", (Product p) => Results.Created($"/api/products/{p.Id}", p));
Benefits:
- Reduced repetition in route patterns
- Apply shared middleware to entire groups
- Organize related endpoints together
- Easier to maintain and understand
8. Async Operations and Error Handling β‘
Minimal APIs fully support async patterns:
app.MapGet("/data", async (IDataService service, CancellationToken ct) =>
{
try
{
var data = await service.GetDataAsync(ct);
return Results.Ok(data);
}
catch (Exception ex)
{
return Results.Problem(ex.Message);
}
});
Best practices:
- Always accept
CancellationTokenfor long-running operations - Use
async/awaitfor I/O operations (database, HTTP calls) - Handle exceptions appropriately
- Return proper status codes for error conditions
Detailed Examples π
Example 1: Complete CRUD API for a Todo Application
Let's build a full-featured API with in-memory storage:
var builder = WebApplication.CreateBuilder(args);
// Register services
builder.Services.AddSingleton<List<Todo>>(new List<Todo>());
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
// GET all todos
app.MapGet("/todos", (List<Todo> todos) =>
{
return Results.Ok(todos);
});
// GET single todo by id
app.MapGet("/todos/{id:int}", (int id, List<Todo> todos) =>
{
var todo = todos.FirstOrDefault(t => t.Id == id);
return todo is not null ? Results.Ok(todo) : Results.NotFound();
});
// POST create new todo
app.MapPost("/todos", (Todo todo, List<Todo> todos) =>
{
todo.Id = todos.Any() ? todos.Max(t => t.Id) + 1 : 1;
todo.CreatedAt = DateTime.UtcNow;
todos.Add(todo);
return Results.Created($"/todos/{todo.Id}", todo);
});
// PUT update todo
app.MapPut("/todos/{id:int}", (int id, Todo updated, List<Todo> todos) =>
{
var index = todos.FindIndex(t => t.Id == id);
if (index == -1) return Results.NotFound();
updated.Id = id;
todos[index] = updated;
return Results.Ok(updated);
});
// DELETE todo
app.MapDelete("/todos/{id:int}", (int id, List<Todo> todos) =>
{
var removed = todos.RemoveAll(t => t.Id == id);
return removed > 0 ? Results.NoContent() : Results.NotFound();
});
app.Run();
record Todo(int Id, string Title, bool IsComplete, DateTime CreatedAt);
What's happening:
- We register a singleton
List<Todo>as our in-memory data store - Each endpoint receives the list via dependency injection
- GET operations query the list
- POST adds new items with auto-incrementing IDs
- PUT replaces entire todo items
- DELETE removes items and returns 204 No Content on success
Example 2: Dependency Injection with Services
Building a more realistic API with a service layer:
public interface IWeatherService
{
Task<IEnumerable<WeatherForecast>> GetForecastAsync(string city);
}
public class WeatherService : IWeatherService
{
private readonly HttpClient _httpClient;
public WeatherService(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<IEnumerable<WeatherForecast>> GetForecastAsync(string city)
{
// Simulate API call
await Task.Delay(100);
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateTime.Now.AddDays(index),
TemperatureC = Random.Shared.Next(-20, 55),
City = city
});
}
}
var builder = WebApplication.CreateBuilder(args);
// Register service with HttpClient
builder.Services.AddHttpClient<IWeatherService, WeatherService>();
var app = builder.Build();
app.MapGet("/weather/{city}", async (string city, IWeatherService service) =>
{
var forecast = await service.GetForecastAsync(city);
return Results.Ok(forecast);
});
app.Run();
record WeatherForecast
{
public DateTime Date { get; init; }
public int TemperatureC { get; init; }
public string City { get; init; } = string.Empty;
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
Key concepts:
- Service interface defines the contract
- Implementation injected with
HttpClientfor external calls - Endpoint receives service automatically
- Async operations for I/O-bound work
- Record types for immutable data models
Example 3: Advanced Route Grouping with Middleware
Organizing a complex API with authentication:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication();
builder.Services.AddAuthorization();
builder.Services.AddScoped<IProductRepository, ProductRepository>();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
// Public API group
var publicApi = app.MapGroup("/api/public");
publicApi.MapGet("/products", async (IProductRepository repo) =>
{
var products = await repo.GetAllAsync();
return Results.Ok(products);
});
// Admin API group with authorization
var adminApi = app.MapGroup("/api/admin")
.RequireAuthorization("AdminPolicy");
adminApi.MapPost("/products", async (Product product, IProductRepository repo) =>
{
await repo.AddAsync(product);
return Results.Created($"/api/public/products/{product.Id}", product);
});
adminApi.MapDelete("/products/{id:int}", async (int id, IProductRepository repo) =>
{
var deleted = await repo.DeleteAsync(id);
return deleted ? Results.NoContent() : Results.NotFound();
});
// Versioned API group
var v2Api = app.MapGroup("/api/v2/products")
.WithOpenApi();
v2Api.MapGet("/", async (IProductRepository repo, int page = 1, int size = 10) =>
{
var products = await repo.GetPagedAsync(page, size);
return Results.Ok(new { Data = products, Page = page, Size = size });
});
app.Run();
Advantages demonstrated:
- Logical separation of public vs. admin endpoints
- Shared middleware (authorization) applied to groups
- API versioning through route prefixes
- Consistent error handling and responses
- Clear security boundaries
Example 4: Request Validation and Filtering
Implementing validation with filters:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IValidator<CreateUserRequest>, UserValidator>();
var app = builder.Build();
app.MapPost("/users", async (
CreateUserRequest request,
IValidator<CreateUserRequest> validator,
IUserRepository repository) =>
{
// Validate request
var validationResult = await validator.ValidateAsync(request);
if (!validationResult.IsValid)
{
return Results.ValidationProblem(validationResult.ToDictionary());
}
// Create user
var user = new User
{
Email = request.Email,
Name = request.Name,
CreatedAt = DateTime.UtcNow
};
await repository.AddAsync(user);
return Results.Created($"/users/{user.Id}", user);
})
.WithName("CreateUser")
.Produces<User>(StatusCodes.Status201Created)
.ProducesValidationProblem()
.WithOpenApi();
app.Run();
record CreateUserRequest(string Email, string Name);
public interface IValidator<T>
{
Task<ValidationResult> ValidateAsync(T instance);
}
public class UserValidator : IValidator<CreateUserRequest>
{
public Task<ValidationResult> ValidateAsync(CreateUserRequest request)
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(request.Email))
errors.Add("Email is required");
if (!request.Email.Contains("@"))
errors.Add("Invalid email format");
if (string.IsNullOrWhiteSpace(request.Name))
errors.Add("Name is required");
return Task.FromResult(new ValidationResult
{
IsValid = !errors.Any(),
Errors = errors
});
}
}
Benefits:
- Separation of concerns (validation logic separate from endpoint)
- Reusable validators
- Consistent error responses
- OpenAPI documentation generation
- Type-safe request/response models
Common Mistakes β οΈ
1. Forgetting to Call app.Run()
β Wrong:
var app = builder.Build();
app.MapGet("/hello", () => "Hello World");
// Missing app.Run() - server never starts!
β Correct:
var app = builder.Build();
app.MapGet("/hello", () => "Hello World");
app.Run(); // Essential!
2. Route Parameter Type Mismatch
β Wrong:
// Route expects int but parameter is string
app.MapGet("/items/{id:int}", (string id) => { /* ... */ });
β Correct:
app.MapGet("/items/{id:int}", (int id) => { /* ... */ });
3. Not Using Async for I/O Operations
β Wrong:
app.MapGet("/data", (IDataService service) =>
{
var data = service.GetData(); // Blocking call!
return data;
});
β Correct:
app.MapGet("/data", async (IDataService service) =>
{
var data = await service.GetDataAsync();
return data;
});
4. Incorrect Service Lifetime
β Wrong:
// Registering DbContext as singleton (should be scoped)
builder.Services.AddSingleton<MyDbContext>();
β Correct:
builder.Services.AddDbContext<MyDbContext>(options =>
options.UseSqlServer(connectionString));
// DbContext is automatically registered as scoped
5. Not Handling Null Returns
β Wrong:
app.MapGet("/users/{id}", (int id, List<User> users) =>
{
return users.FirstOrDefault(u => u.Id == id);
// Returns null with 200 OK if not found!
});
β Correct:
app.MapGet("/users/{id}", (int id, List<User> users) =>
{
var user = users.FirstOrDefault(u => u.Id == id);
return user is not null ? Results.Ok(user) : Results.NotFound();
});
6. Middleware Order Issues
β Wrong:
app.MapGet("/secure", () => "data").RequireAuthorization();
app.UseAuthentication(); // Too late!
app.UseAuthorization();
β Correct:
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/secure", () => "data").RequireAuthorization();
7. Confusing Route Parameters with Query Parameters
β Wrong:
// Trying to capture query string as route parameter
app.MapGet("/search/{query}", (string query) => { /* ... */ });
// This requires: /search/laptop (route)
// But user expects: /search?query=laptop (query string)
β Correct:
app.MapGet("/search", (string query) => { /* ... */ });
// Matches: /search?query=laptop
Key Takeaways π―
Minimal APIs streamline API development by eliminating controllers and reducing boilerplate code
Use
WebApplication.CreateBuilder()to set up dependency injection and configurationMap endpoints with
MapGet,MapPost,MapPut,MapDeletefor corresponding HTTP verbsRoute parameters use
{param}syntax with optional constraints like:intor:guidParameter binding happens automatically from routes, query strings, request body, and DI services
Return
IResulttypes usingResults.*helpers for explicit control over status codesGroup related endpoints with
MapGroup()to share routes, middleware, and configurationAlways use async/await for I/O-bound operations like database queries or HTTP calls
Register services before building the app with
builder.Services.Add*()methodsMiddleware order matters: Authentication β Authorization β Endpoint mapping
π‘ Pro Tip: Start with Minimal APIs for new projects and microservices. Only switch to controllers when you need advanced features like model binding complexity or custom conventions.
π Further Study
- Microsoft Official Documentation - Minimal APIs: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/overview
- ASP.NET Core Route Constraints: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/routing#route-constraints
- Dependency Injection in ASP.NET Core: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection
π Quick Reference Card: Minimal APIs Essentials
| Concept | Syntax | Example |
|---|---|---|
| Basic GET | app.MapGet(route, handler) | app.MapGet("/", () => "Hi") |
| Route parameter | {name} | "/users/{id}" |
| Type constraint | {name:type} | "{id:int}" |
| Query param | Method parameter | (string query) => {} |
| Body binding | Complex type param | (Product p) => {} |
| DI injection | Service type param | (IService s) => {} |
| OK response | Results.Ok(data) | Results.Ok(user) |
| Created | Results.Created(uri, data) | Results.Created("/items/1", item) |
| Not Found | Results.NotFound() | Results.NotFound() |
| Group routes | app.MapGroup(prefix) | app.MapGroup("/api") |
| Async handler | async () => await | async (IService s) => await s.GetAsync() |
| Authorization | .RequireAuthorization() | MapGet(...).RequireAuthorization() |