✨ Hands-On Tutorial

ASP.NET Core Fundamentals: Build Web APIs on .NET 8

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.

Install .NET 8 SDK

Download from dotnet.microsoft.com/download and verify installation:

Terminal
dotnet --version
# Should show 8.0.x or higher

Create a New Web API Project

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
  • Filters: Action filters, authorization filters, exception filters
  • Model binding: Complex binding from headers, route, query, and body
  • Content negotiation: Multiple response formats (JSON, XML, custom)

Configuration & Appsettings

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:

  • appsettings.json
  • appsettings.{Environment}.json (e.g., appsettings.Development.json)
  • User secrets (in Development only)
  • Environment variables
  • Command-line arguments

Later sources override earlier ones, so environment variables override JSON files.

Appsettings.json Structure

appsettings.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "DefaultConnection": "Data Source=todos.db"
  },
  "JwtSettings": {
    "Secret": "your-secret-key-min-32-chars-long",
    "Issuer": "TodoApi",
    "Audience": "TodoClient",
    "ExpirationMinutes": 60
  },
  "ApiOptions": {
    "MaxPageSize": 100,
    "DefaultPageSize": 20,
    "EnableCaching": true
  }
}

Binding Configuration to POCOs

Configuration Binding
// Define POCO
public class JwtSettings
{
    public string Secret { get; set; } = string.Empty;
    public string Issuer { get; set; } = string.Empty;
    public string Audience { get; set; } = string.Empty;
    public int ExpirationMinutes { get; set; }
}

// In Program.cs
builder.Services.Configure<JwtSettings>(
    builder.Configuration.GetSection("JwtSettings"));

// Inject and use
app.MapGet("/config", (IOptions<JwtSettings> options) =>
{
    var settings = options.Value;
    return new { 
        issuer = settings.Issuer, 
        expiration = settings.ExpirationMinutes 
    };
});

Reading Configuration Directly

Direct Configuration Access
// Read connection string
var connectionString = builder.Configuration
    .GetConnectionString("DefaultConnection");

// Read specific value
var maxPageSize = builder.Configuration
    .GetValue<int>("ApiOptions:MaxPageSize");

// Read section
var jwtSettings = builder.Configuration
    .GetSection("JwtSettings")
    .Get<JwtSettings>();

Environment-Specific Configuration

appsettings.Development.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Debug",
      "Microsoft.AspNetCore": "Information"
    }
  },
  "ConnectionStrings": {
    "DefaultConnection": "Data Source=todos-dev.db"
  },
  "JwtSettings": {
    "Secret": "development-secret-key-at-least-32-chars"
  }
}
Tip: User Secrets for Development

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:

Environment Variables
# Linux/macOS
export JwtSettings__Secret="production-secret-key"
export ConnectionStrings__DefaultConnection="Server=prod;Database=todos"

# Windows PowerShell
$env:JwtSettings__Secret="production-secret-key"
$env:ConnectionStrings__DefaultConnection="Server=prod;Database=todos"

Dependency Injection & Services

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:

Middleware Pipeline
var app = builder.Build();

// 1. Exception handling (should be first)
if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}
else
{
    app.UseExceptionHandler("/error");
}

// 2. HTTPS redirection
app.UseHttpsRedirection();

// 3. Static files (if serving them)
app.UseStaticFiles();

// 4. Routing
app.UseRouting();

// 5. CORS (before auth)
app.UseCors("AllowAll");

// 6. Authentication
app.UseAuthentication();

// 7. Authorization
app.UseAuthorization();

// 8. Custom middleware
app.UseMiddleware<RequestLoggingMiddleware>();

// 9. Endpoints (should be last)
app.MapControllers();

app.Run();

Custom Middleware

Request Logging Middleware
public class RequestLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestLoggingMiddleware> _logger;

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

    public async Task InvokeAsync(HttpContext context)
    {
        var start = DateTime.UtcNow;
        
        _logger.LogInformation(
            "Request: {Method} {Path}",
            context.Request.Method,
            context.Request.Path);

        await _next(context);

        var duration = DateTime.UtcNow - start;
        _logger.LogInformation(
            "Response: {StatusCode} in {Duration}ms",
            context.Response.StatusCode,
            duration.TotalMilliseconds);
    }
}

Inline Middleware with Use

Inline Middleware
// Add request ID header
app.Use(async (context, next) =>
{
    context.Response.Headers.Add("X-Request-Id", Guid.NewGuid().ToString());
    await next();
});

// Add timing header
app.Use(async (context, next) =>
{
    var start = DateTime.UtcNow;
    await next();
    var duration = DateTime.UtcNow - start;
    context.Response.Headers.Add("X-Duration", duration.TotalMilliseconds.ToString());
});

Short-Circuiting with Run and Map

Terminal Middleware
// Run - terminal middleware (doesn't call next)
app.Run(async context =>
{
    await context.Response.WriteAsync("End of pipeline");
});

// Map - branch pipeline based on path
app.Map("/health", healthApp =>
{
    healthApp.Run(async context =>
    {
        await context.Response.WriteAsync("Healthy");
    });
});

// MapWhen - conditional branching
app.MapWhen(
    context => context.Request.Path.StartsWithSegments("/api"),
    apiApp =>
    {
        apiApp.Use(async (context, next) =>
        {
            context.Response.Headers.Add("X-API-Version", "1.0");
            await next();
        });
    });

Route Constraints

Route Constraints
// Integer constraint
app.MapGet("/users/{id:int}", (int id) => $"User {id}");

// Minimum value
app.MapGet("/pages/{page:int:min(1)}", (int page) => $"Page {page}");

// String length
app.MapGet("/codes/{code:length(5)}", (string code) => $"Code: {code}");

// Regex
app.MapGet("/phone/{number:regex(^\\d{{3}}-\\d{{4}}$)}", 
    (string number) => $"Phone: {number}");

// Multiple constraints
app.MapGet("/orders/{year:int:range(2020,2030)}/{id:guid}", 
    (int year, Guid id) => new { year, id });

Build Your First API: Todo App

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.

Install EF Core Packages

Install Packages
# SQLite provider (for local development)
dotnet add package Microsoft.EntityFrameworkCore.Sqlite

# Design tools (for migrations)
dotnet add package Microsoft.EntityFrameworkCore.Design

# Install EF CLI tool globally
dotnet tool install --global dotnet-ef

Define DbContext

TodoDb.cs
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.

Install JWT Package

Install JWT Bearer
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

Configure JWT Authentication

Program.cs - JWT Setup
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));
});

Protect Endpoints with [Authorize]

Protected Endpoints
using Microsoft.AspNetCore.Authorization;

// Public endpoint
app.MapGet("/", () => "Public endpoint");

// Protected endpoint
app.MapGet("/secure", [Authorize] () => "Secure endpoint - authenticated!")
    .RequireAuthorization();

// Role-based authorization
app.MapGet("/admin", [Authorize(Roles = "Admin")] () => "Admin only")
    .RequireAuthorization();

// Policy-based authorization
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("RequireAdminRole", policy => 
        policy.RequireRole("Admin"));
});

app.MapGet("/admin-policy", () => "Admin via policy")
    .RequireAuthorization("RequireAdminRole");

Testing Authentication

Testing with curl
# 1. Get token
TOKEN=$(curl -X POST https://localhost:5001/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"password"}' \
  | jq -r '.token')

# 2. Access protected endpoint
curl https://localhost:5001/secure \
  -H "Authorization: Bearer $TOKEN"
OAuth and OpenID Connect

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.

Rate Limiting

Rate Limiting Setup
using System.Threading.RateLimiting;

builder.Services.AddRateLimiter(options =>
{
    // Fixed window: 100 requests per minute
    options.AddFixedWindowLimiter("fixed", limiterOptions =>
    {
        limiterOptions.Window = TimeSpan.FromMinutes(1);
        limiterOptions.PermitLimit = 100;
        limiterOptions.QueueLimit = 0;
    });

    // Sliding window: 50 requests per 30 seconds
    options.AddSlidingWindowLimiter("sliding", limiterOptions =>
    {
        limiterOptions.Window = TimeSpan.FromSeconds(30);
        limiterOptions.PermitLimit = 50;
        limiterOptions.SegmentsPerWindow = 3;
    });
});

var app = builder.Build();

app.UseRateLimiter();

// Apply rate limit to endpoint
app.MapGet("/api/data", () => "Rate limited data")
    .RequireRateLimiting("fixed");

Response Caching

Response Caching
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)
  • ✅ Test JSON serialization options
  • ✅ Run full test suite
  • ✅ Update CI/CD pipelines to .NET 8 SDK

Project File Changes

Project.csproj (ASP.NET Core 7)
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net7.0</TargetFramework>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.15" />
    <PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
  </ItemGroup>
</Project>
Project.csproj (ASP.NET Core 8)
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.0" />
    <PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
  </ItemGroup>
</Project>

Program.cs Differences

Most Program.cs code works unchanged. Key improvements in .NET 8:

  • Better MapGroup support with shared route prefixes
  • Improved OpenAPI/Swagger integration
  • Enhanced rate limiting middleware
  • Better AOT compatibility (if enabled)

Breaking Changes

ASP.NET Core 8 has minimal breaking changes. Notable items:

  • Authentication: Some legacy authentication schemes removed. Verify handler names match documentation.
  • JSON options: Serialization defaults improved. Review custom JSON converters.
  • 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:

Related Tutorials on dotnet-guide.com

Official Documentation

GitHub Example Projects

Clone and run complete Todo API examples:

Clone Repository
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.

Community Resources

  • .NET Blog: devblogs.microsoft.com/dotnet
  • ASP.NET Community Standup: Weekly YouTube livestream on ASP.NET Core
  • Stack Overflow: Tag questions with asp.net-core and asp.net-core-8.0
  • Reddit: r/dotnet community

Deployment Options

  • Azure App Service: Simplest cloud deployment with built-in scaling
  • Docker: Containerize with official .NET images for Kubernetes or Docker Compose
  • AWS: Deploy to Elastic Beanstalk, ECS, or Lambda
  • Self-hosted: Run on Linux with Nginx or Windows with IIS

Frequently Asked Questions

When should I pick controllers over minimal APIs?

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.