ASP.NET Core
Complete Guide
From project structure and dependency injection through middleware pipelines, controllers, EF Core, authentication, and deployment. A comprehensive reference covering every essential concept from basic to intermediate .NET Core development.
What is .NET Core?
.NET Core (now simply called .NET from version 5 onwards) is Microsoft's open-source, cross-platform successor to the Windows-only .NET Framework. ASP.NET Core is the web framework built on top of it — used for building APIs, web apps, gRPC services, and real-time applications.
Project Structure
A typical ASP.NET Core Web API project follows a convention-based structure. Understanding each file's role is essential before writing any code.
MyApi/ ├── Controllers/ // API Controllers (MVC pattern) │ ├── ProductsController.cs │ └── UsersController.cs ├── Models/ // Domain entities / DTOs │ ├── Product.cs │ └── CreateProductRequest.cs ├── Services/ // Business logic layer │ ├── IProductService.cs │ └── ProductService.cs ├── Data/ // EF Core DbContext and repositories │ ├── AppDbContext.cs │ └── Migrations/ ├── Middleware/ // Custom middleware classes │ └── RequestLoggingMiddleware.cs ├── Extensions/ // IServiceCollection extension methods │ └── ServiceCollectionExtensions.cs ├── appsettings.json // Default configuration ├── appsettings.Development.json ├── Program.cs // Entry point + DI + middleware setup └── MyApi.csproj // Project file (SDK, packages, TFM)
<Project Sdk="Microsoft.NET.Sdk.Web"> <PropertyGroup> <TargetFramework>net8.0</TargetFramework> <Nullable>enable</Nullable> <!-- Nullable reference types --> <ImplicitUsings>enable</ImplicitUsings> <!-- Auto-include common namespaces --> <InvariantGlobalization>true</InvariantGlobalization> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" /> </ItemGroup> </Project>
Program.cs & Host Builder
In .NET 6+, Program.cs is the single entry point. It uses the top-level statements and minimal hosting model — no separate Startup class needed. The WebApplicationBuilder configures services and the WebApplication configures the middleware pipeline.
// ─── 1. Create builder ────────────────────────────────────────── var builder = WebApplication.CreateBuilder(args); // ─── 2. Register Services (Dependency Injection container) ────── builder.Services.AddControllers(); // MVC Controllers builder.Services.AddEndpointsApiExplorer(); // Required for Swagger builder.Services.AddSwaggerGen(); // OpenAPI docs builder.Services.AddDbContext<AppDbContext>(opt => opt.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); builder.Services.AddScoped<IProductService, ProductService>(); builder.Services.AddSingleton<ICacheService, MemoryCacheService>(); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { /* configure JWT */ }); builder.Services.AddAuthorization(); builder.Services.AddMemoryCache(); builder.Services.AddResponseCompression(); builder.Services.AddHealthChecks(); // ─── 3. Build the application ─────────────────────────────────── var app = builder.Build(); // ─── 4. Configure Middleware Pipeline ─────────────────────────── if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); // HTTP → HTTPS redirect app.UseResponseCompression(); // Gzip/Brotli responses app.UseStaticFiles(); // Serve wwwroot files app.UseRouting(); // Endpoint routing middleware app.UseCors("AllowAll"); // CORS (MUST be after UseRouting) app.UseAuthentication(); // Who are you? app.UseAuthorization(); // What can you do? app.UseMiddleware<RequestLoggingMiddleware>(); // Custom middleware app.MapControllers(); // Map controller routes app.MapHealthChecks("/health"); // Health endpoint // ─── 5. Run ───────────────────────────────────────────────────── app.Run();
Before .NET 6, you had a separate Startup.cs with ConfigureServices() and Configure() methods. These are now merged into Program.cs. Both patterns work in .NET 8, but the minimal hosting model is the standard going forward.
DI Fundamentals
Dependency Injection is built into ASP.NET Core's foundation. Instead of creating objects directly, you declare what you need (via constructor parameters) and the DI container provides them. This enables loose coupling, testability, and clean separation of concerns.
public class OrderController { public Process() { // Tightly coupled — hard to test var db = new SqlDatabase(); var svc = new OrderService(db); svc.Process(); } }
public class OrderController(IOrderService svc) { public Process() { // Loosely coupled — testable svc.Process(); } // DI container injects IOrderService }
// 1. Define interface public interface IEmailService { Task SendAsync(string to, string subject, string body); } // 2. Implement it public class SmtpEmailService(IConfiguration config) : IEmailService { public async Task SendAsync(string to, string subject, string body) { // send via SMTP using config["Smtp:Host"] } } // 3. Register in Program.cs builder.Services.AddScoped<IEmailService, SmtpEmailService>(); // 4. Inject wherever needed (constructor injection — preferred) public class UserController( IEmailService emailService, IUserService userService, ILogger<UserController> logger) // Primary constructor syntax (C# 12) { // all dependencies available via this.emailService etc. }
Service Lifetimes
Every registered service has a lifetime — it controls how long the instance lives and how it is shared. Choosing the wrong lifetime is a common source of bugs.
| Lifetime | Created | Shared? | Use For | Register |
|---|---|---|---|---|
| Singleton | Once, app lifetime | All requests + threads | Cache, config, HTTP clients | AddSingleton<> |
| Scoped | Once per HTTP request | Within same request | DbContext, per-request services | AddScoped<> |
| Transient | Every injection | Never | Lightweight, stateless services | AddTransient<> |
Never inject a Scoped or Transient service into a Singleton. The Singleton lives forever — the scoped instance it captures becomes stale and can cause concurrency bugs or memory leaks. ASP.NET Core will throw at startup in Development mode if it detects this.
// Singleton — shared across all requests builder.Services.AddSingleton<IMemoryCache, MemoryCache>(); builder.Services.AddSingleton<AppSettings>(); // Scoped — new instance per HTTP request (same instance within a request) builder.Services.AddScoped<IOrderService, OrderService>(); builder.Services.AddDbContext<AppDbContext>(opts => ...); // Scoped by default // Transient — new instance every time it's requested from container builder.Services.AddTransient<IEmailSender, SmtpEmailSender>(); // Factory registration — use when construction needs logic builder.Services.AddScoped<IReportService>(sp => { var config = sp.GetRequiredService<IConfiguration>(); return config["ReportType"] == "PDF" ? new PdfReportService() : new ExcelReportService(); });
Advanced DI Patterns
// ── Keyed Services (.NET 8 new feature) ────────────────────────── builder.Services.AddKeyedScoped<IStorageService, LocalStorageService>("local"); builder.Services.AddKeyedScoped<IStorageService, AzureBlobService>("azure"); // Inject with [FromKeyedServices] attribute public class FileController( [FromKeyedServices("azure")] IStorageService storage) { } // ── IEnumerable injection — get all implementations ─────────────── builder.Services.AddScoped<IValidator, EmailValidator>(); builder.Services.AddScoped<IValidator, PhoneValidator>(); builder.Services.AddScoped<IValidator, AgeValidator>(); public class ValidationService(IEnumerable<IValidator> validators) { public bool ValidateAll(object model) => validators.All(v => v.Validate(model)); } // ── Extension method for clean Program.cs ───────────────────────── public static class ServiceExtensions { public static IServiceCollection AddApplicationServices( this IServiceCollection services, IConfiguration config) { services.AddScoped<IProductService, ProductService>(); services.AddScoped<IOrderService, OrderService>(); services.Configure<EmailOptions>(config.GetSection("Email")); return services; } } // In Program.cs: builder.Services.AddApplicationServices(builder.Configuration);
Configuration System
ASP.NET Core has a layered configuration system. Sources are merged in order — later sources override earlier ones. The final merged result is available via IConfiguration.
{
"ConnectionStrings": {
"DefaultConnection": "Server=.;Database=MyDb;Trusted_Connection=true"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Email": {
"SmtpHost": "smtp.example.com",
"SmtpPort": 587,
"SenderAddress": "noreply@example.com",
"EnableSsl": true
},
"Jwt": {
"Key": "SUPER_SECRET_KEY_MIN_256_BITS",
"Issuer": "https://myapi.com",
"Audience": "https://myapi.com",
"ExpiryMinutes": 60
},
"AllowedHosts": "*"
}
// Direct access (string keys) var host = builder.Configuration["Email:SmtpHost"]; var connStr = builder.Configuration.GetConnectionString("DefaultConnection"); // Strongly-typed section binding var emailSettings = builder.Configuration .GetSection("Email") .Get<EmailOptions>(); // In a service via IConfiguration public class EmailService(IConfiguration config) { private readonly string _host = config["Email:SmtpHost"]!; }
Options Pattern
The Options pattern is the recommended way to consume configuration. It provides strongly-typed access with validation and change-notification support.
// 1. Define options class public class EmailOptions { public const string Section = "Email"; [Required] public string SmtpHost { get; set; } = string.Empty; [Range(1, 65535)] public int SmtpPort { get; set; } = 587; public bool EnableSsl { get; set; } = true; } // 2. Register with validation builder.Services .AddOptions<EmailOptions>() .BindConfiguration(EmailOptions.Section) .ValidateDataAnnotations() // Validate on startup .ValidateOnStart(); // Fail fast if invalid // 3. Inject and use public class EmailService(IOptions<EmailOptions> opts) { private readonly EmailOptions _opts = opts.Value; // IOptions — singleton, doesn't reflect runtime changes // IOptionsSnapshot — scoped, reloads per request // IOptionsMonitor — singleton + reacts to file changes }
Secrets & Environment Variables
# Initialize secrets storage for project dotnet user-secrets init # Set secrets (stored outside project directory) dotnet user-secrets set "Jwt:Key" "my-256-bit-secret-key-here-for-dev" dotnet user-secrets set "ConnectionStrings:DefaultConnection" "Server=..." # Secrets are auto-loaded in Development environment # Access exactly the same as appsettings.json values
# Use __ (double underscore) as section separator export ConnectionStrings__DefaultConnection="Server=prod-db;..." export Jwt__Key="production-secret-key" export Email__SmtpHost="smtp.sendgrid.net" # In Docker Compose: environment: - ConnectionStrings__DefaultConnection=Server=db;Database=MyDb;... - ASPNETCORE_ENVIRONMENT=Production
Configuration Priority (highest to lowest)
- Command-line arguments —
--Jwt:Key=secret - Environment variables — override appsettings values
- User Secrets — Development only, not in source control
- appsettings.{Environment}.json — per-environment overrides
- appsettings.json — base defaults
The Middleware Pipeline
Middleware is software assembled into the request pipeline to handle requests and responses. Each middleware component can execute before and after the next component — forming a bidirectional pipeline. This is the most important architectural concept in ASP.NET Core.
Each middleware receives a RequestDelegate next — calling await next(context) passes the request to the next middleware. Not calling it short-circuits the pipeline (e.g. static files, auth failures). This bidirectional flow means you can execute logic both before and after downstream processing.
Built-in Middleware Reference
| Middleware | Method | Purpose |
|---|---|---|
| Exception Handler | UseExceptionHandler() | Catches unhandled exceptions, returns error response |
| HSTS | UseHsts() | Adds Strict-Transport-Security header (prod only) |
| HTTPS Redirect | UseHttpsRedirection() | Redirects HTTP requests to HTTPS |
| Static Files | UseStaticFiles() | Serves files from wwwroot, short-circuits pipeline |
| Routing | UseRouting() | Matches URL to endpoint, required before UseCors/UseAuth |
| CORS | UseCors() | Sets Access-Control-* headers for cross-origin requests |
| Authentication | UseAuthentication() | Reads auth token/cookie, populates HttpContext.User |
| Authorization | UseAuthorization() | Evaluates [Authorize] policies, returns 401/403 |
| Response Compression | UseResponseCompression() | Gzip/Brotli compresses responses |
| Response Caching | UseResponseCaching() | Cache responses server-side |
| Rate Limiting | UseRateLimiter() | .NET 7+ built-in rate limiting |
| Request Timeout | UseRequestTimeouts() | .NET 8 per-endpoint timeouts |
| Health Checks | MapHealthChecks() | /health endpoint for orchestrators |
| Output Cache | UseOutputCache() | .NET 7+ improved response caching |
Custom Middleware
You can write custom middleware as a class (recommended for complex logic) or inline using Use()/Run()/Map().
public class RequestLoggingMiddleware { private readonly RequestDelegate _next; private readonly ILogger<RequestLoggingMiddleware> _logger; public RequestLoggingMiddleware( RequestDelegate next, ILogger<RequestLoggingMiddleware> logger) // DI works here { _next = next; _logger = logger; } public async Task InvokeAsync(HttpContext context) { // ── Before next middleware ── var sw = Stopwatch.StartNew(); _logger.LogInformation("→ {Method} {Path}", context.Request.Method, context.Request.Path); try { await _next(context); // Call next middleware } finally { // ── After next middleware (response phase) ── sw.Stop(); _logger.LogInformation("← {Status} {Path} in {Ms}ms", context.Response.StatusCode, context.Request.Path, sw.ElapsedMilliseconds); } } } // Register in Program.cs app.UseMiddleware<RequestLoggingMiddleware>();
// Use — passes to next, can also short-circuit app.Use(async (context, next) => { context.Response.Headers.Append("X-Request-Id", Guid.NewGuid().ToString()); await next(context); // call next; omit to short-circuit }); // Run — terminal middleware (never calls next) app.Run(async context => { await context.Response.WriteAsync("Hello from terminal!"); }); // Map — branch pipeline based on path app.Map("/admin", adminApp => { adminApp.UseMiddleware<AdminAuthMiddleware>(); adminApp.Run(async ctx => await ctx.Response.WriteAsync("Admin area")); }); // MapWhen — conditional branching app.MapWhen( ctx => ctx.Request.Query.ContainsKey("version"), branchApp => branchApp.UseMiddleware<VersionMiddleware>() );
public class CorrelationIdMiddleware(RequestDelegate next) { private const string CorrelationIdHeader = "X-Correlation-Id"; public async Task InvokeAsync(HttpContext context) { // Use existing ID or generate new one var correlationId = context.Request.Headers[CorrelationIdHeader].FirstOrDefault() ?? Guid.NewGuid().ToString(); // Store in HttpContext.Items for access downstream context.Items["CorrelationId"] = correlationId; // Echo back in response header context.Response.OnStarting(() => { context.Response.Headers.TryAdd(CorrelationIdHeader, correlationId); return Task.CompletedTask; }); await next(context); } }
Order Matters
Middleware order is critical. The request flows through middleware in the order they are added; the response unwinds in reverse. Getting the order wrong causes subtle bugs — authentication failures, CORS errors, missing headers.
UseAuthentication after UseAuthorization → User is never set, all [Authorize] endpoints return 401. UseCors before UseRouting → CORS policy is applied to all requests, not just matched endpoints. UseStaticFiles after UseRouting → Static file requests hit the router and fail if no route matches.
// ✅ CORRECT ORDER — Production ASP.NET Core 8 app.UseExceptionHandler("/error"); // 1. First — catches everything below app.UseHsts(); // 2. Security headers app.UseHttpsRedirection(); // 3. Protocol enforcement app.UseResponseCompression(); // 4. Before static files helps compression app.UseStaticFiles(); // 5. Before routing — short-circuit assets app.UseRouting(); // 6. Match routes (MUST come before CORS/Auth) app.UseRateLimiter(); // 7. Rate limit (after routing, before auth) app.UseCors(); // 8. CORS (after UseRouting) app.UseOutputCache(); // 9. Cache (after routing) app.UseAuthentication(); // 10. Identify user (BEFORE authorization) app.UseAuthorization(); // 11. Check permissions (AFTER authentication) app.UseMiddleware<CorrelationIdMiddleware>(); // 12. Custom (after auth) app.MapControllers(); // 13. Last — execute endpoint app.MapHealthChecks("/health");
Routing System
[ApiController] [Route("api/[controller]")] // → api/products public class ProductsController : ControllerBase { // GET api/products [HttpGet] public IActionResult GetAll() => Ok(); // GET api/products/42 [HttpGet("{id:int}")] // route constraint: must be int public IActionResult GetById(int id) => Ok(); // GET api/products/search?q=laptop&maxPrice=500 [HttpGet("search")] public IActionResult Search(string q, decimal? maxPrice) => Ok(); // POST api/products [HttpPost] public IActionResult Create([FromBody] CreateProductRequest req) => Ok(); // PUT api/products/42 [HttpPut("{id:int}")] public IActionResult Update(int id, [FromBody] UpdateProductRequest req) => Ok(); // DELETE api/products/42 [HttpDelete("{id:int}")] public IActionResult Delete(int id) => Ok(); // GET api/products/42/reviews [HttpGet("{id:int}/reviews")] // nested resource public IActionResult GetReviews(int id) => Ok(); } // Route Constraints: {id:int} {slug:alpha} {date:datetime} // {id:guid} {id:min(1)} {name:maxlength(50)}
Controllers in Depth
[ApiController] [Route("api/[controller]")] [Produces("application/json")] public class ProductsController( IProductService productService, ILogger<ProductsController> logger) : ControllerBase { /// GET api/products?page=1&size=20 [HttpGet] [ProducesResponseType(typeof(PagedResult<ProductDto>), StatusCodes.Status200OK)] public async Task<IActionResult> GetAll(int page = 1, int size = 20) { var result = await productService.GetPagedAsync(page, size); return Ok(result); } /// GET api/products/42 [HttpGet("{id:int}", Name = "GetProduct")] [ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<IActionResult> GetById(int id) { var product = await productService.GetByIdAsync(id); if (product is null) return NotFound(); return Ok(product); } /// POST api/products [HttpPost] [ProducesResponseType(typeof(ProductDto), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task<IActionResult> Create([FromBody] CreateProductRequest request) { // ModelState is auto-validated by [ApiController] var created = await productService.CreateAsync(request); return CreatedAtRoute("GetProduct", new { id = created.Id }, created); } /// PUT api/products/42 [HttpPut("{id:int}")] [Authorize] public async Task<IActionResult> Update(int id, [FromBody] UpdateProductRequest req) { var success = await productService.UpdateAsync(id, req); return success ? NoContent() : NotFound(); } /// DELETE api/products/42 [HttpDelete("{id:int}")] [Authorize(Roles = "Admin")] public async Task<IActionResult> Delete(int id) { var success = await productService.DeleteAsync(id); return success ? NoContent() : NotFound(); } }
Adding [ApiController] unlocks automatic model validation (returns 400 without entering action), binding source inference ([FromBody] for complex types, [FromRoute] for route params), and problem details responses for validation errors.
Action Results
| Method | Status | Use When |
|---|---|---|
Ok(data) | 200 | Successful GET or operation with data |
Created(uri, data) / CreatedAtRoute() | 201 | Resource created — include location header |
NoContent() | 204 | Successful PUT/DELETE with no response body |
BadRequest() / BadRequest(errors) | 400 | Invalid input, validation failure |
Unauthorized() | 401 | Not authenticated |
Forbid() | 403 | Authenticated but not authorized |
NotFound() | 404 | Resource not found |
Conflict() | 409 | Duplicate resource, optimistic concurrency failure |
UnprocessableEntity() | 422 | Business validation failure |
Problem() | varies | RFC 7807 problem details response |
StatusCode(code, data) | any | Custom status code |
// TypedResults provides compile-time type safety for OpenAPI [HttpGet("{id:int}")] public async Task<Results<Ok<ProductDto>, NotFound>> GetById(int id) { var product = await _service.GetByIdAsync(id); return product is null ? TypedResults.NotFound() : TypedResults.Ok(product); }
Model Binding
public IActionResult Example( [FromRoute] int id, // From URL path: /products/{id} [FromQuery] string? search, // From query string: ?search=laptop [FromBody] CreateRequest body, // From JSON request body [FromHeader(Name="X-Api-Key")] string key, // From request header [FromForm] IFormFile file, // From multipart/form-data [FromServices] IEmailService email) // From DI container (injected) { ... } // Custom complex type from multiple sources public record SearchQuery( [FromQuery] string? Q, [FromQuery] int Page = 1, [FromQuery] int Size = 20, [FromQuery] string? SortBy = "name" );
Model Validation
// ── Data Annotations (built-in) ────────────────────────────────── public class CreateProductRequest { [Required(ErrorMessage = "Name is required")] [StringLength(100, MinimumLength = 3)] public string Name { get; set; } = ""; [Range(0.01, 99999.99)] public decimal Price { get; set; } [EmailAddress] public string? ContactEmail { get; set; } [RegularExpression(@"^[A-Z]{2}\d{4}$")] public string Sku { get; set; } = ""; } // ── FluentValidation (NuGet: FluentValidation.AspNetCore) ───────── public class CreateProductValidator : AbstractValidator<CreateProductRequest> { public CreateProductValidator() { RuleFor(x => x.Name) .NotEmpty() .Length(3, 100) .MustAsync(async (name, ct) => !await NameExistsAsync(name)); RuleFor(x => x.Price) .GreaterThan(0) .LessThanOrEqualTo(99999); RuleFor(x => x.Sku) .Matches(@"^[A-Z]{2}\d{4}$") .WithMessage("SKU must be 2 uppercase letters followed by 4 digits"); } } // Register: builder.Services.AddValidatorsFromAssemblyContaining<CreateProductValidator>();
Minimal API Basics
Minimal APIs (introduced in .NET 6) let you define HTTP endpoints without controllers. Ideal for microservices, small APIs, and when you want to avoid MVC overhead.
var builder = WebApplication.CreateBuilder(args); builder.Services.AddScoped<IProductService, ProductService>(); var app = builder.Build(); // Simple GET app.MapGet("/hello", () => "Hello World!"); // With dependency injection app.MapGet("/products", async (IProductService svc) => Results.Ok(await svc.GetAllAsync())); // With route parameter + query string + DI app.MapGet("/products/{id:int}", async ( int id, IProductService svc) => { var product = await svc.GetByIdAsync(id); return product is null ? Results.NotFound() : Results.Ok(product); }); // POST with body app.MapPost("/products", async ( [FromBody] CreateProductRequest request, IProductService svc) => { var created = await svc.CreateAsync(request); return Results.CreatedAtRoute("GetProduct", new { id = created.Id }, created); }).WithName("CreateProduct").WithOpenApi(); // Auth + metadata app.MapDelete("/products/{id:int}", async (int id, IProductService svc) => { var success = await svc.DeleteAsync(id); return success ? Results.NoContent() : Results.NotFound(); }) .RequireAuthorization("AdminPolicy") .WithTags("Products"); app.Run();
Filters & Route Groups
// Group related endpoints — share prefix, auth, tags var products = app.MapGroup("/api/products") .WithTags("Products") .RequireAuthorization() // All endpoints in group require auth .AddEndpointFilter<ValidationFilter>(); // Apply filter to group products.MapGet("/", async (IProductService s) => await s.GetAllAsync()); products.MapGet("/{id:int}", async (int id, IProductService s) => await s.GetByIdAsync(id)); products.MapPost("/", async (CreateProductRequest r, IProductService s) => await s.CreateAsync(r)); // Endpoint filter (like Action Filters for minimal APIs) public class ValidationFilter : IEndpointFilter { public async ValueTask<object?> InvokeAsync( EndpointFilterInvocationContext ctx, EndpointFilterDelegate next) { // Validate before execution var validator = ctx.HttpContext.RequestServices .GetService<IValidator<CreateProductRequest>>(); if (validator is not null) { var body = ctx.GetArgument<CreateProductRequest>(0); var result = await validator.ValidateAsync(body); if (!result.IsValid) return Results.ValidationProblem(result.ToDictionary()); } return await next(ctx); } } // Separate the endpoints into a static class for organization public static class ProductEndpoints { public static IEndpointRouteBuilder MapProductEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/api/products"); group.MapGet("/", GetAll); group.MapGet("/{id}", GetById); return app; } private static async Task<IResult> GetAll(IProductService svc) => Results.Ok(await svc.GetAllAsync()); private static async Task<IResult> GetById(int id, IProductService svc) => await svc.GetByIdAsync(id) is {} p ? Results.Ok(p) : Results.NotFound(); }
Entity Framework Core
EF Core is the ORM for .NET. It maps C# classes to database tables and lets you query with LINQ. Code-first means you define your model in C# and EF generates the database schema.
// ── Entity ────────────────────────────────────────────────────── public class Product { public int Id { get; set; } public string Name { get; set; } = ""; public decimal Price { get; set; } public int CategoryId { get; set; } public Category Category { get; set; } = null!; // Navigation property public List<Review> Reviews { get; set; } = []; public DateTimeOffset CreatedAt { get; set; } } // ── DbContext ──────────────────────────────────────────────────── public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options) { public DbSet<Product> Products { get; set; } public DbSet<Category> Categories { get; set; } public DbSet<Review> Reviews { get; set; } protected override void OnModelCreating(ModelBuilder mb) { // Fluent API configuration (preferred over Data Annotations) mb.Entity<Product>(e => { e.HasKey(p => p.Id); e.Property(p => p.Name).HasMaxLength(200).IsRequired(); e.Property(p => p.Price).HasColumnType("decimal(18,2)"); e.HasOne(p => p.Category) .WithMany(c => c.Products) .HasForeignKey(p => p.CategoryId) .OnDelete(DeleteBehavior.Restrict); e.HasIndex(p => p.Name).IsUnique(); }); // Apply all IEntityTypeConfiguration in assembly mb.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); } // Auto-set timestamps public override Task<int> SaveChangesAsync(CancellationToken ct = default) { foreach (var entry in ChangeTracker.Entries<IAuditable>()) { if (entry.State == EntityState.Added) entry.Entity.CreatedAt = DateTimeOffset.UtcNow; } return base.SaveChangesAsync(ct); } }
EF Core Migrations
# Install EF Core tools (once, globally) dotnet tool install --global dotnet-ef # Add first migration dotnet ef migrations add InitialCreate --project src/MyApi # Add migration for schema change dotnet ef migrations add AddProductCategory # Apply pending migrations to database dotnet ef database update # Rollback to specific migration dotnet ef database update InitialCreate # Remove last migration (if not applied) dotnet ef migrations remove # Generate SQL script (for CI/CD, production) dotnet ef migrations script --idempotent -o migrations.sql # Apply migrations programmatically at startup
// In Program.cs — apply migrations on startup using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); await db.Database.MigrateAsync(); // Apply any pending migrations }
Querying Data
public class ProductService(AppDbContext db) { // Basic query with filtering, ordering, paging public async Task<List<Product>> GetPagedAsync(int page, int size) => await db.Products .AsNoTracking() // Read-only — skips change tracking .Where(p => p.Price > 0) .OrderBy(p => p.Name) .Skip((page - 1) * size) .Take(size) .ToListAsync(); // Include navigation properties (eager loading) public async Task<Product?> GetWithDetailsAsync(int id) => await db.Products .Include(p => p.Category) .Include(p => p.Reviews.OrderByDescending(r => r.CreatedAt).Take(5)) .FirstOrDefaultAsync(p => p.Id == id); // Projection to DTO (avoids loading full entity) public async Task<List<ProductSummaryDto>> GetSummariesAsync() => await db.Products .Select(p => new ProductSummaryDto(p.Id, p.Name, p.Price, p.Category.Name)) .ToListAsync(); // Raw SQL for complex queries public async Task<List<Product>> SearchAsync(string term) => await db.Products .FromSqlInterpolated($"SELECT * FROM Products WHERE CONTAINS(Name, {term})") .ToListAsync(); // Aggregation public async Task<decimal> GetAveragePriceAsync(int categoryId) => await db.Products .Where(p => p.CategoryId == categoryId) .AverageAsync(p => p.Price); // Insert / Update / Delete public async Task<Product> CreateAsync(CreateProductRequest req) { var product = new Product { Name = req.Name, Price = req.Price }; db.Products.Add(product); await db.SaveChangesAsync(); return product; } // ExecuteUpdate / ExecuteDelete (.NET 7+ — no change tracking overhead) public async Task DeactivateOldProductsAsync() => await db.Products .Where(p => p.LastSoldAt < DateTime.UtcNow.AddYears(-2)) .ExecuteUpdateAsync(s => s.SetProperty(p => p.IsActive, false)); }
Repository Pattern
// Generic repository interface public interface IRepository<T> where T : class { Task<T?> GetByIdAsync(int id); Task<List<T>> GetAllAsync(); Task AddAsync(T entity); void Update(T entity); void Remove(T entity); } // Generic implementation public class Repository<T>(AppDbContext context) : IRepository<T> where T : class { protected readonly DbSet<T> _set = context.Set<T>(); public async Task<T?> GetByIdAsync(int id) => await _set.FindAsync(id); public async Task<List<T>> GetAllAsync() => await _set.AsNoTracking().ToListAsync(); public async Task AddAsync(T entity) => await _set.AddAsync(entity); public void Update(T entity) => _set.Update(entity); public void Remove(T entity) => _set.Remove(entity); } // Unit of Work — single SaveChanges across repos public interface IUnitOfWork { IRepository<Product> Products { get; } IRepository<Category> Categories { get; } Task<int> SaveChangesAsync(); }
Authentication
ASP.NET Core separates authentication (who are you?) from authorization (what can you do?). Authentication schemes read credentials from the request and populate HttpContext.User.
| Scheme | Use Case | Package |
|---|---|---|
| JWT Bearer | APIs, mobile clients, SPAs | Microsoft.AspNetCore.Authentication.JwtBearer |
| Cookie | Web apps, Razor Pages, MVC | Built-in |
| API Key | Service-to-service, B2B | Custom middleware |
| OAuth 2.0 / OIDC | Social login, Azure AD, Auth0 | Microsoft.AspNetCore.Authentication.OpenIdConnect |
| Basic Auth | Internal tools, legacy | Custom handler |
JWT Bearer Authentication
// ── Program.cs — configure JWT ───────────────────────────────── 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"]!)), ClockSkew = TimeSpan.Zero // No tolerance for expired tokens }; }); // ── Token Generation Service ─────────────────────────────────── public class TokenService(IConfiguration config) { public string GenerateToken(User user) { var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:Key"]!)); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, user.Id.ToString()), new(ClaimTypes.Email, user.Email), new(ClaimTypes.Name, user.UserName), new("tenant_id", user.TenantId.ToString()), }; // Add role claims claims.AddRange(user.Roles.Select(r => new Claim(ClaimTypes.Role, r))); var token = new JwtSecurityToken( issuer: config["Jwt:Issuer"], audience: config["Jwt:Audience"], claims: claims, expires: DateTime.UtcNow.AddMinutes(int.Parse(config["Jwt:ExpiryMinutes"]!)), signingCredentials: creds ); return new JwtSecurityTokenHandler().WriteToken(token); } }
Authorization
// ── Setup policies ───────────────────────────────────────────── builder.Services.AddAuthorization(options => { options.AddPolicy("AdminOnly", p => p.RequireRole("Admin")); options.AddPolicy("MinAge18", p => p.RequireClaim("age", "18", "19", "20")); // oversimplified options.AddPolicy("PremiumUser", p => p.RequireClaim("subscription", "premium", "enterprise")); options.AddPolicy("ResourceOwner", p => p.AddRequirements(new ResourceOwnerRequirement())); options.FallbackPolicy = new AuthorizationPolicyBuilder() .RequireAuthenticatedUser() // All endpoints require auth by default .Build(); }); // ── Custom requirement + handler ─────────────────────────────── public class ResourceOwnerRequirement : IAuthorizationRequirement { } public class ResourceOwnerHandler : AuthorizationHandler<ResourceOwnerRequirement> { protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, ResourceOwnerRequirement requirement) { var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; var resourceOwnerId = (context.Resource as Product)?.OwnerId.ToString(); if (userId == resourceOwnerId || context.User.IsInRole("Admin")) context.Succeed(requirement); return Task.CompletedTask; } } // ── Usage on controllers ─────────────────────────────────────── [Authorize] // Any authenticated user [Authorize(Roles = "Admin,Manager")] // Role-based [Authorize(Policy = "PremiumUser")] // Policy-based [AllowAnonymous] // Override — public endpoint
CORS Configuration
// ── Define named policies ───────────────────────────────────── builder.Services.AddCors(options => { // Development — permissive options.AddPolicy("AllowAll", policy => policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()); // Production — restrictive options.AddPolicy("ProductionPolicy", policy => policy .WithOrigins("https://myapp.com", "https://www.myapp.com") .WithMethods("GET", "POST", "PUT", "DELETE") .WithHeaders("Authorization", "Content-Type") .AllowCredentials() // Required for cookies .SetPreflightMaxAge(TimeSpan.FromMinutes(10))); }); // Apply globally app.UseCors("ProductionPolicy"); // Or per endpoint [EnableCors("AllowAll")] public IActionResult PublicEndpoint() => Ok(); [DisableCors] public IActionResult InternalEndpoint() => Ok();
HttpClient & IHttpClientFactory
Never create raw HttpClient instances directly — it causes socket exhaustion. Always use IHttpClientFactory.
// ── Named client ────────────────────────────────────────────── builder.Services.AddHttpClient("PaymentApi", client => { client.BaseAddress = new Uri("https://api.payments.com/"); client.DefaultRequestHeaders.Add("X-Api-Key", config["PaymentApi:Key"]); client.Timeout = TimeSpan.FromSeconds(30); }); // Usage in service public class PaymentService(IHttpClientFactory factory) { public async Task ChargeAsync(ChargeRequest req) { var client = factory.CreateClient("PaymentApi"); var response = await client.PostAsJsonAsync("charges", req); response.EnsureSuccessStatusCode(); } } // ── Typed client (preferred) ─────────────────────────────────── public class WeatherClient(HttpClient http) { public async Task<WeatherData> GetForecastAsync(string city) { var data = await http.GetFromJsonAsync<WeatherData>($"forecast?city={city}"); return data ?? throw new InvalidOperationException("No data"); } } // Register typed client + Polly resilience (.NET 8 builtin) builder.Services.AddHttpClient<WeatherClient>(c => c.BaseAddress = new Uri("https://api.weather.com/")) .AddStandardResilienceHandler(); // Retry + circuit breaker built-in (.NET 8)
Background Services
// ── Periodic background task ─────────────────────────────────── public class EmailQueueProcessor(IServiceScopeFactory scopeFactory, ILogger<EmailQueueProcessor> logger) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { logger.LogInformation("Email processor started"); while (!stoppingToken.IsCancellationRequested) { try { // Create scope for scoped services (EF Core DbContext) using var scope = scopeFactory.CreateScope(); var emailService = scope.ServiceProvider .GetRequiredService<IEmailService>(); await emailService.ProcessQueueAsync(stoppingToken); } catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogError(ex, "Error processing email queue"); } await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); } } } // Register as hosted service builder.Services.AddHostedService<EmailQueueProcessor>(); // ── Timed background service ──────────────────────────────────── public class MetricsCollector : IHostedService, IDisposable { private Timer? _timer; public Task StartAsync(CancellationToken ct) { _timer = new Timer(CollectMetrics, null, TimeSpan.Zero, TimeSpan.FromMinutes(1)); return Task.CompletedTask; } private void CollectMetrics(object? state) => /* gather metrics */{} public Task StopAsync(CancellationToken ct) { _timer?.Change(Timeout.Infinite, 0); return Task.CompletedTask; } public void Dispose() => _timer?.Dispose(); }
Caching
// ── In-Memory Cache ──────────────────────────────────────────── builder.Services.AddMemoryCache(); public class ProductService(IMemoryCache cache, AppDbContext db) { public async Task<Product?> GetByIdAsync(int id) { var cacheKey = $"product:{id}"; if (cache.TryGetValue(cacheKey, out Product? cached)) return cached; var product = await db.Products.FindAsync(id); if (product is not null) cache.Set(cacheKey, product, new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5), SlidingExpiration = TimeSpan.FromMinutes(2), Size = 1 }); return product; } } // ── Redis Distributed Cache ──────────────────────────────────── builder.Services.AddStackExchangeRedisCache(options => options.Configuration = builder.Configuration.GetConnectionString("Redis")); // ── Output Cache (.NET 7+) — cache entire responses ──────────── builder.Services.AddOutputCache(options => { options.AddBasePolicy(b => b.Cache()); options.AddPolicy("ProductsCache", b => b.Expire(TimeSpan.FromMinutes(2)).Tag("products")); }); app.UseOutputCache(); [OutputCache(PolicyName = "ProductsCache")] [HttpGet] public async Task<IActionResult> GetAll() => Ok(await _service.GetAllAsync());
Logging
// ── Built-in ILogger ─────────────────────────────────────────── public class OrderService(ILogger<OrderService> logger) { public async Task ProcessOrderAsync(Order order) { // Structured logging — use message templates, not string interpolation! logger.LogInformation("Processing order {OrderId} for user {UserId}", order.Id, order.UserId); logger.LogWarning("Order {OrderId} total {Total:C} exceeds threshold", order.Id, order.Total); logger.LogError("Failed to process order {OrderId}: {Error}", order.Id, "Payment declined"); // High-performance logging (avoids string allocation when not needed) _logOrderProcessed(logger, order.Id, null); } // Compile-time logging (fastest, .NET 6+) private static readonly Action<ILogger, int, Exception?> _logOrderProcessed = LoggerMessage.Define<int>( LogLevel.Information, EventId: new(1001, "OrderProcessed"), "Order {OrderId} processed successfully"); } // ── Serilog (NuGet: Serilog.AspNetCore) ────────────────────────── builder.Host.UseSerilog((ctx, config) => config .ReadFrom.Configuration(ctx.Configuration) .Enrich.FromLogContext() .Enrich.WithEnvironmentName() .WriteTo.Console( outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") .WriteTo.File("logs/app-.log", rollingInterval: RollingInterval.Day) .WriteTo.Seq("http://localhost:5341")); // Seq log server
| Level | When to use |
|---|---|
Trace | Extremely detailed, every step. Development only. |
Debug | Diagnostic information for developers. Disabled in prod. |
Information | Normal application flow — requests received, tasks completed. |
Warning | Unexpected but handled — slow query, degraded service, retry. |
Error | Failures that affect current operation — exceptions, DB errors. |
Critical | System-level failures requiring immediate action — app crash. |
Error Handling
// ── .NET 8 IExceptionHandler (preferred) ───────────────────── public class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger) : IExceptionHandler { public async ValueTask<bool> TryHandleAsync( HttpContext context, Exception exception, CancellationToken ct) { var (status, title) = exception switch { NotFoundException => (404, "Resource Not Found"), ValidationException => (422, "Validation Error"), UnauthorizedException => (401, "Unauthorized"), _ => (500, "Internal Server Error") }; logger.LogError(exception, "Unhandled exception: {Message}", exception.Message); // Return RFC 7807 Problem Details await Results.Problem( title: title, statusCode: status, detail: exception.Message, extensions: new Dictionary<string, object?> { ["traceId"] = context.TraceIdentifier } ).ExecuteAsync(context); return true; } } // Register in Program.cs builder.Services.AddExceptionHandler<GlobalExceptionHandler>(); builder.Services.AddProblemDetails(); app.UseExceptionHandler();
Action Filters
// Filter executes around controller action methods public class PerformanceFilter(ILogger<PerformanceFilter> logger) : IAsyncActionFilter { public async Task OnActionExecutionAsync( ActionExecutingContext ctx, ActionExecutionDelegate next) { var sw = Stopwatch.StartNew(); var executed = await next(); // Execute the action sw.Stop(); if (sw.ElapsedMilliseconds > 500) logger.LogWarning("SLOW: {Action} took {Ms}ms", ctx.ActionDescriptor.DisplayName, sw.ElapsedMilliseconds); } } // Filter types and execution order: // Authorization → Resource → Action → Exception → Result // Register globally builder.Services.AddControllers(options => options.Filters.Add<PerformanceFilter>()); // Or per controller/action [ServiceFilter(typeof(PerformanceFilter))] public class ProductsController : ControllerBase { }
Health Checks
builder.Services
.AddHealthChecks()
.AddDbContextCheck<AppDbContext>("database")
.AddRedis(config.GetConnectionString("Redis")!, "redis")
.AddUrlGroup(new Uri("https://api.external.com/health"), "external-api")
.AddCheck("custom", () =>
HealthCheckResult.Healthy("All systems go"));
// Expose endpoints
app.MapHealthChecks("/health"); // Simple: 200 / 503
app.MapHealthChecks("/health/detail", new HealthCheckOptions
{
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse // JSON details
});
Unit & Integration Tests
public class ProductServiceTests { private readonly Mock<IRepository<Product>> _repoMock = new(); private readonly ProductService _sut; public ProductServiceTests() { _sut = new ProductService(_repoMock.Object); } [Fact] public async Task GetById_ExistingId_ReturnsProduct() { // Arrange var product = new Product { Id = 1, Name = "Laptop", Price = 999 }; _repoMock.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(product); // Act var result = await _sut.GetByIdAsync(1); // Assert Assert.NotNull(result); Assert.Equal("Laptop", result.Name); _repoMock.Verify(r => r.GetByIdAsync(1), Times.Once); } [Theory] [InlineData("", false)] [InlineData("A", false)] [InlineData("Valid Product", true)] public void ValidateName_VariousInputs_ReturnsExpected(string name, bool expected) { var result = _sut.ValidateName(name); Assert.Equal(expected, result); } }
// Full integration test — spins up real ASP.NET Core test server public class ProductsApiTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>> { private readonly HttpClient _client = factory .WithWebHostBuilder(builder => { builder.ConfigureServices(services => { // Replace real DB with in-memory for tests services.RemoveAll<DbContextOptions<AppDbContext>>(); services.AddDbContext<AppDbContext>(opts => opts.UseInMemoryDatabase("TestDb")); }); }) .CreateClient(); [Fact] public async Task GetProducts_ReturnsOk() { var response = await _client.GetAsync("/api/products"); response.EnsureSuccessStatusCode(); var products = await response.Content.ReadFromJsonAsync<List<ProductDto>>(); Assert.NotNull(products); } [Fact] public async Task CreateProduct_ValidRequest_Returns201() { var request = new CreateProductRequest { Name = "Test", Price = 9.99m }; var response = await _client.PostAsJsonAsync("/api/products", request); Assert.Equal(HttpStatusCode.Created, response.StatusCode); } }
Docker & Deployment
# Stage 1: Build FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src # Copy csproj and restore (layer caching) COPY ["src/MyApi/MyApi.csproj", "src/MyApi/"] RUN dotnet restore "src/MyApi/MyApi.csproj" # Copy everything and build COPY . . WORKDIR /src/src/MyApi RUN dotnet publish -c Release -o /app/publish --no-restore # Stage 2: Runtime image (much smaller) FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final WORKDIR /app EXPOSE 8080 # Create non-root user RUN adduser --disabled-password --gecos "" appuser USER appuser COPY --from=build /app/publish . ENTRYPOINT ["dotnet", "MyApi.dll"]
services:
api:
build: .
ports: ["8080:8080"]
environment:
- ASPNETCORE_ENVIRONMENT=Production
- ConnectionStrings__DefaultConnection=Server=db;Database=MyDb;User=sa;Password=${DB_PASSWORD}
- Jwt__Key=${JWT_KEY}
depends_on:
db:
condition: service_healthy
db:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
SA_PASSWORD: "${DB_PASSWORD}"
ACCEPT_EULA: "Y"
healthcheck:
test: /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P ${DB_PASSWORD} -Q "SELECT 1"
interval: 10s
retries: 5
Quick Cheat Sheet
Essential CLI Commands
# Create new Web API project dotnet new webapi -n MyApi --use-controllers # Minimal API template dotnet new webapi -n MyApi --no-openapi # Add NuGet packages dotnet add package Microsoft.EntityFrameworkCore.SqlServer dotnet add package Serilog.AspNetCore dotnet add package FluentValidation.AspNetCore # EF Core migrations dotnet ef migrations add MigrationName dotnet ef database update dotnet ef migrations script --idempotent -o migrate.sql # Run with hot reload dotnet watch run # Run tests dotnet test --logger "console;verbosity=detailed" # Publish for deployment dotnet publish -c Release -o ./publish
Service Registration Quick Reference
| What | Registration |
|---|---|
| Controllers (MVC) | services.AddControllers() |
| Swagger / OpenAPI | services.AddEndpointsApiExplorer(); services.AddSwaggerGen() |
| EF Core (SQL Server) | services.AddDbContext<TCtx>(o => o.UseSqlServer(connStr)) |
| JWT Auth | services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer() |
| Memory Cache | services.AddMemoryCache() |
| Redis Cache | services.AddStackExchangeRedisCache() |
| CORS | services.AddCors(o => o.AddPolicy("name", p => p.AllowAnyOrigin())) |
| Health Checks | services.AddHealthChecks().AddDbContextCheck<TCtx>() |
| HTTP Client | services.AddHttpClient<TClient>(c => c.BaseAddress = new Uri(...)) |
| Background Service | services.AddHostedService<TService>() |
| Rate Limiting | services.AddRateLimiter() |
| Output Cache | services.AddOutputCache() |
| Problem Details | services.AddProblemDetails(); services.AddExceptionHandler<T>() |
Key Attributes Quick Reference
C# 12 Features Used in .NET 8
// Primary constructors (C# 12) — cleaner service classes public class OrderService(IOrderRepository repo, ILogger<OrderService> logger) { } // Collection expressions int[] ids = [1, 2, 3]; List<string> names = ["Alice", "Bob"]; // Pattern matching switch expression var status = order.State switch { OrderState.Pending => "Awaiting payment", OrderState.Paid => "Processing", OrderState.Shipped => "On the way", _ => "Unknown" }; // Records (immutable DTOs) public record ProductDto(int Id, string Name, decimal Price); // Required properties public class Config { public required string Host { get; init; } public required int Port { get; init; } }