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

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:

LifetimeDescriptionUse Case
TransientCreated each time requestedLightweight, stateless services
ScopedCreated once per requestDatabase contexts, request-specific data
SingletonCreated once for application lifetimeConfiguration, 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
β€’ Uses attributes for routing
β€’ More ceremony, more files
β€’ Better for complex scenarios
β€’ Routes defined inline
β€’ Lambda-based handlers
β€’ Less boilerplate
β€’ 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 can achieve up to 30% better throughput than controller-based APIs in benchmarks due to reduced allocations and simpler execution paths!

πŸ—ΊοΈ 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:

using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// Configure the HTTP request pipeline
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

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
  • 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 https://localhost:5001/hello/YourName to see parameter binding in action!

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 { Id = 1, Name = "Laptop", Price = 999.99m },
        new Product { Id = 2, Name = "Mouse", Price = 29.99m },
        new Product { Id = 3, Name = "Keyboard", Price = 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

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 startTime = DateTime.UtcNow;
        
        // Call the next middleware in the pipeline
        await _next(context);
        
        var duration = DateTime.UtcNow - startTime;
        _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)
  • 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);

// Enable model validation
builder.Services.AddEndpointsApiExplorer();

var app = builder.Build();

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

app.MapPost("/products", (CreateProductRequest request) =>
{
    // Model binding and validation happen automatically
    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();

Explanation:

  • Data annotations ([Required], [Range]) provide validation rules
  • 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

πŸ’‘ 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:

// DbContext should be Scoped (per request)
builder.Services.AddDbContext<MyDbContext>();

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 automatically deserializes request bodies and validates using data annotations

βœ… 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://docs.microsoft.com/aspnet/core/
  2. Minimal APIs Overview - https://docs.microsoft.com/aspnet/core/fundamentals/minimal-apis
  3. Dependency Injection in .NET - https://docs.microsoft.com/dotnet/core/extensions/dependency-injection

πŸ“‹ Quick Reference Card

WebApplication.CreateBuilder()Initializes app with default config
builder.Services.Add...Registers services for DI
app.Use...()Adds middleware to pipeline
app.MapGet/Post/Put/Delete()Defines HTTP endpoints
TransientNew instance every time
ScopedOne instance per request
SingletonOne 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!

Practice Questions

Test your understanding with these questions:

Q1: Complete the Minimal API endpoint definition: ```csharp app.{{1}}(\"hello/{name}\", (string name) => $\"Hello, {name}!\"); ```
A: MapGet
Q2: What is the output of this code? ```csharp var builder = WebApplication.CreateBuilder(); builder.Services.AddTransient<IService, MyService>(); var app = builder.Build(); app.MapGet(\"/test\", (IService svc1, IService svc2) => ReferenceEquals(svc1, svc2)); app.Run(); ``` A. True - same instance B. False - different instances C. Compilation error D. Runtime exception E. Null reference
A: B
Q3: Fill in the service lifetime that creates one instance per HTTP request: ```csharp builder.Services.Add{{1}}(DbContext); ```
A: Scoped
Q4: What HTTP status code does this endpoint return? ```csharp app.MapPost(\"/products\", (Product product) => { products.Add(product); return Results.Created($\"/products/{product.Id}\", product); }); ``` A. 200 OK B. 201 Created C. 202 Accepted D. 204 No Content E. 301 Moved Permanently
A: B
Q5: The {{1}} pattern injects dependencies from an external source rather than creating them internally, and ASP.NET Core provides a built-in {{2}} to manage these dependencies.
A: ["Dependency Injection","container"]