.NET Core Handbook — Basic to Intermediate
V1
Back to handbooks index
.NET 8 · 2025
ASP.NET Core EF Core Middleware Minimal API Basic → Intermediate
Developer Handbook

ASP.NET Core
Complete Guide

// .NET 8 · ASP.NET Core 8 · EF Core 8 · C# 12

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.

.NET 8 LTS C# 12 Middleware Pipeline EF Core 8 JWT Auth Minimal APIs

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.

.NET 8
Current LTS
C# 12
Language Version
3
OS Platforms
AOT
Native Compilation
Open
Source (MIT)
🌐
ASP.NET Core
Web APIs, MVC, Razor Pages, gRPC, SignalR, Blazor — all unified under one framework.
🗄️
Entity Framework Core
ORM for database access. Code-first migrations, LINQ queries, SQL Server, PostgreSQL, SQLite.
Performance
Consistently in top 10 on TechEmpower benchmarks. Kestrel web server is blazingly fast.
🔌
Dependency Injection
First-class built-in DI container. No third-party IoC needed for most scenarios.

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.

textTypical Web API Project Layout
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)
xmlMyApi.csproj — Project File
<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.

csharpProgram.cs — Complete annotated example
// ─── 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();
💡 Pre-.NET 6 vs Modern

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.

❌ Without DI
public class OrderController
{
    public Process()
    {
        // Tightly coupled — hard to test
        var db = new SqlDatabase();
        var svc = new OrderService(db);
        svc.Process();
    }
}
✅ With DI
public class OrderController(IOrderService svc)
{
    public Process()
    {
        // Loosely coupled — testable
        svc.Process();
    }
    // DI container injects IOrderService
}
csharpDefining and Registering Services
// 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.

LifetimeCreatedShared?Use ForRegister
SingletonOnce, app lifetimeAll requests + threadsCache, config, HTTP clientsAddSingleton<>
ScopedOnce per HTTP requestWithin same requestDbContext, per-request servicesAddScoped<>
TransientEvery injectionNeverLightweight, stateless servicesAddTransient<>
⚠️ Captive Dependency (Common Bug)

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.

csharpLifetime Examples
// 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

csharpKeyed Services (.NET 8), IEnumerable injection, Extension Methods
// ── 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.

jsonappsettings.json
{
  "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": "*"
}
csharpReading Configuration
// 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.

csharpIOptions / IOptionsSnapshot / IOptionsMonitor
// 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

bashUser Secrets (Development only)
# 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
bashEnvironment Variables — override any config key
# 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)

  1. Command-line arguments--Jwt:Key=secret
  2. Environment variables — override appsettings values
  3. User Secrets — Development only, not in source control
  4. appsettings.{Environment}.json — per-environment overrides
  5. 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.

── HTTP REQUEST FLOW →→→
UseExceptionHandlerGlobal error catching — outermost layer
UseHsts / UseHttpsRedirectionSecurity headers, HTTP→HTTPS
UseStaticFilesShort-circuits for static assets (no further processing)
UseRoutingMatches request URL to endpoint — sets RouteData
UseCorsCross-origin resource sharing headers
UseAuthenticationSets HttpContext.User from JWT/cookie/scheme
UseAuthorizationChecks [Authorize] policies, returns 401/403
Custom MiddlewareLogging, rate limiting, request ID, etc.
MapControllers / MapGetEndpoint execution — your controller/handler runs here
←←← HTTP RESPONSE UNWINDS
🔑 Key Concept: Request Delegates

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

MiddlewareMethodPurpose
Exception HandlerUseExceptionHandler()Catches unhandled exceptions, returns error response
HSTSUseHsts()Adds Strict-Transport-Security header (prod only)
HTTPS RedirectUseHttpsRedirection()Redirects HTTP requests to HTTPS
Static FilesUseStaticFiles()Serves files from wwwroot, short-circuits pipeline
RoutingUseRouting()Matches URL to endpoint, required before UseCors/UseAuth
CORSUseCors()Sets Access-Control-* headers for cross-origin requests
AuthenticationUseAuthentication()Reads auth token/cookie, populates HttpContext.User
AuthorizationUseAuthorization()Evaluates [Authorize] policies, returns 401/403
Response CompressionUseResponseCompression()Gzip/Brotli compresses responses
Response CachingUseResponseCaching()Cache responses server-side
Rate LimitingUseRateLimiter().NET 7+ built-in rate limiting
Request TimeoutUseRequestTimeouts().NET 8 per-endpoint timeouts
Health ChecksMapHealthChecks()/health endpoint for orchestrators
Output CacheUseOutputCache().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().

csharpClass-based Custom Middleware (recommended)
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>();
csharpInline Middleware — Use / Run / Map
// 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>()
);
csharpCorrelation ID Middleware — Production Example
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.

⛔ Wrong Order — Common Mistakes

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.

csharpCorrect Middleware Order
// ✅ 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

csharpAttribute Routing — all patterns
[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

csharpFull CRUD Controller — Best Practices
[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();
    }
}
💡 [ApiController] Attribute

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

MethodStatusUse When
Ok(data)200Successful GET or operation with data
Created(uri, data) / CreatedAtRoute()201Resource created — include location header
NoContent()204Successful PUT/DELETE with no response body
BadRequest() / BadRequest(errors)400Invalid input, validation failure
Unauthorized()401Not authenticated
Forbid()403Authenticated but not authorized
NotFound()404Resource not found
Conflict()409Duplicate resource, optimistic concurrency failure
UnprocessableEntity()422Business validation failure
Problem()variesRFC 7807 problem details response
StatusCode(code, data)anyCustom status code
csharpTypedResults — .NET 7+ Minimal API style in controllers
// 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

csharpBinding Sources
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

csharpData Annotations + FluentValidation
// ── 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.

csharpProgram.cs — Minimal API with all patterns
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

csharpRouteGroupBuilder — clean organization
// 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.

csharpDbContext + Entity Configuration
// ── 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

bashMigration CLI Commands
# 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
csharpAuto-migrate at startup (dev/staging)
// 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

csharpLINQ Queries — Common Patterns
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

csharpGeneric Repository + Unit of Work
// 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.

SchemeUse CasePackage
JWT BearerAPIs, mobile clients, SPAsMicrosoft.AspNetCore.Authentication.JwtBearer
CookieWeb apps, Razor Pages, MVCBuilt-in
API KeyService-to-service, B2BCustom middleware
OAuth 2.0 / OIDCSocial login, Azure AD, Auth0Microsoft.AspNetCore.Authentication.OpenIdConnect
Basic AuthInternal tools, legacyCustom handler

JWT Bearer Authentication

csharpJWT Setup — Program.cs + Token Generation
// ── 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

csharpRole-based + Policy-based 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

csharpCORS Policies
// ── 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.

csharpNamed + Typed Clients
// ── 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

csharpBackgroundService + IHostedService
// ── 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

csharpIMemoryCache + IDistributedCache + Output Cache
// ── 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

csharpILogger + Structured Logging + Serilog
// ── 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
LevelWhen to use
TraceExtremely detailed, every step. Development only.
DebugDiagnostic information for developers. Disabled in prod.
InformationNormal application flow — requests received, tasks completed.
WarningUnexpected but handled — slow query, degraded service, retry.
ErrorFailures that affect current operation — exceptions, DB errors.
CriticalSystem-level failures requiring immediate action — app crash.

Error Handling

csharpGlobal Exception Handler + Problem Details
// ── .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

csharpIActionFilter — before and after controller actions
// 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

csharpHealth Checks for database, Redis, external APIs
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

csharpxUnit + Moq Unit Test
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);
    }
}
csharpIntegration Tests — WebApplicationFactory
// 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

dockerfileDockerfile — multi-stage optimized build
# 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"]
yamldocker-compose.yml
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

bash
# 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

WhatRegistration
Controllers (MVC)services.AddControllers()
Swagger / OpenAPIservices.AddEndpointsApiExplorer(); services.AddSwaggerGen()
EF Core (SQL Server)services.AddDbContext<TCtx>(o => o.UseSqlServer(connStr))
JWT Authservices.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer()
Memory Cacheservices.AddMemoryCache()
Redis Cacheservices.AddStackExchangeRedisCache()
CORSservices.AddCors(o => o.AddPolicy("name", p => p.AllowAnyOrigin()))
Health Checksservices.AddHealthChecks().AddDbContextCheck<TCtx>()
HTTP Clientservices.AddHttpClient<TClient>(c => c.BaseAddress = new Uri(...))
Background Serviceservices.AddHostedService<TService>()
Rate Limitingservices.AddRateLimiter()
Output Cacheservices.AddOutputCache()
Problem Detailsservices.AddProblemDetails(); services.AddExceptionHandler<T>()

Key Attributes Quick Reference

[ApiController] [Route("api/[controller]")] [HttpGet/Post/Put/Delete/Patch] [FromBody] [FromQuery] [FromRoute] [FromHeader] [Authorize] [AllowAnonymous] [Authorize(Roles="Admin")] [Required] [StringLength(100)] [Range(1,99)] [OutputCache(Duration=60)] [ResponseCache] [ServiceFilter] [ProducesResponseType(200)] [Produces("application/json")]

C# 12 Features Used in .NET 8

csharp
// 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; }
}