.NET Minimal APIs revolutionized API development by reducing boilerplate code, while Native AOT (Ahead-of-Time compilation) delivers lightning-fast startup times and reduced memory footprint. This guide covers everything you need to master both technologies.
What are Minimal APIs?
Minimal APIs were introduced in .NET 6 as a simplified way to build HTTP APIs without the ceremony of controllers, action methods, and heavy abstractions.
Traditional Controller-Based API
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductService _productService;
public ProductsController(IProductService productService)
{
_productService = productService;
}
[HttpGet]
public async Task<ActionResult<List<Product>>> GetAll()
{
var products = await _productService.GetAllAsync();
return Ok(products);
}
[HttpGet("{id}")]
public async Task<ActionResult<Product>> GetById(int id)
{
var product = await _productService.GetByIdAsync(id);
if (product == null)
return NotFound();
return Ok(product);
}
}
Minimal API Equivalent
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/api/products", async (IProductService productService) =>
{
var products = await productService.GetAllAsync();
return Results.Ok(products);
});
app.MapGet("/api/products/{id}", async (int id, IProductService productService) =>
{
var product = await productService.GetByIdAsync(id);
return product is not null ? Results.Ok(product) : Results.NotFound();
});
app.Run();
Key Benefits:
- Less code: ~60% fewer lines compared to controllers
- Faster development: No need for controller classes, inheritance
- Performance: Slightly better throughput due to less abstraction
- Modern syntax: Uses latest C# features (pattern matching, top-level statements)
When to Use Minimal APIs
✅ Use Minimal APIs When:
- Building microservices with simple CRUD operations
- Creating lightweight APIs for serverless/containers
- Prototyping or building MVPs quickly
- You prefer functional programming style
- Performance is critical (native AOT compatibility)
- Building simple webhooks or event handlers
⚠️ Consider Controllers When:
- Building large enterprise applications with hundreds of endpoints
- Team prefers object-oriented approach
- You need extensive controller features (filters, model binding customization)
- Existing codebase uses controllers heavily
Creating Your First Minimal API
1. Create New Project
dotnet new web -n MinimalApiDemo
cd MinimalApiDemo
2. Basic Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run();
3. Run the Application
dotnet run
# Navigate to http://localhost:5000
HTTP Methods and Routing
All HTTP Verbs
// GET
app.MapGet("/products", () => /* ... */);
// POST
app.MapPost("/products", (Product product) => /* ... */);
// PUT
app.MapPut("/products/{id}", (int id, Product product) => /* ... */);
// DELETE
app.MapDelete("/products/{id}", (int id) => /* ... */);
// PATCH
app.MapPatch("/products/{id}", (int id, JsonPatchDocument patch) => /* ... */);
Route Parameters
// Simple parameter
app.MapGet("/products/{id}", (int id) =>
Results.Ok($"Product ID: {id}"));
// Multiple parameters
app.MapGet("/categories/{categoryId}/products/{productId}",
(int categoryId, int productId) =>
Results.Ok(new { categoryId, productId }));
// Optional parameters (use nullable types)
app.MapGet("/search", (string? query, int? page) =>
Results.Ok($"Query: {query ?? "all"}, Page: {page ?? 1}"));
// Route constraints
app.MapGet("/products/{id:int}", (int id) => /* ... */);
app.MapGet("/products/{slug:alpha}", (string slug) => /* ... */);
app.MapGet("/items/{id:guid}", (Guid id) => /* ... */);
Query String Parameters
// Automatic binding from query string
app.MapGet("/products", (int page, int pageSize, string? search) =>
{
// GET /products?page=1&pageSize=10&search=laptop
return Results.Ok(new
{
Page = page,
PageSize = pageSize,
Search = search ?? ""
});
});
Request and Response Handling
Reading Request Body
// Simple object
app.MapPost("/products", (Product product) =>
{
// Product automatically deserialized from JSON
return Results.Created($"/products/{product.Id}", product);
});
// With validation
app.MapPost("/products", (Product product) =>
{
if (string.IsNullOrEmpty(product.Name))
return Results.BadRequest("Name is required");
return Results.Created($"/products/{product.Id}", product);
});
// Manual HttpContext access
app.MapPost("/upload", async (HttpContext context) =>
{
using var reader = new StreamReader(context.Request.Body);
var body = await reader.ReadToEndAsync();
return Results.Ok($"Received: {body}");
});
Response Types
// Ok (200)
app.MapGet("/products/{id}", (int id) =>
Results.Ok(new Product { Id = id, Name = "Laptop" }));
// Created (201)
app.MapPost("/products", (Product product) =>
Results.Created($"/products/{product.Id}", product));
// NoContent (204)
app.MapDelete("/products/{id}", (int id) =>
Results.NoContent());
// NotFound (404)
app.MapGet("/products/{id}", (int id) =>
Results.NotFound());
// BadRequest (400)
app.MapPost("/products", (Product product) =>
Results.BadRequest("Invalid product data"));
// Custom status code
app.MapGet("/health", () =>
Results.StatusCode(503)); // Service Unavailable
// File download
app.MapGet("/download", () =>
Results.File("report.pdf", "application/pdf"));
// Redirect
app.MapGet("/old-path", () =>
Results.Redirect("/new-path"));
Dependency Injection
Registering Services
var builder = WebApplication.CreateBuilder(args);
// Register services
builder.Services.AddSingleton<IProductRepository, ProductRepository>();
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddTransient<IEmailService, EmailService>();
// Add database context
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
var app = builder.Build();
Injecting into Endpoints
// Service injection (automatically resolved)
app.MapGet("/products", async (IProductService productService) =>
{
var products = await productService.GetAllAsync();
return Results.Ok(products);
});
// Multiple services
app.MapPost("/products", async (
Product product,
IProductService productService,
ILogger<Program> logger) =>
{
logger.LogInformation("Creating product: {Name}", product.Name);
await productService.CreateAsync(product);
return Results.Created($"/products/{product.Id}", product);
});
// Built-in services
app.MapGet("/config", (IConfiguration config) =>
Results.Ok(config["AppSettings:Version"]));
Organizing Minimal APIs
Route Groups
var app = builder.Build();
// Product routes
var products = app.MapGroup("/api/products")
.WithTags("Products");
products.MapGet("/", GetAllProducts);
products.MapGet("/{id}", GetProductById);
products.MapPost("/", CreateProduct);
products.MapPut("/{id}", UpdateProduct);
products.MapDelete("/{id}", DeleteProduct);
// Category routes with prefix
var categories = app.MapGroup("/api/categories")
.WithTags("Categories")
.RequireAuthorization(); // All category routes require auth
categories.MapGet("/", GetAllCategories);
categories.MapGet("/{id}", GetCategoryById);
app.Run();
Separate Endpoint Classes (Carter Pattern)
// ProductEndpoints.cs
public static class ProductEndpoints
{
public static void MapProductEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/products")
.WithTags("Products")
.WithOpenApi();
group.MapGet("/", GetAll);
group.MapGet("/{id}", GetById);
group.MapPost("/", Create);
group.MapPut("/{id}", Update);
group.MapDelete("/{id}", Delete);
}
private static async Task<IResult> GetAll(IProductService service)
{
var products = await service.GetAllAsync();
return Results.Ok(products);
}
private static async Task<IResult> GetById(int id, IProductService service)
{
var product = await service.GetByIdAsync(id);
return product is not null ? Results.Ok(product) : Results.NotFound();
}
private static async Task<IResult> Create(Product product, IProductService service)
{
var created = await service.CreateAsync(product);
return Results.Created($"/api/products/{created.Id}", created);
}
private static async Task<IResult> Update(
int id, Product product, IProductService service)
{
await service.UpdateAsync(id, product);
return Results.NoContent();
}
private static async Task<IResult> Delete(int id, IProductService service)
{
await service.DeleteAsync(id);
return Results.NoContent();
}
}
// Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapProductEndpoints();
app.Run();
Validation and Error Handling
Manual Validation
app.MapPost("/products", (Product product) =>
{
if (string.IsNullOrWhiteSpace(product.Name))
return Results.ValidationProblem(new Dictionary<string, string[]>
{
{ "Name", new[] { "Name is required" } }
});
if (product.Price <= 0)
return Results.ValidationProblem(new Dictionary<string, string[]>
{
{ "Price", new[] { "Price must be greater than 0" } }
});
return Results.Created($"/products/{product.Id}", product);
});
FluentValidation Integration
dotnet add package FluentValidation.DependencyInjectionExtensions
// ProductValidator.cs
public class ProductValidator : AbstractValidator<Product>
{
public ProductValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Name is required")
.MaximumLength(100);
RuleFor(x => x.Price)
.GreaterThan(0).WithMessage("Price must be positive");
RuleFor(x => x.Stock)
.GreaterThanOrEqualTo(0).WithMessage("Stock cannot be negative");
}
}
// Program.cs
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
app.MapPost("/products", async (
Product product,
IValidator<Product> validator,
IProductService service) =>
{
var validationResult = await validator.ValidateAsync(product);
if (!validationResult.IsValid)
{
return Results.ValidationProblem(
validationResult.ToDictionary());
}
var created = await service.CreateAsync(product);
return Results.Created($"/products/{created.Id}", created);
});
Global Exception Handler
app.UseExceptionHandler(exceptionHandlerApp =>
{
exceptionHandlerApp.Run(async context =>
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json";
var exceptionHandlerFeature =
context.Features.Get<IExceptionHandlerFeature>();
var error = new
{
message = "An error occurred",
detail = exceptionHandlerFeature?.Error.Message
};
await context.Response.WriteAsJsonAsync(error);
});
});
Authentication and Authorization
JWT Authentication
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]))
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
// Public endpoint
app.MapGet("/public", () => "Public data");
// Protected endpoint
app.MapGet("/private", () => "Private data")
.RequireAuthorization();
// Role-based authorization
app.MapGet("/admin", () => "Admin data")
.RequireAuthorization("Admin");
// Policy-based authorization
app.MapGet("/manager", () => "Manager data")
.RequireAuthorization(policy => policy.RequireRole("Manager", "Admin"));
Native AOT: What and Why
What is Native AOT?
Native AOT (Ahead-of-Time compilation) compiles your .NET application directly to native machine code before runtime, instead of using Just-In-Time (JIT) compilation.
Traditional .NET (JIT):
C# Source → IL Code → (Runtime) JIT → Native Code
Native AOT:
C# Source → Native Code (at build time)
Why Use Native AOT?
Benefits:
-
Fast Startup: 4-10x faster startup times
- Traditional: 500ms-2s
- Native AOT: 50-200ms
-
Lower Memory Usage: 50-70% reduction
- Traditional: 50-100MB baseline
- Native AOT: 10-30MB baseline
-
Smaller Deployment: Single executable, no runtime needed
- Traditional: 100-200MB
- Native AOT: 10-50MB
-
Predictable Performance: No JIT warmup time
Trade-offs:
- Longer Build Times: 2-10x slower builds
- Limited Reflection: Only source-generated or trimming-safe
- No Dynamic Code: No
Assembly.Load, Reflection.Emit - Library Compatibility: Not all NuGet packages support AOT
When to Use AOT
✅ Perfect for:
- Serverless functions (AWS Lambda, Azure Functions)
- Microservices with frequent cold starts
- CLI tools and utilities
- Edge computing / IoT devices
- Container-based applications (smaller images)
❌ Avoid for:
- Applications using heavy reflection
- Apps with dynamic plugin systems
- Complex Entity Framework scenarios
- Third-party libraries without AOT support
Enabling Native AOT
1. Create AOT-Compatible Project
dotnet new webapiaot -n MyApiAot
cd MyApiAot
Or modify existing .csproj:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Enable Native AOT -->
<PublishAot>true</PublishAot>
<!-- Optional: Reduce size further -->
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
</Project>
2. Write AOT-Compatible Code
using System.Text.Json.Serialization;
var builder = WebApplication.CreateSlimBuilder(args);
// Configure JSON serialization for AOT
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
});
var app = builder.Build();
app.MapGet("/products", () =>
new Product { Id = 1, Name = "Laptop", Price = 999.99m });
app.Run();
// Required for AOT - JSON source generation
[JsonSerializable(typeof(Product))]
[JsonSerializable(typeof(List<Product>))]
internal partial class AppJsonSerializerContext : JsonSerializerContext
{
}
public record Product(int Id, string Name, decimal Price);
3. Publish Native Executable
# Publish for current OS
dotnet publish -c Release
# Publish for specific runtime
dotnet publish -c Release -r linux-x64
dotnet publish -c Release -r win-x64
dotnet publish -c Release -r osx-arm64
Output: Single native executable (10-50MB) with no dependencies.
AOT-Compatible Patterns
Using SlimBuilder
// Standard builder (includes many services)
var builder = WebApplication.CreateBuilder(args);
// Slim builder (minimal services for AOT)
var builder = WebApplication.CreateSlimBuilder(args);
// Manually add only what you need
builder.Services.AddSingleton<IProductService, ProductService>();
JSON Source Generation
// Define all types that will be serialized
[JsonSerializable(typeof(Product))]
[JsonSerializable(typeof(List<Product>))]
[JsonSerializable(typeof(ApiResponse<Product>))]
[JsonSerializable(typeof(ErrorResponse))]
internal partial class AppJsonSerializerContext : JsonSerializerContext
{
}
// Configure in builder
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0,
AppJsonSerializerContext.Default);
});
Avoiding Reflection
// ❌ Not AOT-compatible (reflection)
var type = Type.GetType("MyNamespace.Product");
var instance = Activator.CreateInstance(type);
// ✅ AOT-compatible (direct instantiation)
var product = new Product { Id = 1, Name = "Laptop" };
// ❌ Not AOT-compatible (dynamic loading)
var assembly = Assembly.Load("MyPlugin.dll");
// ✅ AOT-compatible (compile-time known)
var service = new ProductService();
Complete AOT Example
using System.Text.Json.Serialization;
var builder = WebApplication.CreateSlimBuilder(args);
// Add logging
builder.Logging.AddConsole();
// Configure JSON with source generation
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0,
AppJsonSerializerContext.Default);
});
// Register services
builder.Services.AddSingleton<IProductRepository, InMemoryProductRepository>();
var app = builder.Build();
// Endpoints
var products = app.MapGroup("/api/products");
products.MapGet("/", (IProductRepository repo) =>
{
var allProducts = repo.GetAll();
return Results.Ok(allProducts);
});
products.MapGet("/{id}", (int id, IProductRepository repo) =>
{
var product = repo.GetById(id);
return product is not null ? Results.Ok(product) : Results.NotFound();
});
products.MapPost("/", (Product product, IProductRepository repo) =>
{
var created = repo.Add(product);
return Results.Created($"/api/products/{created.Id}", created);
});
products.MapDelete("/{id}", (int id, IProductRepository repo) =>
{
repo.Delete(id);
return Results.NoContent();
});
app.Run();
// Models with JSON source generation
[JsonSerializable(typeof(Product))]
[JsonSerializable(typeof(List<Product>))]
internal partial class AppJsonSerializerContext : JsonSerializerContext
{
}
public record Product(int Id, string Name, decimal Price, int Stock);
// Repository
public interface IProductRepository
{
List<Product> GetAll();
Product? GetById(int id);
Product Add(Product product);
void Delete(int id);
}
public class InMemoryProductRepository : IProductRepository
{
private readonly List<Product> _products = new()
{
new(1, "Laptop", 999.99m, 10),
new(2, "Mouse", 29.99m, 50),
new(3, "Keyboard", 79.99m, 25)
};
public List<Product> GetAll() => _products;
public Product? GetById(int id) => _products.FirstOrDefault(p => p.Id == id);
public Product Add(Product product)
{
var newProduct = product with { Id = _products.Max(p => p.Id) + 1 };
_products.Add(newProduct);
return newProduct;
}
public void Delete(int id)
{
var product = _products.FirstOrDefault(p => p.Id == id);
if (product is not null)
_products.Remove(product);
}
}
Performance Comparison
Startup Time Benchmark
Application: Simple API with 10 endpoints
Traditional .NET 8 (JIT):
- Cold start: 892ms
- Memory: 62MB
Native AOT:
- Cold start: 128ms (7x faster)
- Memory: 18MB (3.4x less)
Memory Footprint
Application: REST API with database
Traditional:
- Baseline: 75MB
- Under load: 150MB
- Peak: 220MB
Native AOT:
- Baseline: 22MB (71% reduction)
- Under load: 45MB (70% reduction)
- Peak: 68MB (69% reduction)
Deployment Size
Traditional publish:
- Runtime + dependencies: 180MB
- Application: 25MB
- Total: 205MB
Native AOT publish:
- Single executable: 32MB (84% reduction)
- No runtime needed
Best Practices
1. Use CreateSlimBuilder for AOT
// For AOT
var builder = WebApplication.CreateSlimBuilder(args);
// For traditional apps
var builder = WebApplication.CreateBuilder(args);
2. Always Use JSON Source Generation
[JsonSerializable(typeof(Product))]
[JsonSerializable(typeof(List<Product>))]
internal partial class AppJsonSerializerContext : JsonSerializerContext
{
}
3. Test AOT Compatibility Early
# Check for AOT warnings
dotnet publish -c Release /p:PublishAot=true
4. Keep Dependencies Minimal
// Only add packages that support AOT
// Check package documentation
5. Use Records for DTOs
// Record types work great with source generators
public record ProductDto(int Id, string Name, decimal Price);
6. Avoid Reflection
// Use dependency injection instead of reflection
builder.Services.AddSingleton<IProductService, ProductService>();
Common Pitfalls and Solutions
Issue: JsonSerializer throws at runtime
Problem:
// Missing source generation
return Results.Ok(new Product { ... });
// Runtime error: Type 'Product' not supported
Solution:
[JsonSerializable(typeof(Product))]
internal partial class AppJsonSerializerContext : JsonSerializerContext { }
Issue: EF Core queries fail
Problem:
// Complex LINQ with reflection
context.Products.Where(p => p.Name.Contains(searchTerm))
Solution: Use compiled queries or simpler LINQ with source generation support.
Issue: Slow build times
Problem: Full AOT publish takes 5+ minutes
Solution:
# During development, use JIT
dotnet run
# Only use AOT for production builds
dotnet publish -c Release -p:PublishAot=true
Minimal APIs + AOT: Production Example
Here’s a production-ready example combining everything:
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http.HttpResults;
var builder = WebApplication.CreateSlimBuilder(args);
// Logging
builder.Logging.AddJsonConsole();
// JSON configuration
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0,
AppJsonSerializerContext.Default);
});
// Services
builder.Services.AddSingleton<IProductService, ProductService>();
builder.Services.AddSingleton<IMetricsCollector, MetricsCollector>();
// Health checks
builder.Services.AddHealthChecks();
var app = builder.Build();
// Middleware
app.UseHealthChecks("/health");
// API endpoints
var api = app.MapGroup("/api/v1");
api.MapGet("/products", GetProducts)
.WithName("GetProducts")
.Produces<List<Product>>();
api.MapGet("/products/{id:int}", GetProduct)
.WithName("GetProduct")
.Produces<Product>()
.Produces(404);
api.MapPost("/products", CreateProduct)
.WithName("CreateProduct")
.Produces<Product>(201)
.Produces(400);
app.Run();
// Handlers
static async Task<Ok<List<Product>>> GetProducts(
IProductService service,
ILogger<Program> logger)
{
logger.LogInformation("Fetching all products");
var products = await service.GetAllAsync();
return TypedResults.Ok(products);
}
static async Task<Results<Ok<Product>, NotFound>> GetProduct(
int id,
IProductService service)
{
var product = await service.GetByIdAsync(id);
return product is not null
? TypedResults.Ok(product)
: TypedResults.NotFound();
}
static async Task<Results<Created<Product>, ValidationProblem>> CreateProduct(
Product product,
IProductService service)
{
if (string.IsNullOrWhiteSpace(product.Name))
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
["Name"] = new[] { "Name is required" }
});
var created = await service.CreateAsync(product);
return TypedResults.Created($"/api/v1/products/{created.Id}", created);
}
// JSON Source Generation
[JsonSerializable(typeof(Product))]
[JsonSerializable(typeof(List<Product>))]
[JsonSerializable(typeof(Dictionary<string, string[]>))]
internal partial class AppJsonSerializerContext : JsonSerializerContext
{
}
// Models
public record Product(int Id, string Name, decimal Price, int Stock);
// Services
public interface IProductService
{
Task<List<Product>> GetAllAsync();
Task<Product?> GetByIdAsync(int id);
Task<Product> CreateAsync(Product product);
}
public class ProductService : IProductService
{
private readonly List<Product> _products = new()
{
new(1, "Laptop", 999.99m, 10),
new(2, "Mouse", 29.99m, 50)
};
public Task<List<Product>> GetAllAsync() =>
Task.FromResult(_products);
public Task<Product?> GetByIdAsync(int id) =>
Task.FromResult(_products.FirstOrDefault(p => p.Id == id));
public Task<Product> CreateAsync(Product product)
{
var newProduct = product with { Id = _products.Max(p => p.Id) + 1 };
_products.Add(newProduct);
return Task.FromResult(newProduct);
}
}
public interface IMetricsCollector
{
void RecordRequest(string endpoint);
}
public class MetricsCollector : IMetricsCollector
{
public void RecordRequest(string endpoint) { }
}
Docker Integration
Dockerfile for AOT
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["MyApi.csproj", "./"]
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish -r linux-x64 -p:PublishAot=true
# Use minimal runtime image
FROM mcr.microsoft.com/dotnet/runtime-deps:8.0-alpine
WORKDIR /app
COPY --from=build /app/publish .
# Create non-root user
RUN adduser -D -u 1000 appuser && \
chown -R appuser:appuser /app
USER appuser
EXPOSE 8080
ENTRYPOINT ["./MyApi"]
Result:
- Traditional image: ~200MB
- AOT image: ~35MB (5.7x smaller)
References
- https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis
- https://learn.microsoft.com/en-us/aspnet/core/fundamentals/native-aot
- https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/
- https://devblogs.microsoft.com/dotnet/asp-net-core-updates-in-dotnet-8/