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

ASP.NET Core & Minimal APIs Foundations

Master the fundamentals of ASP.NET Core architecture, Minimal APIs concepts, and environment setup for building modern web APIs with .NET 10

Last generated

ASP.NET Core and Minimal APIs Foundations

Master ASP.NET Core and Minimal APIs with free flashcards and spaced repetition to solidify your understanding. This lesson covers the fundamentals of ASP.NET Core architecture, dependency injection, middleware pipeline configuration, and building lightweight HTTP APIs using the Minimal API patternβ€”essential skills for modern .NET 10 web development.

Welcome πŸ’»

Welcome to the foundations of ASP.NET Core and Minimal APIs! Whether you're building enterprise web applications or lightweight microservices, understanding these core concepts will empower you to create high-performance, scalable applications with .NET 10.

ASP.NET Core represents a complete redesign of the traditional ASP.NET framework. It's cross-platform, modular, high-performance, and built from the ground up with modern development practices in mind. Minimal APIs, introduced in .NET 6 and enhanced in .NET 10, provide a streamlined approach to building HTTP APIs with minimal ceremony and maximum performance.

🎯 What You'll Learn:

  • ASP.NET Core architecture and request pipeline
  • Dependency Injection (DI) fundamentals
  • Middleware components and ordering
  • Minimal API syntax and routing
  • Configuration and environment management
  • Testing strategies for Minimal APIs

Core Concepts

πŸ—οΈ ASP.NET Core Architecture

ASP.NET Core applications are built around a request pipeline that processes incoming HTTP requests through a series of middleware components. Think of it like an assembly line in a factoryβ€”each station (middleware) performs a specific task before passing the request to the next station.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚         ASP.NET CORE REQUEST PIPELINE       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

  HTTP Request
       β”‚
       β–Ό
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚  Exception  β”‚ ← Catches errors from downstream
  β”‚  Handler    β”‚
  β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
         β”‚
         β–Ό
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚   Static    β”‚ ← Serves CSS, JS, images
  β”‚   Files     β”‚
  β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
         β”‚
         β–Ό
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚   Routing   β”‚ ← Matches URL to endpoint
  β”‚             β”‚
  β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
         β”‚
         β–Ό
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚   Auth      β”‚ ← Validates identity
  β”‚             β”‚
  β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
         β”‚
         β–Ό
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚  Endpoint   β”‚ ← Your application logic
  β”‚  Execution  β”‚
  β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
         β”‚
         β–Ό
  HTTP Response

Key architectural principles:

  1. Modularity: Only include what you need via NuGet packages
  2. Dependency Injection: Built-in DI container for loose coupling
  3. Configuration: Flexible configuration from multiple sources
  4. Hosting: Can run in IIS, Kestrel, Docker, or as a standalone executable

πŸ”Œ Dependency Injection (DI)

Dependency Injection is a design pattern where objects receive their dependencies from an external source rather than creating them internally. ASP.NET Core has DI baked into its core.

πŸ’‘ Real-world analogy: Imagine you're a chef (your service). Instead of growing your own vegetables, raising livestock, and milling flour (creating dependencies), ingredients are delivered to your kitchen (injected). You focus on cooking, not farming.

Service Lifetimes:

Lifetime Description Use Case
Transient Created each time requested Lightweight, stateless services
Scoped Created once per request Database contexts, request-specific data
Singleton Created once for application lifetime Configuration, caching, logging

🧠 Memory Device - "TSS": Transient (Temporary), Scoped (Single request), Singleton (Stays forever)

πŸ”€ Middleware Pipeline

Middleware components form a pipeline that handles requests and responses. Each middleware can:

  • Process the incoming request
  • Call the next middleware in the pipeline
  • Process the outgoing response

Middleware order matters! Early middleware can short-circuit the pipeline by not calling next().

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚    MIDDLEWARE EXECUTION FLOW           β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Request  ───┐
            β”œβ”€β”€β†’ Middleware 1 ──┐
            β”‚                    β”‚
            β”‚    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
            β”‚    β”‚
            β”œβ”€β”€β”€β”€β”Όβ”€β”€β†’ Middleware 2 ──┐
            β”‚    β”‚                    β”‚
            β”‚    β”‚    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
            β”‚    β”‚    β”‚
            └────┼────┼──→ Endpoint
                 β”‚    β”‚
                 β”‚    └──← Middleware 2 ←─┐
                 β”‚                         β”‚
                 └──────← Middleware 1 ←────
                                           β”‚
Response β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

⚑ Minimal APIs

Minimal APIs reduce the boilerplate required for building HTTP APIs. Instead of controllers with classes, attributes, and method signatures, you define endpoints with concise lambda expressions.

Traditional Controller vs Minimal API:

Traditional Controller Minimal API
Requires controller class Routes defined inline
Uses attributes for routing Lambda-based handlers
More ceremony, more files Less boilerplate
Better for complex scenarios Perfect for microservices

Minimal API Philosophy:

  • Start simple, add complexity only when needed
  • Optimize for developer productivity
  • Excellent performance characteristics
  • Easier to understand and maintain for simple scenarios

πŸ€” Did you know? Minimal APIs generally allocate less and take a shorter path through the framework than controller-based APIs, so they tend to show higher throughput in benchmarks. The size of the gap depends entirely on your workloadβ€”measure your own app rather than trusting a headline number.

πŸ—ΊοΈ Routing in Minimal APIs

Routing maps incoming HTTP requests to specific handlers based on:

  • HTTP Method (GET, POST, PUT, DELETE, etc.)
  • URL Pattern (e.g., /products/{id})
  • Route Constraints (e.g., {id:int} for integer-only)

Route Parameters:

  • {id} - Captures any value
  • {id:int} - Only matches integers
  • {name:alpha} - Only matches alphabetic characters
  • {category?} - Optional parameter

βš™οΈ Configuration System

ASP.NET Core uses a flexible configuration system that can read from multiple sources:

  1. appsettings.json (base configuration)
  2. appsettings..json (environment-specific)
  3. Environment variables (deployment-specific)
  4. Command-line arguments (runtime overrides)
  5. User secrets (development-only sensitive data)

Configuration hierarchy (later sources override earlier ones):

appsettings.json
       ↓
appsettings.Development.json
       ↓
Environment Variables
       ↓
Command-line Arguments
       ↓
   FINAL CONFIG

Examples

Example 1: Basic Minimal API Setup πŸš€

Let's create a simple "Hello World" Minimal API that demonstrates the core structure:

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
// AddOpenApi() comes from the Microsoft.AspNetCore.OpenApi package and is the
// built-in replacement for Swashbuckle, which was dropped from the ASP.NET Core
// templates in .NET 9.
builder.Services.AddOpenApi();

var app = builder.Build();

// Configure the HTTP request pipeline
if (app.Environment.IsDevelopment())
{
    // Serves the generated document at /openapi/v1.json
    app.MapOpenApi();
}

app.UseHttpsRedirection();

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

app.MapGet("/hello/{name}", (string name) =>
    $"Hello, {name}!");

app.Run();

Explanation:

  • WebApplication.CreateBuilder(args) creates the builder with default configuration
  • builder.Services is where you register dependencies for DI
  • AddOpenApi() + MapOpenApi() generate an OpenAPI document with no third-party package
  • app.Use...() methods add middleware to the pipeline
  • app.MapGet() defines GET endpoints with inline handlers
  • Route parameters (like {name}) are automatically bound to method parameters

πŸ’‘ Try this: Run the application and navigate to /hello/YourName on the HTTPS URL printed in your console (the port is assigned in launchSettings.json, so it differs per project) to see parameter binding in action.

πŸ“„ On Swagger UI: AddOpenApi() produces the OpenAPI document but no interactive UI. If you want a browsable page, add a UI package such as Scalar.AspNetCore or Swashbuckle.AspNetCore.SwaggerUI and point it at /openapi/v1.json.

Example 2: Dependency Injection with Services πŸ”§

Here's how to create and inject a custom service:

public interface IProductService
{
    Task<List<Product>> GetAllProductsAsync();
    Task<Product?> GetProductByIdAsync(int id);
}

public class ProductService : IProductService
{
    private readonly List<Product> _products = new()
    {
        new Product(1, "Laptop", 999.99m),
        new Product(2, "Mouse", 29.99m),
        new Product(3, "Keyboard", 79.99m)
    };

    public Task<List<Product>> GetAllProductsAsync()
    {
        return Task.FromResult(_products);
    }

    public Task<Product?> GetProductByIdAsync(int id)
    {
        return Task.FromResult(_products.FirstOrDefault(p => p.Id == id));
    }
}

public record Product(int Id, string Name, decimal Price);

// Program.cs
var builder = WebApplication.CreateBuilder(args);

// Register the service with Scoped lifetime
builder.Services.AddScoped<IProductService, ProductService>();

var app = builder.Build();

// Inject the service into endpoints
app.MapGet("/products", async (IProductService productService) =>
{
    var products = await productService.GetAllProductsAsync();
    return Results.Ok(products);
});

app.MapGet("/products/{id:int}", async (int id, IProductService productService) =>
{
    var product = await productService.GetProductByIdAsync(id);
    return product is not null
        ? Results.Ok(product)
        : Results.NotFound();
});

app.Run();

Explanation:

  • AddScoped<TInterface, TImplementation>() registers the service with scoped lifetime
  • Parameters in endpoint handlers are automatically resolved from DI
  • Results.Ok() and Results.NotFound() return typed responses with proper status codes
  • {id:int} constraint ensures only integer IDs are matched
  • Product is a positional record, so it is constructed as new Product(1, "Laptop", 999.99m)

Example 3: Custom Middleware Component πŸ”€

Middleware can intercept requests and responses for cross-cutting concerns:

public class RequestTimingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestTimingMiddleware> _logger;

    public RequestTimingMiddleware(
        RequestDelegate next,
        ILogger<RequestTimingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var timestamp = Stopwatch.GetTimestamp();

        // Call the next middleware in the pipeline
        await _next(context);

        var duration = Stopwatch.GetElapsedTime(timestamp);
        _logger.LogInformation(
            "Request {Method} {Path} completed in {Duration}ms",
            context.Request.Method,
            context.Request.Path,
            duration.TotalMilliseconds);
    }
}

// Extension method for cleaner registration
public static class MiddlewareExtensions
{
    public static IApplicationBuilder UseRequestTiming(
        this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<RequestTimingMiddleware>();
    }
}

// Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// Add custom middleware early in pipeline
app.UseRequestTiming();

app.MapGet("/slow", async () =>
{
    await Task.Delay(500); // Simulate slow operation
    return "Done!";
});

app.Run();

Explanation:

  • Middleware receives RequestDelegate _next to call the next component
  • InvokeAsync is called for each request
  • Timing logic wraps the call to _next(context)
  • Stopwatch.GetTimestamp() / GetElapsedTime() is the accurate way to measure elapsed time; DateTime.UtcNow is a wall clock and can jump when the system time is adjusted
  • Extension methods provide clean syntax: app.UseRequestTiming()

⚠️ Important: Register middleware in the correct order! The timing middleware should come early so it measures the entire pipeline.

Example 4: POST Endpoints with Validation πŸ“

Handling POST requests with model binding and validation:

using System.ComponentModel.DataAnnotations;

public record CreateProductRequest
{
    [Required]
    [StringLength(100, MinimumLength = 3)]
    public string Name { get; init; } = string.Empty;

    [Range(0.01, 10000.00)]
    public decimal Price { get; init; }

    [StringLength(500)]
    public string? Description { get; init; }
}

public record ProductResponse(int Id, string Name, decimal Price);

// Program.cs
var builder = WebApplication.CreateBuilder(args);

// .NET 10: opt in to Data Annotations validation for Minimal APIs.
// Without this call the [Required]/[Range] attributes below are NOT enforced.
builder.Services.AddValidation();

var app = builder.Build();

var products = new List<ProductResponse>();
var nextId = 1;

app.MapPost("/products", (CreateProductRequest request) =>
{
    // AddValidation() runs the annotations before this handler is invoked,
    // returning 400 with a ProblemDetails body if the model is invalid.
    var product = new ProductResponse(
        nextId++,
        request.Name,
        request.Price);

    products.Add(product);

    return Results.Created($"/products/{product.Id}", product);
});

app.MapPut("/products/{id:int}", (int id, CreateProductRequest request) =>
{
    var existingProduct = products.FirstOrDefault(p => p.Id == id);

    if (existingProduct is null)
        return Results.NotFound();

    var updatedProduct = new ProductResponse(id, request.Name, request.Price);
    products.Remove(existingProduct);
    products.Add(updatedProduct);

    return Results.Ok(updatedProduct);
});

app.MapDelete("/products/{id:int}", (int id) =>
{
    var product = products.FirstOrDefault(p => p.Id == id);

    if (product is null)
        return Results.NotFound();

    products.Remove(product);
    return Results.NoContent();
});

app.Run();

Validation in Minimal APIs is opt-in. Alongside AddValidation(), add the interceptor namespace to your .csproj so the source generator can wire up the validation:

<PropertyGroup>
  <InterceptorsNamespaces>$(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Validation.Generated</InterceptorsNamespaces>
</PropertyGroup>

Explanation:

  • Data annotations ([Required], [Range]) declare the rules, but AddValidation() is what enforces them
  • Before .NET 10 you had to write a filter yourself or use a library such as FluentValidation
  • record types are perfect for DTOsβ€”immutable and concise
  • Results.Created() returns 201 status with Location header
  • Results.NoContent() returns 204 for successful DELETE operations
  • Model binding automatically deserializes JSON request bodies

⚠️ Heads up: the nextId++ counter above is captured by the lambda and is not thread-safeβ€”it exists to keep the example short. Use a database or Interlocked.Increment in real code.

πŸ’‘ Pro tip: Use separate request/response models to avoid over-posting vulnerabilities and maintain clean API contracts.


⚠️ Common Mistakes

1. Incorrect Middleware Order

❌ Wrong:

app.UseRouting();
app.UseStaticFiles(); // Too late!
app.UseAuthentication();

βœ… Correct:

app.UseStaticFiles(); // Early for performance
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();

Why it matters: Static files shouldn't go through authentication. Put UseStaticFiles() early to short-circuit unnecessary processing.

2. Wrong Service Lifetime

❌ Wrong:

// DbContext as Singleton - leads to threading issues!
builder.Services.AddSingleton<MyDbContext>();

βœ… Correct:

// AddDbContext registers the context as Scoped (per request) by default
builder.Services.AddDbContext<MyDbContext>(options =>
    options.UseSqlServer(connectionString));

Why it matters: DbContext isn't thread-safe. Using Singleton lifetime causes race conditions and data corruption.

3. Forgetting async/await

❌ Wrong:

app.MapGet("/data", (IDataService service) =>
{
    var data = service.GetDataAsync(); // Returns Task, not data!
    return Results.Ok(data);
});

βœ… Correct:

app.MapGet("/data", async (IDataService service) =>
{
    var data = await service.GetDataAsync();
    return Results.Ok(data);
});

4. Injecting Scoped Services into Singletons

❌ Wrong:

public class MySingletonService
{
    private readonly MyDbContext _db; // DANGER!

    public MySingletonService(MyDbContext db)
    {
        _db = db; // Captures a scoped service!
    }
}

βœ… Correct:

public class MySingletonService
{
    private readonly IServiceScopeFactory _scopeFactory;

    public MySingletonService(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }

    public async Task DoWork()
    {
        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<MyDbContext>();
        // Use db here
    }
}

5. Not Using Route Constraints

❌ Wrong:

app.MapGet("/products/{id}", (string id) =>
{
    if (!int.TryParse(id, out var numericId))
        return Results.BadRequest();
    // ...
});

βœ… Correct:

app.MapGet("/products/{id:int}", (int id) =>
{
    // id is guaranteed to be an integer
    // ...
});

Why it matters: Route constraints prevent invalid requests from reaching your handler and provide automatic 404 responses for mismatches.


Key Takeaways 🎯

βœ… ASP.NET Core is a cross-platform, high-performance framework with built-in DI, modular architecture, and flexible configuration

βœ… Minimal APIs reduce boilerplate while maintaining performanceβ€”perfect for microservices and simple APIs

βœ… Middleware order mattersβ€”early middleware can short-circuit the pipeline for performance

βœ… Service lifetimes (Transient, Scoped, Singleton) control how dependencies are created and shared

βœ… Route constraints ({id:int}) validate parameters before your handler runs

βœ… Model binding deserializes request bodies automatically, but Data Annotations are only enforced once you call AddValidation()

βœ… OpenAPI is built in via AddOpenApi()/MapOpenApi()β€”Swashbuckle is no longer part of the template

βœ… Results helpers (Results.Ok(), Results.NotFound(), etc.) provide type-safe HTTP responses

βœ… Always use async/await for I/O operationsβ€”never block the thread pool


πŸ“š Further Study

  1. Official ASP.NET Core Documentation - https://learn.microsoft.com/aspnet/core/
  2. Minimal APIs Overview - https://learn.microsoft.com/aspnet/core/fundamentals/minimal-apis
  3. Minimal API Validation - https://learn.microsoft.com/aspnet/core/fundamentals/minimal-apis/validation
  4. OpenAPI Support in ASP.NET Core - https://learn.microsoft.com/aspnet/core/fundamentals/openapi/overview
  5. Dependency Injection in .NET - https://learn.microsoft.com/dotnet/core/extensions/dependency-injection

πŸ“‹ Quick Reference Card

API Purpose
WebApplication.CreateBuilder() Initializes app with default config
builder.Services.Add... Registers services for DI
builder.Services.AddValidation() Enables Data Annotations validation (.NET 10)
builder.Services.AddOpenApi() Registers built-in OpenAPI document generation
app.Use...() Adds middleware to pipeline
app.MapGet/Post/Put/Delete() Defines HTTP endpoints
app.MapOpenApi() Serves the OpenAPI document
Transient New instance every time
Scoped One instance per request
Singleton One instance for app lifetime
{param:type} Route constraint (int, alpha, guid, etc.)
Results.Ok/NotFound/Created() Type-safe HTTP result helpers
ILogger<T> Built-in logging abstraction

🧠 Remember: Start simple with Minimal APIs, add complexity (controllers, filters, etc.) only when your scenario demands it. The best code is code you don't have to write!