You are viewing a preview of this lesson. Sign in to start learning
Back to ASP.NET with .NET 10

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 WhenUse Controllers When
Building microservicesLarge, complex applications
Simple CRUD operationsNeed advanced filters/conventions
Rapid prototypingHeavy OOP design patterns
Learning ASP.NETTeam prefers MVC structure
Performance-critical APIsExtensive 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 builder
  • builder.Services: The dependency injection container for registering services
  • builder.Build(): Constructs the WebApplication instance
  • app.Map*(): Methods for defining HTTP endpoints
  • app.Run(): Starts the web server and begins listening for requests

2. HTTP Verb Mapping Methods πŸ›£οΈ

Minimal APIs provide dedicated methods for each HTTP verb:

MethodHTTP VerbCommon Use
MapGetGETRetrieve data
MapPostPOSTCreate new resources
MapPutPUTUpdate entire resource
MapPatchPATCHPartial resource update
MapDeleteDELETERemove 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) => { /* ... */ });
ConstraintExampleDescription
: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 context
  • HttpRequest: The incoming request
  • HttpResponse: The outgoing response
  • CancellationToken: For async cancellation
  • ClaimsPrincipal: 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 CancellationToken for long-running operations
  • Use async/await for 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:

  1. We register a singleton List<Todo> as our in-memory data store
  2. Each endpoint receives the list via dependency injection
  3. GET operations query the list
  4. POST adds new items with auto-incrementing IDs
  5. PUT replaces entire todo items
  6. 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 HttpClient for 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 🎯

  1. Minimal APIs streamline API development by eliminating controllers and reducing boilerplate code

  2. Use WebApplication.CreateBuilder() to set up dependency injection and configuration

  3. Map endpoints with MapGet, MapPost, MapPut, MapDelete for corresponding HTTP verbs

  4. Route parameters use {param} syntax with optional constraints like :int or :guid

  5. Parameter binding happens automatically from routes, query strings, request body, and DI services

  6. Return IResult types using Results.* helpers for explicit control over status codes

  7. Group related endpoints with MapGroup() to share routes, middleware, and configuration

  8. Always use async/await for I/O-bound operations like database queries or HTTP calls

  9. Register services before building the app with builder.Services.Add*() methods

  10. Middleware 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

  1. Microsoft Official Documentation - Minimal APIs: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/overview
  2. ASP.NET Core Route Constraints: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/routing#route-constraints
  3. Dependency Injection in ASP.NET Core: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection

πŸ“‹ Quick Reference Card: Minimal APIs Essentials

ConceptSyntaxExample
Basic GETapp.MapGet(route, handler)app.MapGet("/", () => "Hi")
Route parameter{name}"/users/{id}"
Type constraint{name:type}"{id:int}"
Query paramMethod parameter(string query) => {}
Body bindingComplex type param(Product p) => {}
DI injectionService type param(IService s) => {}
OK responseResults.Ok(data)Results.Ok(user)
CreatedResults.Created(uri, data)Results.Created("/items/1", item)
Not FoundResults.NotFound()Results.NotFound()
Group routesapp.MapGroup(prefix)app.MapGroup("/api")
Async handlerasync () => awaitasync (IService s) => await s.GetAsync()
Authorization.RequireAuthorization()MapGet(...).RequireAuthorization()

Practice Questions

Test your understanding with these questions:

Q1: Complete the code to create a basic GET endpoint: ```csharp var app = builder.Build(); app.{{1}}("/hello", () => "Hello World"); app.Run(); ```
A: MapGet
Q2: What is the return type when using Results.Ok() in a Minimal API endpoint? A. ActionResult B. IActionResult C. IResult D. HttpResponse E. Task<IResult>
A: C
Q3: Fill in the blanks for dependency injection in Minimal APIs: ```csharp builder.Services.{{1}}<IUserService, UserService>(); app.MapGet("/users", ({{2}} service) => service.GetAll()); ```
A: ["AddScoped","IUserService"]
Q4: What does this Minimal API code return when id is 999 and the user doesn't exist? ```csharp app.MapGet("/users/{id:int}", (int id, List<User> users) => { var user = users.FirstOrDefault(u => u.Id == id); return user is not null ? Results.Ok(user) : Results.NotFound(); }); ``` A. 200 OK with null B. 404 Not Found C. 400 Bad Request D. 500 Internal Server Error E. 204 No Content
A: B
Q5: Complete the route constraint for an integer parameter: ```csharp app.MapGet("/products/{id:{{1}}}", (int id) => { }); ```
A: int