ASP.NET Core Fundamentals: Build Web APIs on .NET 8
37 min read
Intermediate
ASP.NET Core on .NET 8 is Microsoft's modern framework for building fast, cloud-ready Web APIs. Whether you're building microservices, REST APIs, or GraphQL endpoints, ASP.NET Core gives you minimal APIs for simplicity and controllers for convention-based routing. This guide walks you through the essentials—setup, routing, dependency injection, authentication, and Entity Framework Core integration. By the end, you'll have a working Todo API deployed with JWT authentication and ready for production. We cover both minimal APIs and controllers so you can choose the right approach for your project.
What's New in ASP.NET Core 8
ASP.NET Core 8 shipped with .NET 8 in November 2023 and brings focused improvements to APIs, performance, and developer experience. Here's what makes ASP.NET Core 8 worth adopting:
🚀
Minimal APIs
Cleaner endpoint mapping, better OpenAPI integration, and quality-of-life improvements for building APIs with less ceremony.
⚡
Performance
Faster request handling and JSON serialization with notable improvements to HTTP and JSON pipelines, plus lower allocations in common paths.
🔒
Auth Setup
Streamlined JWT and cookie configuration with endpoint-level authorization, making security easier to implement correctly.
📊
EF Core
Better LINQ translation, interceptors, and tooling improvements that work seamlessly with ASP.NET Core APIs for cleaner data access.
🌐
Cloud-Ready
Built-in health checks, rate limiting, and metrics that integrate smoothly with Azure, Kubernetes, and modern observability tools.
Note on Compatibility
ASP.NET Core 8 is part of .NET 8 LTS (Long-Term Support), which receives updates through November 2026. Most ASP.NET Core 7 applications upgrade to 8 with minimal changes.
Setup & Project Creation
You need the .NET 8 SDK to build ASP.NET Core 8 applications. The SDK includes the runtime, compiler, and project templates.
Use the webapi template to create a new ASP.NET Core API project:
Create Project
dotnet new webapi -n TodoApi
cd TodoApi
dotnet run
Your API will start on https://localhost:5001 and http://localhost:5000. Navigate to https://localhost:5001/swagger to see the auto-generated Swagger UI.
Project Structure
The webapi template creates a minimal structure with top-level statements in Program.cs:
Project Files
TodoApi/
├── Program.cs # Entry point with top-level statements
├── appsettings.json # Configuration
├── appsettings.Development.json
├── TodoApi.csproj # Project file
└── Properties/
└── launchSettings.json # Dev server settings
Tip: Classic Program Entry Point
Prefer the classic Main method? Add --use-program-main to the template command: dotnet new webapi --use-program-main. This generates a traditional Main method instead of top-level statements.
IDE Options
Visual Studio 2022: Use the "ASP.NET Core Web API" template in the project wizard
Visual Studio Code: Install the C# Dev Kit extension and use the command palette to create projects
JetBrains Rider: New Solution → ASP.NET Core Web Application → API
All three IDEs provide IntelliSense, debugging, and hot reload for ASP.NET Core development.
Minimal APIs (Deep Dive)
Minimal APIs let you define HTTP endpoints with minimal ceremony—no controllers, no attributes, just clean lambda-based routing. They're perfect for microservices, simple APIs, and internal tools.
Your First Minimal API
Program.cs - Basic Minimal API
var builder = WebApplication.CreateBuilder(args);
// Add services
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Configure middleware
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
// Define endpoints
app.MapGet("/", () => "Hello World!");
app.MapGet("/ping", () => new { status = "ok", timestamp = DateTime.UtcNow });
app.Run();
Route Parameters and Query Strings
Route Parameters
// Route parameter
app.MapGet("/users/{id}", (int id) =>
new { userId = id, name = $"User {id}" });
// Multiple parameters
app.MapGet("/posts/{year}/{month}", (int year, int month) =>
new { year, month, posts = 42 });
// Query string parameters
app.MapGet("/search", (string? query, int page = 1) =>
new { query, page, results = new[] { "item1", "item2" } });
Request and Response Types
Typed Requests/Responses
record CreateUserRequest(string Name, string Email);
record UserResponse(int Id, string Name, string Email);
app.MapPost("/users", (CreateUserRequest request) =>
{
var user = new UserResponse(1, request.Name, request.Email);
return Results.Created($"/users/{user.Id}", user);
});
// Return typed results
app.MapGet("/users/{id}", (int id) =>
{
if (id <= 0) return Results.BadRequest("Invalid ID");
var user = new UserResponse(id, "John Doe", "john@example.com");
return Results.Ok(user);
});
Organizing Endpoints with MapGroup
Endpoint Groups
// Group related endpoints
var usersGroup = app.MapGroup("/api/users");
usersGroup.MapGet("/", () => new[] { "user1", "user2" });
usersGroup.MapGet("/{id}", (int id) => $"User {id}");
usersGroup.MapPost("/", (CreateUserRequest req) => Results.Created("/api/users/1", req));
// Versioned API groups
var v1 = app.MapGroup("/api/v1");
v1.MapGet("/products", () => new[] { "product1", "product2" });
var v2 = app.MapGroup("/api/v2");
v2.MapGet("/products", () => new[] { "product1", "product2", "product3" });
When to Use Minimal APIs
Microservices: Small, focused services with a few endpoints
Internal tools: Admin APIs, health checks, webhooks
Edge gateways: Simple routing and transformation layers
Serverless functions: Lambda-style APIs deployed to Azure Functions or AWS Lambda
Minimal APIs vs Controllers
Use minimal APIs for small services and internal tools. Use controllers when you need filters, model binders, formatters, or lots of conventional endpoints. Hybrid approaches work great—minimal APIs for health/auth/token endpoints plus controllers for the rest.
Controllers & MVC Patterns
Controllers provide convention-based routing with attributes, filters, and model binding. They're ideal for large APIs with many endpoints following REST conventions.
Creating a Controller
TodosController.cs
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/[controller]")]
public class TodosController : ControllerBase
{
private static List<Todo> _todos = new()
{
new Todo(1, "Learn ASP.NET Core", false),
new Todo(2, "Build an API", false)
};
[HttpGet]
public ActionResult<IEnumerable<Todo>> GetAll()
{
return Ok(_todos);
}
[HttpGet("{id}")]
public ActionResult<Todo> GetById(int id)
{
var todo = _todos.FirstOrDefault(t => t.Id == id);
if (todo == null) return NotFound();
return Ok(todo);
}
[HttpPost]
public ActionResult<Todo> Create(CreateTodoRequest request)
{
var todo = new Todo(_todos.Count + 1, request.Title, false);
_todos.Add(todo);
return CreatedAtAction(nameof(GetById), new { id = todo.Id }, todo);
}
}
record Todo(int Id, string Title, bool IsComplete);
record CreateTodoRequest(string Title);
Registering Controllers
Add controller services and mapping in Program.cs:
Program.cs - Controllers Setup
var builder = WebApplication.CreateBuilder(args);
// Add controllers
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
// Map controllers
app.MapControllers();
app.Run();
Action Results and Status Codes
Status Codes
[HttpGet("{id}")]
public ActionResult<Todo> GetById(int id)
{
var todo = _todos.FirstOrDefault(t => t.Id == id);
// 404 Not Found
if (todo == null)
return NotFound(new { message = "Todo not found" });
// 200 OK
return Ok(todo);
}
[HttpPost]
public ActionResult<Todo> Create(CreateTodoRequest request)
{
// Validation
if (string.IsNullOrWhiteSpace(request.Title))
return BadRequest(new { message = "Title is required" });
var todo = new Todo(_todos.Count + 1, request.Title, false);
_todos.Add(todo);
// 201 Created with Location header
return CreatedAtAction(nameof(GetById), new { id = todo.Id }, todo);
}
[HttpDelete("{id}")]
public IActionResult Delete(int id)
{
var todo = _todos.FirstOrDefault(t => t.Id == id);
if (todo == null) return NotFound();
_todos.Remove(todo);
// 204 No Content
return NoContent();
}
Model Validation
Data Annotations
using System.ComponentModel.DataAnnotations;
record CreateTodoRequest(
[Required]
[StringLength(100, MinimumLength = 3)]
string Title,
[StringLength(500)]
string? Description
);
[HttpPost]
public ActionResult<Todo> Create(CreateTodoRequest request)
{
// Model validation happens automatically
// Invalid requests return 400 with validation errors
var todo = new Todo(_todos.Count + 1, request.Title, false);
_todos.Add(todo);
return CreatedAtAction(nameof(GetById), new { id = todo.Id }, todo);
}
When to Use Controllers
Large APIs: Many endpoints following REST conventions
Complex routing: Attribute routing with constraints and area support
ASP.NET Core uses a flexible configuration system that reads from JSON files, environment variables, command-line arguments, and more. Configuration values bind to strongly-typed objects for type safety.
Configuration Sources
By default, ASP.NET Core reads configuration in this order:
Store sensitive data like API keys outside source control using User Secrets: dotnet user-secrets set "JwtSettings:Secret" "my-secret". Secrets are stored in your user profile and only available in Development.
Environment Variables
Override configuration with environment variables using __ (double underscore) as a separator:
ASP.NET Core includes built-in dependency injection (DI) for managing service lifetimes and dependencies. DI promotes testable, loosely-coupled code by injecting dependencies rather than creating them directly.
Service Lifetimes
Transient: Created each time they're requested. Use for lightweight, stateless services.
Scoped: Created once per HTTP request. Use for services that should share state within a request (like DbContext).
Singleton: Created once for the application lifetime. Use for stateless services or shared state.
Registering Services
Service Registration
// Define interface and implementation
public interface ITodoRepository
{
Task<IEnumerable<Todo>> GetAllAsync();
Task<Todo?> GetByIdAsync(int id);
Task<Todo> CreateAsync(Todo todo);
}
public class TodoRepository : ITodoRepository
{
private readonly List<Todo> _todos = new();
public Task<IEnumerable<Todo>> GetAllAsync() =>
Task.FromResult(_todos.AsEnumerable());
public Task<Todo?> GetByIdAsync(int id) =>
Task.FromResult(_todos.FirstOrDefault(t => t.Id == id));
public Task<Todo> CreateAsync(Todo todo)
{
_todos.Add(todo);
return Task.FromResult(todo);
}
}
// Register in Program.cs
builder.Services.AddScoped<ITodoRepository, TodoRepository>();
Constructor Injection in Controllers
Constructor Injection
[ApiController]
[Route("api/[controller]")]
public class TodosController : ControllerBase
{
private readonly ITodoRepository _repository;
private readonly ILogger<TodosController> _logger;
public TodosController(
ITodoRepository repository,
ILogger<TodosController> logger)
{
_repository = repository;
_logger = logger;
}
[HttpGet]
public async Task<ActionResult<IEnumerable<Todo>>> GetAll()
{
_logger.LogInformation("Fetching all todos");
var todos = await _repository.GetAllAsync();
return Ok(todos);
}
}
Parameter Injection in Minimal APIs
Minimal API Injection
// Services injected as lambda parameters
app.MapGet("/todos", async (ITodoRepository repository) =>
{
var todos = await repository.GetAllAsync();
return Results.Ok(todos);
});
app.MapGet("/todos/{id}", async (int id, ITodoRepository repository) =>
{
var todo = await repository.GetByIdAsync(id);
return todo is not null ? Results.Ok(todo) : Results.NotFound();
});
app.MapPost("/todos", async (CreateTodoRequest request, ITodoRepository repository) =>
{
var todo = new Todo(0, request.Title, false);
var created = await repository.CreateAsync(todo);
return Results.Created($"/todos/{created.Id}", created);
});
Service Registration Patterns
Common Registration Patterns
// Transient - new instance every time
builder.Services.AddTransient<IEmailService, EmailService>();
// Scoped - one instance per request
builder.Services.AddScoped<ITodoRepository, TodoRepository>();
// Singleton - one instance for app lifetime
builder.Services.AddSingleton<ICache, MemoryCache>();
// Register concrete class without interface
builder.Services.AddScoped<TodoService>();
// Register with factory
builder.Services.AddScoped<ITodoRepository>(provider =>
{
var logger = provider.GetRequiredService<ILogger<TodoRepository>>();
return new TodoRepository(logger);
});
// Register multiple implementations
builder.Services.AddScoped<INotificationService, EmailNotificationService>();
builder.Services.AddScoped<INotificationService, SmsNotificationService>();
Captive Dependencies
Don't inject scoped services into singleton services—this creates captive dependencies that can cause memory leaks and unexpected behavior. Always inject services with equal or longer lifetimes.
Routing & Middleware Pipeline
The middleware pipeline processes every HTTP request. Middleware components execute in order, and each can handle the request, modify it, or pass it to the next component.
Middleware Order Matters
The order you add middleware determines the request/response flow:
Let's build a complete Todo API with both minimal APIs and controller approaches. This shows CRUD operations, validation, and proper HTTP status codes.
Define the Todo Model
Todo.cs
public record Todo(int Id, string Title, bool IsComplete);
public record CreateTodoRequest(string Title);
public record UpdateTodoRequest(string Title, bool IsComplete);
Minimal API Implementation
Program.cs - Minimal API
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
// In-memory storage
var todos = new List<Todo>
{
new Todo(1, "Learn ASP.NET Core", false),
new Todo(2, "Build an API", false)
};
// GET /todos
app.MapGet("/todos", () => Results.Ok(todos))
.WithName("GetTodos")
.Produces<List<Todo>>(StatusCodes.Status200OK);
// GET /todos/{id}
app.MapGet("/todos/{id}", (int id) =>
{
var todo = todos.FirstOrDefault(t => t.Id == id);
return todo is not null ? Results.Ok(todo) : Results.NotFound();
})
.WithName("GetTodoById")
.Produces<Todo>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
// POST /todos
app.MapPost("/todos", (CreateTodoRequest request) =>
{
if (string.IsNullOrWhiteSpace(request.Title))
return Results.BadRequest("Title is required");
var todo = new Todo(todos.Count + 1, request.Title, false);
todos.Add(todo);
return Results.Created($"/todos/{todo.Id}", todo);
})
.WithName("CreateTodo")
.Produces<Todo>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest);
// PUT /todos/{id}
app.MapPut("/todos/{id}", (int id, UpdateTodoRequest request) =>
{
var todo = todos.FirstOrDefault(t => t.Id == id);
if (todo is null) return Results.NotFound();
var index = todos.IndexOf(todo);
todos[index] = todo with { Title = request.Title, IsComplete = request.IsComplete };
return Results.NoContent();
})
.WithName("UpdateTodo")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
// DELETE /todos/{id}
app.MapDelete("/todos/{id}", (int id) =>
{
var todo = todos.FirstOrDefault(t => t.Id == id);
if (todo is null) return Results.NotFound();
todos.Remove(todo);
return Results.NoContent();
})
.WithName("DeleteTodo")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
app.Run();
Controller Implementation
The same API implemented with controllers for comparison:
TodosController.cs
[ApiController]
[Route("api/[controller]")]
public class TodosController : ControllerBase
{
private static List<Todo> _todos = new()
{
new Todo(1, "Learn ASP.NET Core", false),
new Todo(2, "Build an API", false)
};
[HttpGet]
[ProducesResponseType(typeof(List<Todo>), StatusCodes.Status200OK)]
public ActionResult<IEnumerable<Todo>> GetAll()
{
return Ok(_todos);
}
[HttpGet("{id}")]
[ProducesResponseType(typeof(Todo), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<Todo> GetById(int id)
{
var todo = _todos.FirstOrDefault(t => t.Id == id);
return todo is not null ? Ok(todo) : NotFound();
}
[HttpPost]
[ProducesResponseType(typeof(Todo), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public ActionResult<Todo> Create(CreateTodoRequest request)
{
if (string.IsNullOrWhiteSpace(request.Title))
return BadRequest("Title is required");
var todo = new Todo(_todos.Count + 1, request.Title, false);
_todos.Add(todo);
return CreatedAtAction(nameof(GetById), new { id = todo.Id }, todo);
}
[HttpPut("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult Update(int id, UpdateTodoRequest request)
{
var todo = _todos.FirstOrDefault(t => t.Id == id);
if (todo is null) return NotFound();
var index = _todos.IndexOf(todo);
_todos[index] = todo with
{
Title = request.Title,
IsComplete = request.IsComplete
};
return NoContent();
}
[HttpDelete("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult Delete(int id)
{
var todo = _todos.FirstOrDefault(t => t.Id == id);
if (todo is null) return NotFound();
_todos.Remove(todo);
return NoContent();
}
}
Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
Testing Your API
Test endpoints using Swagger UI at https://localhost:5001/swagger or with curl:
Testing with curl
# Get all todos
curl https://localhost:5001/todos
# Get specific todo
curl https://localhost:5001/todos/1
# Create todo
curl -X POST https://localhost:5001/todos \
-H "Content-Type: application/json" \
-d '{"title":"New Todo"}'
# Update todo
curl -X PUT https://localhost:5001/todos/1 \
-H "Content-Type: application/json" \
-d '{"title":"Updated Todo","isComplete":true}'
# Delete todo
curl -X DELETE https://localhost:5001/todos/1
Data Access with EF Core
Entity Framework Core is an ORM (Object-Relational Mapper) that maps C# objects to database tables. It works seamlessly with ASP.NET Core for data access patterns.
using Microsoft.EntityFrameworkCore;
public class TodoDb : DbContext
{
public TodoDb(DbContextOptions<TodoDb> options) : base(options) { }
public DbSet<Todo> Todos => Set<Todo>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Todo>(entity =>
{
entity.HasKey(t => t.Id);
entity.Property(t => t.Title).IsRequired().HasMaxLength(200);
});
}
}
// Update Todo record to be an entity
public class Todo
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public bool IsComplete { get; set; }
}
Register DbContext
Program.cs - EF Core Setup
var builder = WebApplication.CreateBuilder(args);
// Register DbContext with SQLite
builder.Services.AddDbContext<TodoDb>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Create database on startup (development only)
if (app.Environment.IsDevelopment())
{
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<TodoDb>();
db.Database.EnsureCreated();
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
// Endpoints with EF Core
app.MapGet("/todos", async (TodoDb db) =>
await db.Todos.ToListAsync());
app.MapGet("/todos/{id}", async (int id, TodoDb db) =>
await db.Todos.FindAsync(id) is Todo todo
? Results.Ok(todo)
: Results.NotFound());
app.MapPost("/todos", async (CreateTodoRequest request, TodoDb db) =>
{
var todo = new Todo { Title = request.Title, IsComplete = false };
db.Todos.Add(todo);
await db.SaveChangesAsync();
return Results.Created($"/todos/{todo.Id}", todo);
});
app.MapPut("/todos/{id}", async (int id, UpdateTodoRequest request, TodoDb db) =>
{
var todo = await db.Todos.FindAsync(id);
if (todo is null) return Results.NotFound();
todo.Title = request.Title;
todo.IsComplete = request.IsComplete;
await db.SaveChangesAsync();
return Results.NoContent();
});
app.MapDelete("/todos/{id}", async (int id, TodoDb db) =>
{
var todo = await db.Todos.FindAsync(id);
if (todo is null) return Results.NotFound();
db.Todos.Remove(todo);
await db.SaveChangesAsync();
return Results.NoContent();
});
app.Run();
Create Migrations
EF Core Migrations
# Create initial migration
dotnet ef migrations add InitialCreate
# Apply migration to database
dotnet ef database update
# Add new migration after model changes
dotnet ef migrations add AddDescriptionField
# Apply updates
dotnet ef database update
LINQ Queries with EF Core
LINQ Queries
// Filter completed todos
app.MapGet("/todos/completed", async (TodoDb db) =>
await db.Todos.Where(t => t.IsComplete).ToListAsync());
// Search by title
app.MapGet("/todos/search", async (string query, TodoDb db) =>
await db.Todos
.Where(t => t.Title.Contains(query))
.ToListAsync());
// Pagination
app.MapGet("/todos/page", async (int page, int pageSize, TodoDb db) =>
{
var todos = await db.Todos
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
var total = await db.Todos.CountAsync();
return new { todos, total, page, pageSize };
});
// Count and aggregates
app.MapGet("/todos/stats", async (TodoDb db) =>
{
var total = await db.Todos.CountAsync();
var completed = await db.Todos.CountAsync(t => t.IsComplete);
var pending = total - completed;
return new { total, completed, pending };
});
EF Core and Native AOT
Entity Framework Core uses reflection extensively. If you enable Native AOT, you'll need careful trimming configuration and rd.xml descriptors. Enable Native AOT only after confirming EF Core and serializers are trim-safe in your application. For most APIs, standard JIT compilation is sufficient.
SQL Server for Production
SQLite is great for development but not recommended for production. For production, use SQL Server, PostgreSQL, or MySQL. Install the appropriate provider (e.g., Microsoft.EntityFrameworkCore.SqlServer) and update your connection string.
Authentication & Authorization Basics
ASP.NET Core supports multiple authentication schemes. We'll implement JWT (JSON Web Token) authentication for API security.
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
// Read JWT settings from configuration
var jwtSettings = builder.Configuration.GetSection("JwtSettings");
var secretKey = jwtSettings["Secret"] ?? throw new InvalidOperationException("JWT Secret not configured");
// Configure JWT authentication
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtSettings["Issuer"],
ValidAudience = jwtSettings["Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(secretKey))
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.Run();
Generate JWT Token
Token Generation
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
record LoginRequest(string Username, string Password);
record LoginResponse(string Token, DateTime Expiration);
app.MapPost("/auth/login", (LoginRequest request, IConfiguration config) =>
{
// Validate credentials (use proper user store in production)
if (request.Username != "admin" || request.Password != "password")
return Results.Unauthorized();
var jwtSettings = config.GetSection("JwtSettings");
var secretKey = jwtSettings["Secret"]!;
var issuer = jwtSettings["Issuer"]!;
var audience = jwtSettings["Audience"]!;
var expirationMinutes = int.Parse(jwtSettings["ExpirationMinutes"]!);
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(ClaimTypes.Name, request.Username),
new Claim(ClaimTypes.NameIdentifier, "user-id-123"),
new Claim(ClaimTypes.Role, "Admin")
};
var expiration = DateTime.UtcNow.AddMinutes(expirationMinutes);
var token = new JwtSecurityToken(
issuer: issuer,
audience: audience,
claims: claims,
expires: expiration,
signingCredentials: credentials
);
var tokenString = new JwtSecurityTokenHandler().WriteToken(token);
return Results.Ok(new LoginResponse(tokenString, expiration));
});
For production applications, consider using OAuth 2.0 and OpenID Connect instead of custom JWT implementation. Use AddOpenIdConnect() or integrate with identity providers like Azure AD, Auth0, or Duende IdentityServer.
Performance & Best Practices
ASP.NET Core 8 includes built-in features for improving API performance and reliability.
builder.Services.AddResponseCaching();
var app = builder.Build();
app.UseResponseCaching();
// Cache for 60 seconds
app.MapGet("/api/cached", (HttpContext context) =>
{
context.Response.Headers.CacheControl = "public,max-age=60";
return new { data = "Cached response", timestamp = DateTime.UtcNow };
});
Memory Cache
In-Memory Caching
using Microsoft.Extensions.Caching.Memory;
builder.Services.AddMemoryCache();
app.MapGet("/api/expensive", async (IMemoryCache cache, TodoDb db) =>
{
const string cacheKey = "todos-stats";
if (!cache.TryGetValue(cacheKey, out object? stats))
{
// Expensive operation
stats = new
{
total = await db.Todos.CountAsync(),
completed = await db.Todos.CountAsync(t => t.IsComplete)
};
// Cache for 5 minutes
cache.Set(cacheKey, stats, TimeSpan.FromMinutes(5));
}
return stats;
});
Health Checks
Health Checks
builder.Services.AddHealthChecks()
.AddDbContextCheck<TodoDb>(); // Check database connectivity
var app = builder.Build();
// Liveness probe - is the app running?
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = _ => false // No checks, just returns 200 if app is running
});
// Readiness probe - is the app ready to serve traffic?
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => true // Run all health checks
});
Kubernetes Integration: Use /health/live for liveness probes (determines if the container should be restarted) and /health/ready for readiness probes (determines if the container can receive traffic).
HTTP/2 and Compression
HTTP/2 Configuration
// Enable response compression
builder.Services.AddResponseCompression(options =>
{
options.EnableForHttps = true;
});
var app = builder.Build();
app.UseResponseCompression();
// HTTP/2 is enabled by default in .NET 8
// Configure Kestrel for production if needed
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxConcurrentConnections = 100;
options.Limits.MaxRequestBodySize = 10 * 1024 * 1024; // 10MB
});
Logging Best Practices
Structured Logging
// Configure logging levels in appsettings.json
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Logging.AddDebug();
// Use structured logging
app.MapPost("/todos", async (CreateTodoRequest request, TodoDb db, ILogger<Program> logger) =>
{
logger.LogInformation("Creating todo: {Title}", request.Title);
var todo = new Todo { Title = request.Title };
db.Todos.Add(todo);
await db.SaveChangesAsync();
logger.LogInformation("Created todo with ID: {TodoId}", todo.Id);
return Results.Created($"/todos/{todo.Id}", todo);
});
Production Observability
For production, integrate with observability platforms like Application Insights, Datadog, or Grafana. Use structured logging with Serilog for better log aggregation and querying.
Migration from ASP.NET Core 7
Upgrading from ASP.NET Core 7 to 8 is straightforward with minimal breaking changes.
Migration Checklist
✅ Update TargetFramework to net8.0
✅ Update all NuGet packages to 8.0.x versions
✅ Update Swagger/Swashbuckle packages if used
✅ Update EF Core packages to 8.0.x
✅ Verify middleware order (especially authentication)
Hosting: Some WebHost APIs deprecated in favor of WebApplication.
Testing After Migration
Run your full test suite after upgrading. Pay special attention to authentication flows, JSON serialization edge cases, and middleware pipeline behavior. Most applications upgrade without code changes.
Update NuGet Packages
Update Packages
# Update all packages to latest
dotnet list package --outdated
dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version 8.0.0
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version 8.0.0
# Or update in .csproj and restore
dotnet restore
Next Steps & Resources
You've learned ASP.NET Core fundamentals. Here's where to go next:
git clone https://github.com/dotnet-guide-com/tutorials.git
cd tutorials/aspnet-core
# Run basic Todo API (minimal APIs)
cd TodoApiBasic
dotnet run
# Run advanced Todo API (EF Core + JWT)
cd ../TodoApiEfJwt
dotnet run
Each project includes a README with setup instructions and API documentation.
Use controllers when you need filters, model binders, formatters, or lots of conventional endpoints. Minimal APIs work best for small services, internal tools, and edge gateways. Hybrid approaches are common—use minimal APIs for health/auth/token endpoints and controllers for the rest of your API.
How do I enable HTTPS locally and in production?
Locally, run dotnet dev-certs https --trust to generate and trust a development certificate. In production, use certificates from Let's Encrypt, Azure Key Vault, or your cloud provider. Configure Kestrel with UseHttps() and point to your certificate file or use your cloud platform's built-in SSL termination.
What's the easiest way to add CORS?
Add services.AddCors() in Program.cs, define a policy with builder.WithOrigins("https://yourfrontend.com"), and call app.UseCors("PolicyName") before UseAuthorization(). For development, you can use AllowAnyOrigin(), but always restrict origins in production for security.
How do I version APIs?
Use route prefixes like MapGroup("/v1") for minimal APIs or [Route("api/v1/[controller]")] for controllers. For more sophisticated versioning with URL path, query string, or header-based versioning, add the Asp.Versioning.Http package and configure version policies in your services.
How do I deploy to Azure App Service quickly?
Use Visual Studio's publish wizard (right-click project → Publish → Azure) or run az webapp up --name myapp --resource-group mygroup --runtime "DOTNETCORE:8.0" from the Azure CLI. For CI/CD, configure GitHub Actions with the Azure Web App deploy action pointing to your publish profile.
Is Native AOT worth it for APIs?
Often no. Native AOT reduces startup time and memory footprint but adds complexity and limits reflection-heavy features like Entity Framework Core. Measure first with dotnet-counters and BenchmarkDotNet. For most APIs, standard JIT compilation performs well enough, especially with .NET 8's performance improvements.
Should I use SQLite or SQL Server for development?
SQLite is great for local development—zero setup, file-based, and fast. Use SQL Server (or SQL Server LocalDB) if your production database is SQL Server and you need to test vendor-specific features like stored procedures or full-text search. For team development, consider running SQL Server in Docker for consistency across environments.
How do I test ASP.NET Core APIs?
Use WebApplicationFactory<Program> from the Microsoft.AspNetCore.Mvc.Testing package for integration tests. This creates an in-memory test server. For unit tests, inject mock dependencies into controllers or test minimal API lambdas directly. Use xUnit, NUnit, or MSTest as your test framework.