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:
- Modularity: Only include what you need via NuGet packages
- Dependency Injection: Built-in DI container for loose coupling
- Configuration: Flexible configuration from multiple sources
- 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:
- appsettings.json (base configuration)
- appsettings..json (environment-specific)
- Environment variables (deployment-specific)
- Command-line arguments (runtime overrides)
- 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 configurationbuilder.Servicesis where you register dependencies for DIAddOpenApi()+MapOpenApi()generate an OpenAPI document with no third-party packageapp.Use...()methods add middleware to the pipelineapp.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()andResults.NotFound()return typed responses with proper status codes{id:int}constraint ensures only integer IDs are matchedProductis a positionalrecord, so it is constructed asnew 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 _nextto call the next component InvokeAsyncis called for each request- Timing logic wraps the call to
_next(context) Stopwatch.GetTimestamp()/GetElapsedTime()is the accurate way to measure elapsed time;DateTime.UtcNowis 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, butAddValidation()is what enforces them - Before .NET 10 you had to write a filter yourself or use a library such as FluentValidation
recordtypes are perfect for DTOsβimmutable and conciseResults.Created()returns 201 status with Location headerResults.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
- Official ASP.NET Core Documentation - https://learn.microsoft.com/aspnet/core/
- Minimal APIs Overview - https://learn.microsoft.com/aspnet/core/fundamentals/minimal-apis
- Minimal API Validation - https://learn.microsoft.com/aspnet/core/fundamentals/minimal-apis/validation
- OpenAPI Support in ASP.NET Core - https://learn.microsoft.com/aspnet/core/fundamentals/openapi/overview
- 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!