Getting Started Last updated: March 1, 2026, 8:47 p.m.

ASP.NET Core is a cross-platform, high-performance framework designed for building modern, cloud-enabled, and Internet-connected applications. It represents a significant evolution from the original ASP.NET, moving toward a modular, decoupled architecture that runs on Windows, macOS, and Linux. The entry point for any developer is understanding the unified platform provided by the .NET SDK, which includes the tools necessary to create, build, and run applications via the Command Line Interface (CLI) or integrated environments like Visual Studio.

At the heart of getting started is the concept of the Host, which manages the application's lifecycle, including startup, shutdown, and resource management. Whether you are building a simple hobby project or a complex microservice, the framework provides pre-configured templates that help you scaffold a project with best practices for folder structure and dependency management. This ensures that developers can focus on writing business logic rather than boilerplate infrastructure code.

Introduction to ASP.NET Core

ASP.NET Core represents a fundamental shift in the evolution of web development within the Microsoft ecosystem. It is a high-performance, open-source, and cross-platform framework designed for building modern, cloud-based, and internet-connected applications. Unlike its predecessor, the legacy ASP.NET 4.x, ASP.NET Core was architected from the ground up to be modular and decoupled from the underlying operating system and web server. This modularity allows developers to include only the necessary NuGet packages required for their specific application, resulting in a smaller deployment footprint, improved security, and enhanced performance.

The framework operates on the .NET runtime (formerly .NET Core), which enables applications to run seamlessly across Windows, macOS, and Linux. This cross-platform capability is paired with a unified programming model for building both Web UI (using Razor Pages or MVC) and Web APIs. Furthermore, ASP.NET Core is engineered to be "cloud-ready" by providing built-in support for dependency injection, a lightweight and high-performance asynchronous request pipeline, and environment-based configuration systems that simplify the transition from local development to production environments like Azure or AWS.

Core Architectural Components

At the heart of an ASP.NET Core application is the Host, which is responsible for application startup and lifetime management. The host configures the server (typically Kestrel, a cross-platform web server) and the request processing pipeline. This pipeline is composed of Middleware—individual components that execute in sequence to handle incoming HTTP requests and outgoing responses. This design allows for granular control over features such as authentication, logging, and static file serving, as each feature is opted-in via code rather than being globally enabled by default.

Component Description Primary Responsibility
Kestrel Default cross-platform HTTP server Edge server or used behind a reverse proxy (IIS/Nginx).
Middleware Software assembled into an app pipeline Handles requests and responses (e.g., Routing, Auth).
Dependency Injection Built-in IoC container Manages object lifetimes and provides services to classes.
Configuration File, Environment, or Secret based system Loads settings from appsettings.json, environment variables, etc.

Application Entry Point and Initialization

Every ASP.NET Core application begins as a simple console application that defines a Main method. This method uses the WebApplication builder to configure the web server, services, and the request pipeline. The modern "Minimal APIs" approach simplifies this further, allowing developers to define routes and logic in a single file, though the underlying complexity of the builder remains available for enterprise-scale configurations.

using Microsoft.AspNetCore.Builder;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container (Dependency Injection)
builder.Services.AddControllers();

var app = builder.Build();

// Configure the HTTP request pipeline (Middleware)
if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}

app.UseHttpsRedirection();
app.UseAuthorization();

app.MapGet("/", () => "Welcome to ASP.NET Core!");

app.Run();
        

Note: ASP.NET Core is significantly faster than the legacy ASP.NET framework. Benchmarks often place it among the fastest web frameworks available, largely due to the non-blocking I/O nature of the Kestrel server and the optimization of the .NET pipeline.


Comparison: ASP.NET Core vs. Legacy ASP.NET

Understanding the distinction between the modern framework and the legacy version is critical for architectural decision-making. The following table highlights the primary technical differences:

Feature ASP.NET Core (Current) Legacy ASP.NET (4.x)
Operating System Windows, Linux, macOS Windows Only
Server Kestrel, HTTP.sys, IIS IIS Only
Dependency Injection Built-in / Native Requires 3rd-party libraries
Pipeline Modular Middleware (Fast) Global.asax / System.Web (Heavy)
Open Source Yes (GitHub) Partially (Reference Source)
Hosting Model Self-hosted or Reverse Proxy Hosted via Worker Process (w3wp.exe)

Performance and Scalability

One of the primary drivers for adopting ASP.NET Core is its ability to handle high-concurrency workloads with minimal resource consumption. Because the framework is no longer tied to the System.Web.dll (which was heavily burdened by legacy dependencies), it can execute with much higher throughput. This efficiency is particularly beneficial in containerized environments like Docker and Kubernetes, where resource allocation directly impacts operational costs.

Warning: When migrating from legacy ASP.NET to ASP.NET Core, be aware that many libraries depending on the Windows Registry or GDI+ may not be available or function differently on Linux-based environments. Always verify cross-platform compatibility for third-party dependencies.

Installation and the .NET CLI

To begin developing with ASP.NET Core, the primary requirement is the .NET Software Development Kit (SDK). The SDK includes everything necessary to build and run applications, including the .NET Runtime, the specialized ASP.NET Core Runtime for web hosting, and the .NET Command-Line Interface (CLI). Unlike older versions of the framework that relied heavily on Visual Studio's graphical installers, the modern .NET ecosystem is centered around the CLI, ensuring that development workflows remain consistent across Windows, macOS, and Linux.

The installation process typically involves downloading the installer for your specific operating system from the official .NET portal. Once installed, the SDK provides the dotnet executable, which serves as the primary entry point for all development tasks—from creating new projects and managing NuGet packages to compiling code and launching a local web server.

Understanding the .NET SDK vs. Runtime

It is important to distinguish between the SDK and the Runtime, especially when moving from a development environment to a production server. The SDK is a superset of the Runtime; it contains compilers (Roslyn), build tools (MSBuild), and the CLI. In contrast, a production server only requires the ASP.NET Core Runtime to execute the compiled binaries, which significantly reduces the attack surface and disk space requirements of the hosting environment.

Component Included Features Target Environment
.NET SDK CLI, Compilers, Build Tools, Runtime Development Machines, CI/CD Build Agents
ASP.NET Core Runtime Web Server (Kestrel), Core Libraries Production Web Servers, Docker Containers
.NET Desktop Runtime WPF and WinForms support Windows Desktop Workstations

The .NET Command-Line Interface (CLI)

The .NET CLI is a cross-platform toolchain for developing, building, running, and publishing .NET applications. It is designed to be extensible and scriptable, making it the foundation for modern DevOps pipelines. Every command in the CLI follows a predictable structure: dotnet <command> <argument> <option>.

The CLI manages the entire application lifecycle. When you execute a command like dotnet build, the CLI invokes the underlying build engine to resolve dependencies defined in the project file (.csproj) and produces executable artifacts.

Essential CLI Commands

The following table outlines the most frequently used commands required to manage an ASP.NET Core project lifecycle:

Command Purpose Common Options
dotnet new Creates a new project from a template -n (name), -o (output directory)
dotnet restore Downloads dependencies defined in the project N/A (usually implicit in build)
dotnet build Compiles the project into binaries -c Release (configuration)
dotnet run Compiles and immediately launches the app --project (path to csproj)
dotnet watch Restarts or hot-reloads the app on file changes N/A
dotnet publish Prepares the app for deployment -r (runtime identifier)

Creating and Running Your First Project

To verify a successful installation, you can use the CLI to bootstrap a new web application. The web template provides the most basic ASP.NET Core configuration, often referred to as an "Empty" template, which is ideal for understanding the bare-metal mechanics of the framework.

# Verify the installed version of the SDK
dotnet --version

# Create a new empty web project in a folder named 'MyFirstApp'
dotnet new web -o MyFirstApp

# Navigate into the project directory
cd MyFirstApp

# Build and run the application
dotnet run
        

Upon executing dotnet run, the CLI provides a local URL (typically https://localhost:5001 or http://localhost:5000). Accessing this URL in a browser confirms that the Kestrel web server is active and responding to requests.

Note: On Windows and macOS, the .NET SDK installs a self-signed development certificate for HTTPS. You must trust this certificate to avoid "Your connection is not private" errors in the browser. You can do this by running the command: dotnet dev-certs https --trust.

Project File Structure

When you create a project via the CLI, it generates a .csproj file. This is an XML-based file that manages the project’s target framework, NuGet package references, and build configurations. Unlike older versions of ASP.NET, you no longer need to list every single .cs file in the project; the modern SDK automatically includes all code files within the directory tree.

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
  </ItemGroup>

</Project>
        

Warning: Manually editing the .csproj file is common and supported in ASP.NET Core. However, always ensure the Sdk attribute is set to Microsoft.NET.Sdk.Web for web projects. Using the standard Microsoft.NET.Sdk will prevent the project from loading the necessary web-related libraries and Middlewares.

Creating Your First Web App

Building your first application in ASP.NET Core involves more than just running a command; it requires understanding how the framework initializes and handles a web request. While the .NET CLI automates the scaffolding, the resulting project structure is a specialized collection of files designed for high-performance execution. By creating a project from the "Web" (Empty) template, you can observe the fundamental skeleton of a web application without the noise of pre-configured UI frameworks like MVC or Blazor.

The creation process sets up the Project File, the Program.cs entry point, and the App Settings. These three pillars define how the application builds, how it starts, and how it behaves across different environments.

Scaffolding the Application

To create a new web application, you use the dotnet new command followed by a template short name. The web template is the most lightweight option, providing only the bare essentials required to listen for HTTP requests and return a response. This is often the preferred starting point for developers who want full control over their middleware pipeline.

# Create a directory for the project
mkdir MyFirstWebApp
cd MyFirstWebApp

# Scaffold a new empty web project
dotnet new web

# List the files created to see the project structure
ls -R
        

Key Project Components

Every ASP.NET Core project contains a specific set of files that the compiler and runtime use to manage the application's lifecycle. Understanding these files is crucial for troubleshooting and extending your application.

File Name Purpose Technical Detail
MyFirstWebApp.csproj Project Configuration Defines the Target Framework (e.g., net8.0) and NuGet dependencies.
Program.cs Application Entry Point Contains the logic to build the host and define the request pipeline.
appsettings.json Configuration Store A JSON file for application settings like connection strings or API keys.
obj/ & bin/ Build Artifacts Folders created during compilation containing intermediate and executable files.

The Request Pipeline in Program.cs

The Program.cs file is where the application’s "brain" resides. In modern ASP.NET Core (specifically versions 6.0 and later), this file uses Top-Level Statements to reduce boilerplate code. It performs two distinct phases: Building (where services are registered via Dependency Injection) and Configuring (where the HTTP middleware pipeline is defined).

The order in which you define middleware in the configuration phase is critical, as the application processes incoming requests in the exact order they are registered.

var builder = WebApplication.CreateBuilder(args);

// PHASE 1: SERVICE REGISTRATION
// This is where you add framework services or custom logic.
// Example: builder.Services.AddHealthChecks();

var app = builder.Build();

// PHASE 2: MIDDLEWARE PIPELINE
// The order here matters! Security usually comes before Routing.
if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}

// Defining a "Map" tells the app how to respond to a specific URL
app.MapGet("/", () => "Hello, ASP.NET Core World!");

// Starts the Kestrel server and listens for requests
app.Run();
        

Execution and Environment Variables

When you execute dotnet run, the framework looks for a folder named Properties containing a launchSettings.json file. This file determines which profile the application uses, which ports it listens on, and which Environment (Development, Staging, or Production) is active.

Environment-based logic allows you to enable features like detailed error pages in Development while keeping them hidden in Production for security reasons.

Environment Purpose Behavior in Default Template
Development Local coding and debugging Enables UseDeveloperExceptionPage for rich error stacks.
Staging Pre-production testing Mimics production settings but may use test data.
Production Live user environment Optimized for performance; strict security headers enabled.

Note: You can override the environment at the command line by setting the ASPNETCORE_ENVIRONMENT variable. On Windows PowerShell, use $env:ASPNETCORE_ENVIRONMENT="Production"; on Linux/macOS, use export ASPNETCORE_ENVIRONMENT=Production.

Verifying the Web Server

Once the application is running, the CLI output will indicate the listening URLs. By default, Kestrel binds to one HTTP port (usually 5000) and one HTTPS port (usually 5001). You can verify the application is working by sending a request using a web browser or a tool like curl.

# Execute the application
dotnet run

# Expected Output:
# Building...
# info: Microsoft.Hosting.Lifetime[14]
#       Now listening on: https://localhost:7234
# info: Microsoft.Hosting.Lifetime[14]
#       Now listening on: http://localhost:5234

# In a separate terminal, test the endpoint
curl http://localhost:5234

Warning: If you receive a "Certificate Not Trusted" error when visiting the HTTPS URL, ensure you have initialized your development certificates as described in the previous section. Browsers will block requests to local ASP.NET Core apps if the SSL handshake fails.

Project Structure and Program.cs

Understanding the project structure is the gateway to mastering ASP.NET Core. Modern applications utilize a "lean by default" philosophy, where the file system remains uncluttered, and the entry point is consolidated into a single, high-efficiency file. Unlike legacy frameworks that relied on heavy XML configurations (web.config) and global event handlers (Global.asax), ASP.NET Core uses a streamlined, code-first approach to define its behavior.

The File Hierarchy

When you scaffold a new ASP.NET Core project, the SDK generates a specific set of files and directories. Each serves a distinct purpose in the application's lifecycle, from compilation to runtime configuration.

File/Folder Category Description
Program.cs Logic The entry point of the application; configures services and the HTTP pipeline.
appsettings.json Configuration Stores hierarchical configuration data like connection strings and logging levels.
Properties/ Development Contains launchSettings.json, which governs how the app starts during local development.
.csproj Build An MSBuild file defining the SDK, target framework, and NuGet package references.
wwwroot/ Static Assets The only folder from which the app will serve static files (HTML, CSS, JS, Images) by default.
bin/ & obj/ Artifacts Directories created during the build process; contain compiled .dll files and intermediate build data.

Deep Dive: Program.cs

The Program.cs file is the heart of the application. In .NET 6 and later, it uses Top-Level Statements, eliminating the need for explicit Namespace, Class, and Main method declarations. This file follows a strict two-part pattern: the Builder Phase and the App Phase.

Phase 1: The WebApplicationBuilder

The first few lines of Program.cs initialize a WebApplicationBuilder. This object is responsible for three critical tasks:

  1. Configuration: Loading settings from appsettings.json and environment variables.
  2. Logging: Setting up providers to output logs to the console, debug window, or third-party services.
  3. Dependency Injection (DI): Registering services (classes) into the built-in IoC (Inversion of Control) container so they can be injected into other parts of the app.

Phase 2: The Middleware Pipeline

Once builder.Build() is called, the WebApplication object (typically named app) is created. At this point, service registration is locked. The remaining code defines the Request Pipeline. Every piece of middleware added here determines how an incoming HTTP request is processed and how the response is generated.

// 1. Initialize the Builder
var builder = WebApplication.CreateBuilder(args);

// 2. Add Services (Dependency Injection)
builder.Services.AddControllersWithViews(); // Adds MVC support
builder.Services.AddEndpointsApiExplorer(); // Support for API documentation

// 3. Build the Application
var app = builder.Build();

// 4. Configure the Middleware Pipeline (The Order Matters!)
if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage(); // Shows detailed errors only in Dev
}

app.UseHttpsRedirection(); // Redirects HTTP to HTTPS
app.UseStaticFiles();      // Enables serving files from wwwroot

app.UseRouting();          // Matches requests to endpoints
app.UseAuthorization();    // Validates user permissions

// 5. Define Endpoints
app.MapGet("/status", () => Results.Ok("System is running"));
app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");

// 6. Run the Application
app.Run();

Configuration via appsettings.json

The appsettings.json file is the primary location for application variables. ASP.NET Core automatically loads this file at startup. It also supports environment-specific overrides, such as appsettings.Development.json, which allow you to use different database strings or API keys depending on where the code is running.

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=MyDatabase;Trusted_Connection=True;"
  }
}

The Launch Settings

Located in Properties/launchSettings.json, this file is strictly for local development. It is not deployed to the production server. It defines different profiles that can be selected in your IDE (like Visual Studio or VS Code) to determine which URL the app listens on and which environment variables are set.

Key Description
commandName Determines if the app starts as a standalone process ("Project") or through IIS Express.
launchBrowser A boolean indicating if the browser should open automatically on start.
applicationUrl The list of semi-colon separated URLs the server binds to (e.g., https://localhost:5001).
environmentVariables Key-value pairs like ASPNETCORE_ENVIRONMENT used to toggle app behavior.

Warning: Do not store sensitive secrets (like production passwords or API keys) in appsettings.json or launchSettings.json. For local development, use the Secret Manager tool (dotnet user-secrets), and for production, use environment variables or a secure vault like Azure Key Vault.

Note: The wwwroot folder is the "Web Root" of your application. Any file placed inside it is publicly accessible via its relative path (e.g., wwwroot/css/site.css is accessed at /css/site.css). Files outside of wwwroot are protected and cannot be served to the client directly.

Core Fundamentals Last updated: March 1, 2026, 8:47 p.m.

The foundation of every ASP.NET Core application is its Middleware Pipeline and Dependency Injection (DI) container. Unlike legacy frameworks, everything in modern ASP.NET Core is an opt-in service. The DI container is a first-class citizen, allowing you to register services that are automatically provided to your classes, which promotes loose coupling and high testability. This modular approach ensures that your application remains "lean," as you only include the features and libraries that your specific project requires.

Another critical fundamental is the Configuration system, which allows you to pull settings from multiple sources like JSON files, environment variables, or cloud-based secret managers seamlessly. This is paired with an extensible Logging framework and a robust Routing engine. Together, these core components ensure that requests are directed to the correct logic while providing the observability and flexibility needed to run applications in diverse environments, from local development to global cloud clusters.

Dependency Injection (DI)

Dependency Injection (DI) is a fundamental architectural pattern in ASP.NET Core used to achieve Inversion of Control (IoC) between classes and their dependencies. Instead of a class manually instantiating its dependencies (the "New" keyword), the framework's built-in IoC container provides those dependencies at runtime. This decoupled approach is critical for building maintainable, testable, and scalable applications, as it allows developers to swap implementations (such as a mock database for a real one) without modifying the consuming code.

The Dependency Injection Lifecycle

In ASP.NET Core, DI is managed through the IServiceCollection during the application startup phase in Program.cs. When a service is requested—typically via a class constructor—the container looks up the registered implementation and manages its entire lifecycle. This lifecycle management is defined by the Service Lifetime, which determines how long a service instance remains active and when it is shared across different parts of the application.

Lifetime Description Use Case
Transient Created every time they are requested from the service container. Lightweight, stateless services (e.g., a simple calculation engine).
Scoped Created once per client request (connection). Database Contexts (DbContext) or per-request user caches.
Singleton Created the first time they are requested and then reused everywhere. Caching services, configuration wrappers, or high-performance state.

Registering and Injecting Services

Registration happens in the "Builder" phase of Program.cs. Once registered, any class managed by the framework (such as Controllers, Razor Pages, or Middleware) can request these services through Constructor Injection. The framework automatically resolves the hierarchy of dependencies; if Service A requires Service B, the container will instantiate both in the correct order.

Service Registration Example

var builder = WebApplication.CreateBuilder(args);

// Registering an Interface with a concrete implementation
builder.Services.AddScoped<IMyService, MyService>();

// Registering a Singleton that maintains state across the whole app
builder.Services.AddSingleton<ICacheService, MemoryCacheService>();

// Registering a Transient service for one-off tasks
builder.Services.AddTransient<IEmailSender, SendGridEmailSender>();

var app = builder.Build();

Constructor Injection Example

public class HomeController : Controller
{
    private readonly IMyService _myService;

    // The IoC container automatically provides the implementation here
    public HomeController(IMyService myService)
    {
        _myService = myService;
    }

    public IActionResult Index()
    {
        var data = _myService.GetDashboardData();
        return View(data);
    }
}

Advanced DI Techniques

While constructor injection is the standard, ASP.NET Core supports alternative methods for specific scenarios. Action Injection allows you to inject a service directly into a single controller method using the [FromServices] attribute, which is useful when a service is expensive and only needed for one specific operation. Additionally, you can manually resolve services from the HttpContext.RequestServices property in middleware or low-level code, though this "Service Locator" pattern is generally discouraged in favor of explicit constructor injection.

Injection Type Attribute/Method Best Usage
Constructor Default behavior Preferred method for 95% of use cases.
Action [FromServices] Heavy services needed by only one endpoint.
Manual IServiceProvider Within Middleware or background tasks where DI is limited.

Warning: Avoid the Service Locator Pattern, which involves passing the IServiceProvider directly into your classes to resolve dependencies manually. This hides class dependencies, makes unit testing significantly harder, and can lead to runtime errors if a service is not registered.

Service Disposal and Cleanup

The built-in container is responsible for the cleanup of any service that implements IDisposable. When a Scoped service's request ends, or when a Singleton service's application shuts down, the container automatically calls the .Dispose() method on those instances. This ensures that resources like database connections or file handles are released correctly without manual intervention from the developer.

Note: Be extremely careful when injecting a Scoped service into a Singleton. Since the Singleton lives for the life of the application, it will hold onto the Scoped service indefinitely, effectively turning that Scoped service into a Singleton. This can lead to bugs, such as keeping a database transaction open for the entire duration of the app.

Middleware Pipeline

The Middleware Pipeline is the sequence of software components assembled into an application to handle HTTP requests and responses. In ASP.NET Core, every incoming request from the web server (Kestrel) passes through this pipeline. Each component, or "middleware," has the choice to either process the request and pass it to the next component in the sequence or "short-circuit" the pipeline by returning a response immediately, effectively stopping further execution.

This architecture is modular and highly efficient. Unlike legacy ASP.NET, which forced developers into a rigid, predefined lifecycle, ASP.NET Core starts with an empty pipeline. You only pay the performance cost for the features—such as static files, routing, or authentication—that you explicitly add.

How Middleware Works

Middleware components are executed in a bidirectional flow. When a request arrives, it travels "inward" through the middleware components in the order they were defined. Once a terminal middleware (like an API controller) generates a response, the execution flow reverses, traveling "outward" back through the same middleware chain. This allows components like a Logging middleware to record the start of a request on the way in and the final execution time on the way out.

The Order of Execution

The order in which middleware is registered in Program.cs is critical for application security and functionality. For instance, if you place the Static Files middleware before the Authorization middleware, all files in the wwwroot folder will be publicly accessible regardless of user permissions.

The following table outlines the standard, recommended order for common middleware components:

Order Middleware Purpose Technical Detail
1 ExceptionHandler Error Handling Catches exceptions from subsequent middleware and returns a friendly error page.
2 HSTS / HTTPS Redirection Security Enforces secure connections by redirecting HTTP traffic to HTTPS.
3 Static Files Resource Delivery Serves CSS, JS, and images; skips the rest of the pipeline if a file is found.
4 Routing Request Matching Analyzes the URL and selects the correct endpoint to execute.
5 CORS Cross-Origin Support Handles pre-flight requests for cross-domain API calls.
6 Authentication Identity Determines "Who" the user is based on tokens or cookies.
7 Authorization Permissions Determines "What" the authenticated user is allowed to do.
8 Endpoints Terminal Execution Executes the actual logic (e.g., Controllers, Razor Pages, or Minimal APIs).

Implementing Middleware in Code

Middleware is configured using the IApplicationBuilder (represented as app in modern Program.cs files). There are three primary methods for adding middleware: Use, Run, and Map.

  • Use: Chains multiple middleware components together. It receives a next delegate to call the subsequent component.
  • Run: Defines a terminal middleware. It does not receive a next delegate and always ends the pipeline.
  • Map: Branches the pipeline based on the request path.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// 1. Custom Middleware using app.Use
// This executes on the way IN and on the way OUT
app.Use(async (context, next) => 
{
    // Logic before the next middleware
    Console.WriteLine("Incoming Request: " + context.Request.Path);

    await next.Invoke(); // Call the next component

    // Logic after the next middleware
    Console.WriteLine("Outgoing Response: " + context.Response.StatusCode);
});

// 2. Branching the pipeline using app.Map
// Only executes if the URL starts with /health
app.Map("/health", healthApp => {
    healthApp.Run(async context => {
        await context.Response.WriteAsync("System Healthy");
    });
});

// 3. Terminal Middleware using app.Run
// This will stop the pipeline; nothing below this will execute for matched requests
app.Run(async context => {
    await context.Response.WriteAsync("Hello from the end of the pipeline!");
});

app.Run();

Short-Circuiting the Pipeline

Short-circuiting occurs when a middleware component returns a response without calling next.Invoke(). This is a powerful feature for performance and security. For example, the Static Files middleware short-circuits the pipeline if it finds a matching file on disk, preventing the overhead of the Routing or Authentication middleware for a simple .png file.

Warning: Be careful when short-circuiting. If you return a response in a middleware component placed before the CORS middleware, the browser may block the response because the necessary CORS headers were never added.

Custom Middleware Classes

For complex logic, it is a best practice to encapsulate middleware in a dedicated class rather than defining it inline in Program.cs. A standard middleware class requires a constructor that accepts a RequestDelegate and an InvokeAsync method that receives the HttpContext.

public class RequestCultureMiddleware
{
    private readonly RequestDelegate _next;

    public RequestCultureMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var cultureQuery = context.Request.Query["culture"];
        if (!string.IsNullOrWhiteSpace(cultureQuery))
        {
            var culture = new System.Globalization.CultureInfo(cultureQuery);
            System.Globalization.CultureInfo.CurrentCulture = culture;
            System.Globalization.CultureInfo.CurrentUICulture = culture;
        }

        // Call the next delegate/middleware in the pipeline
        await _next(context);
    }
}

Note: To use this class in your pipeline, you typically create an extension method for IApplicationBuilder, allowing you to call app.UseRequestCulture(); in your Program.cs file for better readability.

The Host and Generic Host

In ASP.NET Core, an application does not run on its own; it requires a Host to manage its lifecycle, resources, and underlying infrastructure. The Host is an object that encapsulates all of the app’s resources, including the HTTP server implementation, dependency injection containers, logging providers, and configuration systems. Modern .NET uses theGeneric Host (IHostBuilder), which is designed to support not only web applications but also non-HTTP workloads like background services, messaging consumers, and cron jobs, all using the same foundational patterns.

Role of the Host

The primary responsibility of the Host is to "bootstrap" the application. It ensures that all services required by the application are properly instantiated and that the application starts and stops gracefully. When the Host starts, it triggers the StartAsync method of every registered Hosted Service (background tasks), and when it shuts down, it ensures that these services have a chance to clean up resources, such as closing database connections or finishing the processing of a message queue.

Responsibility Description
Service Provider Initializes the IServiceProvider (DI container).
Configuration Aggregates settings from JSON files, environment variables, and command-line arguments.
Logging Configures the ILoggerFactory and registers logging sinks (Console, Debug, etc.).
Lifetime Management Controls the application startup and provides a CancellationToken for graceful shutdown.
Server Hosting In web contexts, it initializes and manages the Kestrel web server.

The Evolution: WebHost vs. Generic Host

In earlier versions of ASP.NET Core (1.x and 2.x), developers used the WebHostBuilder. While functional, it was tightly coupled to HTTP. Starting with .NET 3.0 and solidified in .NET 6/7/8, Microsoft moved to the Generic Host. This allows the same configuration patterns to be used for a Web API as well as a Windows Service or a Linux Daemon. In modern Program.cs files, the WebApplication.CreateBuilder(args) call is a specialized abstraction that wraps the Generic Host to provide a more streamlined experience for web developers.

Understanding WebApplicationBuilder

The WebApplicationBuilder is the modern implementation of the Host builder pattern. It follows a distinct workflow: you configure the builder (inputs), call Build() to freeze the configuration and DI container, and then use the resulting WebApplicationinstance to define the request pipeline.

// The builder initializes the Generic Host under the hood
var builder = WebApplication.CreateBuilder(args);

// Configuring the Host: Adding Services to DI
builder.Services.AddSingleton<IDateTimeProvider, SystemDateTimeProvider>();

// Configuring the Host: Adding Logging
builder.Logging.ClearProviders();
builder.Logging.AddConsole();

// The Build() method creates the 'Host' instance (the WebApplication)
var app = builder.Build();

// Accessing Host properties
var env = app.Environment;
var logger = app.Logger;

logger.LogInformation("The app is starting in {EnvName} mode", env.EnvironmentName);

app.MapGet("/", () => "Host is running.");

// The Run() method starts the Host and blocks the calling thread
app.Run();

Background Tasks and IHostedService

One of the greatest advantages of the Generic Host is the ability to run background tasks alongside your web application. By implementing the IHostedService interface or inheriting from the BackgroundService base class, you can create long-running logic that starts when the Host starts and stops when the Host stops.

public class MyTimedBackgroundService : BackgroundService
{
    private readonly ILogger<MyTimedBackgroundService> _logger;

    public MyTimedBackgroundService(ILogger<MyTimedBackgroundService> logger)
    {
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            _logger.LogInformation("Background task performing work at: {time}", DateTimeOffset.Now);
            await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
        }
    }
}

// Registration in Program.cs
// builder.Services.AddHostedService<MyTimedBackgroundService>();

Host Lifetime Events

The Host provides a way to hook into the application's lifetime events via the IHostApplicationLifetime interface. This is particularly useful for executing logic exactly when the application has fully started or right before it shuts down.

Event When it triggers
ApplicationStarted Triggered when the host has fully started.
ApplicationStopping Triggered when the host is performing a graceful shutdown. Requests may still be processing.
ApplicationStopped Triggered when the host has completed a graceful shutdown. All resources should be released.

Warning: Do not perform long-running or blocking operations inside the ApplicationStopping event. If the shutdown logic takes too long, the operating system or the container orchestrator (like Kubernetes) may forcefully terminate the process, potentially leading to data corruption or incomplete state.

Note: When using WebApplication.CreateBuilder, the Host automatically loads configuration from appsettings.json, appsettings.{Environment}.json, User Secrets (in Development), Environment Variables, and Command-line arguments in that specific order. This "last-in-wins" approach allows you to easily override settings for different deployment targets.

Configuration (appsettings.json and Environment Va

Configuration in ASP.NET Core is a robust, hierarchical system designed to aggregate settings from multiple sources into a single, unified view. Unlike older versions of .NET that relied on a static web.config file, the modern configuration framework is extensible and environment-aware. It allows developers to maintain a base set of settings while overriding specific values for development, testing, and production environments without changing the application code.

The Configuration Provider Model

The framework uses Configuration Providers, which read configuration data from various sources. By default, when you initialize a WebApplicationBuilder, the host loads configuration sources in a specific order of precedence. If a setting exists in multiple sources, the provider added last overrides the values from the previous providers.

The default order of precedence is as follows:

Order Source Use Case
1 appsettings.json Base settings applicable to all environments.
2 appsettings.{Environment}.json Environment-specific overrides (e.g., Development vs. Production).
3 User Secrets Local development only; used to store sensitive keys outside the project tree.
4 Environment Variables Cloud and container configuration (Docker, Azure, AWS).
5 Command-line Arguments Ad-hoc overrides provided when launching the application.

Working with appsettings.json

The appsettings.json file uses a hierarchical JSON structure. This allows you to group related settings logically. To access these settings in your code, you can use the IConfiguration interface, which provides a key-value pair abstraction of the flattened JSON tree.

{
  "ExternalServices": {
    "WeatherApi": {
      "BaseUrl": "https://api.weather.com",
      "ApiKey": "DefaultKey123",
      "TimeoutSeconds": 30
    }
  },
  "FeatureToggles": {
    "EnableNewDashboard": true
  }
}

To access the ApiKey in the example above, the configuration key would be ExternalServices:WeatherApi:ApiKey.

Environment Variables and Naming Conventions

Environment variables are particularly useful in CI/CD pipelines and Docker environments. Because environment variable names cannot always contain the colon (:) character used in JSON hierarchies, ASP.NET Core supports using a double underscore (__) as a separator.

Platform Variable Name Maps to JSON Path
Standard ExternalServices__WeatherApi__ApiKey ExternalServices:WeatherApi:ApiKey
Linux/Bash export ExternalServices__WeatherApi__ApiKey="SecretValue" ExternalServices:WeatherApi:ApiKey

The Options Pattern

While you can inject IConfiguration directly into your classes, the recommended best practice is the Options Pattern. This involves creating a plain old CLR object (POCO) class that represents a section of your configuration. This provides strong typing, validation, and better testability.

  1. Define the Options Class
  2. public class WeatherApiOptions
    {
        public const string SectionName = "ExternalServices:WeatherApi";
    
        public string BaseUrl { get; set; } = string.Empty;
        public string ApiKey { get; set; } = string.Empty;
        public int TimeoutSeconds { get; set; }
    }
  3. Register and Bind the Options
  4. In Program.cs, you bind the configuration section to the class:

    var builder = WebApplication.CreateBuilder(args);
    
    // Bind the configuration section to the WeatherApiOptions class
    builder.Services.Configure<WeatherApiOptions>(
        builder.Configuration.GetSection(WeatherApiOptions.SectionName));
  5. Inject the Options
  6. You use IOptions<T>, IOptionsSnapshot<T>, or IOptionsMonitor<T> to consume the settings:

public class WeatherService
{
    private readonly WeatherApiOptions _options;

    public WeatherService(IOptions<WeatherApiOptions> options)
    {
        // Accessing the strongly-typed settings
        _options = options.Value;
    }

    public void PrintConfig() => Console.WriteLine($"URL: {_options.BaseUrl}");
}

Options Interfaces Comparison

Interface Lifecycle Best Use Case
IOptions<T> Singleton Registered as a singleton; does not read config changes after startup.
IOptionsSnapshot<T> Scoped Useful for settings that should be re-read on every request.
IOptionsMonitor<T> Singleton Used to retrieve current options at any time; supports change notifications.

Warning: Never store production secrets (passwords, connection strings, or private keys) in appsettings.json. These files are often checked into source control (Git), which exposes your secrets. Use Environment Variables or a dedicated secret manager like Azure Key Vault or AWS Secrets Manager for production deployments.

Note: During local development, use the Secret Manager tool. Run dotnet user-secrets init in your project folder. This creates a secrets.json file stored in your local user profile directory, ensuring sensitive data never stays in your project repository.

The Options Pattern

The Options Pattern is the preferred architectural approach in ASP.NET Core for accessing configuration data. While the framework allows you to inject the raw IConfiguration object into your classes, doing so creates a "string-heavy" dependency that is difficult to unit test and prone to runtime errors due to typos. The Options Pattern solves these issues by using classes to represent groups of related settings, providing strong typing, validation, and a clear separation of concerns.

Why Use the Options Pattern?

By mapping configuration sections to Plain Old CLR Objects (POCOs), you adhere to two key software engineering principles: Encapsulation (your classes only depend on the settings they actually need) and Interface Segregation (classes are not burdened with the entire configuration tree).

Benefit Description
Strong Typing Access settings via properties (e.g., options.Timeout) rather than strings (e.g., config["Timeout"]).
Validation Use Data Annotations to ensure settings like port numbers or URLs are valid at startup.
Testability Easily mock settings in unit tests by passing a simple object instead of a complex configuration mock.
Reloading Support for "hot-reloading" settings without restarting the entire application.

Implementing the Options Pattern

To implement this pattern, you follow a three-step process: defining the schema class, registering it in the DI container, and injecting it into your services.

  1. Define the Options Class
  2. Create a class that matches the structure of a section in your appsettings.json.

    // Example appsettings.json section:
    // "StorageSettings": {
    //   "BlobContainerName": "uploads",
    //   "MaxFileSizeMb": 10
    // }
    
    public class StorageOptions
    {
        public const string SectionName = "StorageSettings";
    
        public string BlobContainerName { get; set; } = string.Empty;
        public int MaxFileSizeMb { get; set; }
    }
  3. Register the Options
  4. In Program.cs, bind the configuration section to your class using builder.Services.Configure<T>.

    var builder = WebApplication.CreateBuilder(args);
    
    // Binds the "StorageSettings" section to the StorageOptions class
    builder.Services.Configure<StorageOptions>(
        builder.Configuration.GetSection(StorageOptions.SectionName));
    
    var app = builder.Build();
  5. Inject and Consume
  6. Inject one of the Options interfaces into your class constructor.

    public class FileUploadService
    {
        private readonly StorageOptions _options;
    
        public FileUploadService(IOptions<StorageOptions> options)
        {
            // Use the .Value property to access the settings
            _options = options.Value;
        }
    
        public void CheckSize(int size)
        {
            if (size > _options.MaxFileSizeMb) 
            {
                throw new Exception("File too large!");
            }
        }
    }

Choosing the Right Interface

ASP.NET Core provides three primary interfaces for consuming options. Choosing the correct one depends on whether you need the settings to update while the app is running and how you plan to manage the service's lifetime.

Interface Registration Lifetime Characteristics
IOptions<T> Singleton Read only once at startup. Use this for settings that never change without a restart.
IOptionsSnapshot<T> Scoped Re-computed on every request. Ideal for settings that might change in appsettings.json while the app is running.
IOptionsMonitor<T> Singleton Provides a .CurrentValue property and a OnChange event. Best for long-running background tasks.

Warning: You cannot inject IOptionsSnapshot<T> into a Singleton service. Because IOptionsSnapshot is scoped, the DI container will throw a runtime exception to prevent "captured dependencies," where a shorter-lived object is held indefinitely by a longer-lived one. Use IOptionsMonitor<T> in Singletons instead.


Options Validation

To prevent the application from starting with invalid configuration (e.g., a missing API key), you can use Data Annotations or a custom validation delegate. This is known as Eager Validation.

using System.ComponentModel.DataAnnotations;

public class StorageOptions
{
    [Required]
    public string BlobContainerName { get; set; } = string.Empty;

    [Range(1, 100)]
    public int MaxFileSizeMb { get; set; }
}

// In Program.cs:
builder.Services.AddOptions<StorageOptions>()
    .Bind(builder.Configuration.GetSection(StorageOptions.SectionName))
    .ValidateDataAnnotations() // Ensures the rules above are met
    .ValidateOnStart();       // Throws exception immediately at startup if invalid

Note: ValidateOnStart() is a high-value best practice. It ensures that if a developer forgets to set a required environment variable in production, the application fails immediately during deployment rather than crashing later when a user attempts to access a specific feature.

Logging Providers

Logging is a first-class citizen in ASP.NET Core, providing a unified API that allows developers to record application behavior across a variety of destinations. The framework utilizes a Logging Provider model, which acts as an abstraction layer between your code and the underlying logging infrastructure. This means you can write a single log message in your application logic, and the logging system can simultaneously route that message to the console, a text file, a cloud-based monitoring service like Azure Application Insights, or a structured data store like Seq.

The ILogger Interface

To record logs, ASP.NET Core provides the ILogger<T> interface. The generic category T (usually the class name) is used to identify the source of the log message, which is invaluable when filtering logs during a debugging session. The framework's built-in Dependency Injection container automatically provides an implementation of this interface to any class that requests it.

Component Role
ILogger<T> The interface used by developers to write log entries.
ILoggerFactory The engine that creates logger instances and manages providers.
ILoggerProvider A destination for logs (e.g., Console, Debug, EventLog).
Log Level The severity of the message (e.g., Information, Warning, Error).

Log Levels and Severity

ASP.NET Core defines seven log levels to help categorize the importance of messages. Proper use of these levels allows you to filter out noise in production while retaining high-fidelity data for development.

Level Value Usage
Trace 0 Highly detailed messages, potentially containing sensitive data. Disabled by default.
Debug 1 Information useful during development and local troubleshooting.
Information 2 General flow of the application (e.g., "Order processed successfully").
Warning 3 Abnormal or unexpected events that don't stop the app (e.g., "API retry #1").
Error 4 Failures that affect the current operation but not the entire app.
Critical 5 Catastrophic failures requiring immediate attention (e.g., "Database connection lost").
None 6 Highest possible value; used to disable all logging.

Configuring Providers and Filtering

In Program.cs, the WebApplicationBuilder adds several providers by default, including the Console, Debug, and EventSource providers. You can customize these by clearing the defaults or adding third-party providers.

The filtering logic—determining which levels are captured for which categories—is typically managed in the appsettings.json file. This allows you to change logging verbosity without recompiling the application.

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "MyNamespace.Services": "Debug"
    }
  }
}

In the example above, the application will log "Information" and higher by default, but it will restrict "Microsoft.AspNetCore" logs to "Warning" or higher to reduce noise from the framework itself.

Implementation Example

To use logging, inject ILogger<TCategoryName> into your class constructor. Use structured logging (message templates) rather than string interpolation; this allows logging providers to index the parameters as searchable data fields rather than just flat text.

public class OrderService
{
    private readonly ILogger<OrderService> _logger;

    public OrderService(ILogger<OrderService> logger)
    {
        _logger = logger;
    }

    public void ProcessOrder(int orderId)
    {
        // Use message templates for structured logging
        _logger.LogInformation("Processing order with ID: {OrderId} at {Time}", orderId, DateTime.UtcNow);

        try
        {
            // Simulate logic
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "An error occurred while processing order {OrderId}", orderId);
        }
    }
}

Popular Third-Party Providers

While the built-in providers are excellent for basic needs, most enterprise applications use third-party "Serilog" or "NLog" libraries. These offer Structured Logging, which turns your log messages into searchable JSON objects.

Provider Description Key Feature
Serilog Highly popular structured logging library. "Sinks" for almost every database and cloud service.
NLog A flexible, long-standing logging framework. Advanced XML/Programmatic configuration.
Application Insights Microsoft's cloud monitoring tool. Deep integration with Azure and performance telemetry.

Warning: Avoid using Console.WriteLine() for logging in ASP.NET Core. It is synchronous and can lead to performance bottlenecks under high load. Additionally, it does not support log levels, filtering, or structured data output.

Note: Log messages can be grouped using Scopes. By calling _logger.BeginScope("TransactionId: {Id}", transId), every log message generated within that execution block will automatically include the TransactionId, making it much easier to correlate logs in a multi-threaded web environment.

Routing Concepts

Routing is the mechanism responsible for matching incoming HTTP requests to specific executable endpoints within an application. In ASP.NET Core, the routing system parses the URL path and HTTP method (GET, POST, etc.) and dispatches the request to a corresponding handler, such as a Controller action or a Minimal API lambda. This system is designed to be highly flexible, supporting complex URL patterns, optional parameters, and data constraints.

The Two Approaches to Routing

ASP.NET Core provides two distinct ways to define routes. While they share the same underlying engine, they cater to different architectural styles and project complexities.

Routing Type Definition Location Primary Use Case
Attribute Routing Directly on Controllers or Action methods via [Route] attributes. REST APIs and complex, non-standard URL structures.
Conventional Routing Centrally defined in Program.cs using templates. Standardized Web UI applications (MVC/Razor Pages).

Routes are defined using templates, which are string patterns that can contain literal text and placeholders (tokens). Tokens are wrapped in curly braces {} and represent variables that the routing engine will extract from the URL and pass to your code.

Template Example Matching URL Extracted Data
products/{id} /products/5 id = 5
blog/{year}/{slug} /blog/2026/routing-tips year = 2026, slug = routing-tips
search/{term?} /search or /search/net term is optional
files/{*filepath} /files/images/logo.png filepath = images/logo.png (Catch-all)

Route Constraints

To prevent a route from matching invalid data, you can apply constraints. Constraints ensure that a placeholder matches a specific data type or pattern (like an integer or a GUID). If the URL segment does not satisfy the constraint, the routing engine skips that route and continues searching for a better match.

// Example of a route with an integer constraint
app.MapGet("/users/{id:int}", (int id) => $"User ID: {id}");

// Example of a route with a length constraint
app.MapGet("/posts/{slug:minlength(5)}", (string slug) => $"Post: {slug}");

Endpoint Routing Middleware

Routing is implemented as two separate middleware components in the pipeline: UseRouting and UseEndpoints.

  1. UseRouting: Matches the incoming request to an endpoint. It examines the URL and decides which "endpoint" (action) should execute, but it does not execute it yet.
  2. UseEndpoints (or Map methods): Executes the matched endpoint.

This separation allows other middleware—like Authorization or CORS—to see which endpoint was selected and make decisions (e.g., "Does this specific user have permission to access the 'Admin' endpoint?") before the actual code runs.

Implementation: Attribute Routing

Attribute routing is the standard for modern API development because it keeps the route definition close to the logic.

[ApiController]
[Route("api/[controller]")] // [controller] is a token for 'Products'
public class ProductsController : ControllerBase
{
    [HttpGet("{id:int}")] // Matches GET api/products/5
    public IActionResult GetProduct(int id)
    {
        return Ok($"Returning product {id}");
    }

    [HttpPost("upload")] // Matches POST api/products/upload
    public IActionResult CreateProduct()
    {
        return Created();
    }
}

Route Precedence and Ambiguity

When multiple routes could potentially match a single URL, ASP.NET Core uses a scoring system to determine the "best match." More specific routes (those with more literal segments) take precedence over generic ones.

Warning: If the routing engine finds two routes that are equally "specific" for the same URL, it will throw an AmbiguousMatchException at runtime. For example, orders/{id:int} and orders/{name:alpha} would both match /orders/123 unless the constraints strictly separate them.

Note: Use the [controller] and [action] tokens in your attributes to avoid hardcoding class and method names. This ensures that if you rename your controller, your routes update automatically to reflect the new name.

Error Handling and Exception Filters

Error handling in ASP.NET Core is a multi-layered system designed to capture failures at different stages of the request-response lifecycle. While standard C# try-catch blocks are used for localized logic, the framework provides global mechanisms to handle unhandled exceptions gracefully. This ensures that users receive a professional error response (rather than a raw stack trace) and that developers receive the diagnostic information needed to fix the issue.

The Error Handling Middleware

The most robust way to handle global exceptions is through the Exception Handling Middleware. This component is placed at the very beginning of the middleware pipeline in Program.cs. Because of its position, it can catch any exception thrown by subsequent middleware, controllers, or database calls.

The behavior of this middleware typically changes based on the application's environment. In Development, it provides a rich, interactive "Developer Exception Page." In Production, it redirects the user to a generic error path or provides a structured JSON response for APIs.

Feature Environment Behavior
Developer Exception Page Development Shows stack traces, query strings, cookies, and HTTP headers.
Exception Handler Lambda Production Executes a custom logic block to return a standard error UI or JSON.
Status Code Pages All Intercepts 4xx errors (like 404) to provide custom content for missing routes.

Implementation in Program.cs

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    // Provides detailed diagnostic info to the developer
    app.UseDeveloperExceptionPage();
}
else
{
    // Provides a custom error handling path for end users
    app.UseExceptionHandler("/error");
    // Enforces HSTS (Security best practice)
    app.UseHsts();
}

app.UseHttpsRedirection();
app.MapGet("/error", () => "A technical error occurred. Please try again later.");

Exception Filters

While middleware catches everything in the pipeline, Exception Filters are specific to the MVC and Web API layers. They run after the routing engine has selected a controller and action. Exception filters are ideal for handling exceptions that require context about the specific controller or action being executed—such as logging a specific "Product ID" that failed to load.

Exception filters implement either the IExceptionFilter or IAsyncExceptionFilter interface. They are often used to map specific domain exceptions (like EntityNotFoundException) to specific HTTP status codes (like 404 Not Found).

Method Description
OnException Called when an action method throws an unhandled exception.
context.Exception Accesses the raw Exception object.
context.Result If set, it short-circuits the request and sends the result to the client.
context.ExceptionHandled A boolean that, if set to true, prevents the exception from bubbling up to the middleware.

Custom Exception Filter Example

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;

public class HttpResponseExceptionFilter : IActionFilter, IOrderedFilter
{
    public int Order => int.MaxValue - 10;

    public void OnActionExecuting(ActionExecutingContext context) { }

    public void OnActionExecuted(ActionExecutedContext context)
    {
        if (context.Exception is UnauthorizedAccessException)
        {
            context.Result = new ObjectResult("You do not have permission.")
            {
                StatusCode = 403
            };
            context.ExceptionHandled = true;
        }
    }
}

Comparison: Middleware vs. Filters

Choosing between middleware and filters depends on the scope of the error handling you require.

Criteria Middleware Exception Filters
Scope Global (catches everything in the app). Limited to MVC/Web API actions.
Context Access to HttpContext only. Access to ActionContext (Route data, Model state).
Usage Best for generic errors and logging. Best for transforming specific domain errors into API responses.
Execution Runs outside the MVC Action Invoker. Runs inside the MVC Action Invoker.

Problem Details for APIs

For modern Web APIs, the best practice for returning errors is the Problem Details specification (RFC 7807). This provides a standardized machine-readable format for errors, making it easier for client applications (like React or Angular) to parse the failure reason.

// In Program.cs
builder.Services.AddProblemDetails();

// In an API Controller
[HttpGet("{id}")]
public IActionResult GetItem(int id)
{
    if (id < 0)
    {
        return Problem(
            detail: "The ID must be a positive integer.",
            statusCode: 400,
            title: "Invalid Parameter"
        );
    }
    return Ok();
}

Warning: Never expose raw Exception messages or stack traces in a Production environment. This provides attackers with detailed information about your server's file structure, library versions, and database schema, creating a significant security vulnerability.

Note: Use the UseStatusCodePages middleware to handle cases where no exception is thrown but the response has a failure status code (e.g., 404 Not Found). This ensures that even "Page Not Found" errors follow your application's design and branding.

Web Apps (Razor Pages & MVC) Last updated: March 1, 2026, 8:48 p.m.

For server-side web development, ASP.NET Core offers two primary patterns: Model-View-Controller (MVC) and Razor Pages. MVC is the classic architectural pattern that separates an application into three main components, making it ideal for large, complex sites where clear separation of concerns is a priority. Razor Pages, on the other hand, is a page-centric model that simplifies the development of forms and data-heavy pages by keeping the logic and the UI closely related, reducing the "ceremony" required to build functional web interfaces.

Both frameworks utilize the Razor Syntax, a powerful templating engine that allows you to blend C# with HTML seamlessly. Features like Tag Helpers enable developers to write server-side code that looks like standard HTML, improving readability and maintainability. Whether you choose MVC for its architectural rigidity or Razor Pages for its productivity, both benefit from a shared infrastructure for model binding, validation, and layout management.

Introduction to Razor Pages (Page-based UI)

Razor Pages is the recommended framework for building cross-platform, server-side rendered web applications in ASP.NET Core. While the Model-View-Controller (MVC) pattern focuses on separating an application into three distinct layers, Razor Pages adopts a page-centric approach. Each page is a self-contained unit that encapsulates its own view (HTML/Razor) and its own logic (C#), making it significantly more intuitive for building features like forms, profile pages, or dashboards.

This model is built on top of the same infrastructure as MVC, utilizing the same routing engine, tag helpers, and model binding. However, it reduces architectural complexity by grouping the files associated with a single feature together, adhering to the principle of "high cohesion."

The File-Pair Structure

A Razor Page consists of two primary files located within the /Pages directory. This pairing creates a clean separation between the presentation layer and the backend processing logic without the overhead of maintaining separate Controllers and Views across the project directory tree.

File Type Extension Responsibility
View .cshtml Contains HTML markup and Razor syntax for rendering the UI.
PageModel .cshtml.cs A C# class that handles HTTP requests (GET, POST) and manages data for the view.

Routing and the Pages Directory

Razor Pages uses a convention-based routing system centered around the /Pages folder. By default, the URL path to a page is determined by its file path relative to this folder. This eliminates the need to manually define routes for every page in the application.

File Location Resulting URL
Pages/Index.cshtml / or /Index
Pages/Contact.cshtml /Contact
Pages/Inventory/Details.cshtml /Inventory/Details
Pages/Shared/_Layout.cshtml N/A (Shared files starting with _ are not routable)

The PageModel and Handler Methods

The PageModel class serves as both a controller and a data transfer object. It uses Handler Methods—prefixed with On and the HTTP verb—to respond to incoming requests. Data is shared between the C# code and the HTML view via public properties.

Implementation Example: The PageModel

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

public class ContactModel : PageModel
{
    // Properties are automatically accessible in the .cshtml file
    [BindProperty]
    public string Message { get; set; } = string.Empty;

    public string ServerTime { get; set; } = string.Empty;

    // Triggered on an HTTP GET request
    public void OnGet()
    {
        ServerTime = DateTime.Now.ToString("T");
    }

    // Triggered on an HTTP POST request (e.g., form submission)
    public IActionResult OnPost()
    {
        if (!ModelState.IsValid)
        {
            return Page();
        }

        // Logic to process the message...
        return RedirectToPage("Index");
    }
}

Implementation Example: The View (.cshtml)

The view file uses the @page directive at the top, which tells ASP.NET Core that this file is a routable Razor Page rather than a standard MVC view.

@page
@model ContactModel

<h2>Contact Us</h2>
<p>The current server time is: @Model.ServerTime</p>

<form method="post">
    <div class="form-group">
        <label asp-for="Message">Your Message:</label>
        <textarea asp-for="Message" class="form-control"></textarea>
    </div>
    <button type="submit" class="btn btn-primary">Send</button>
</form>

Key Razor Directives

Razor Pages rely on specific directives to control the behavior of the page and link the markup to the underlying logic.

Directive Description
@page Must be the first line. Converts the file into a Razor Page.
@model Specifies the type of the PageModel associated with the page.
@using Adds namespace references for the C# code within the page.
@inject Allows for direct Dependency Injection into the view.

Note: The [BindProperty] attribute is essential for POST requests. It tells the framework to automatically populate the property with data from the submitted form, saving you from manually reading Request.Form.

Warning: Never omit the @page directive at the top of your .cshtml file in the /Pages directory. Without it, the routing engine will not recognize the file as a Razor Page, and you will receive a 404 Not Found or a compilation error when trying to access the URL.

Introduction to Model-View-Controller (MVC)

Model-View-Controller (MVC) is a classic architectural pattern that separates an application into three main logical components: the Model, the View, and the Controller. In ASP.NET Core, the MVC framework provides a powerful, patterns-based way to build dynamic websites that enables a clean separation of concerns. This separation helps manage complexity when building large-scale applications, as it allows developers to work on the user interface, business logic, and data access layers independently.

The Three Pillars of MVC

The MVC pattern is defined by the distinct responsibilities of its three components. By strictly adhering to these roles, the application becomes easier to test, maintain, and evolve over time.

Component Responsibility Technical Implementation
Model Represents the data and business logic. C# classes (POCOs) often mapped to a database via Entity Framework.
View Manages the display of information (UI). .cshtml files using Razor syntax to render HTML.
Controller Handles user input and coordinates the Model and View. C# classes inheriting from Controller that contain action methods.

The MVC Request Lifecycle

When a request reaches an MVC application, the Routing engine determines which Controller and Action should handle it. The Controller then interacts with the Model to retrieve or update data. Finally, the Controller selects a View, passes the Model data to it, and the View generates the final HTML response sent back to the client's browser.

  1. The Model
  2. The Model is responsible for the state of the application. It should be "thin" regarding UI logic but "fat" regarding business rules.

    namespace MyApp.Models
    {
        public class Product
        {
            public int Id { get; set; }
            public string Name { get; set; } = string.Empty;
            public decimal Price { get; set; }
            public bool IsInStock { get; set; }
        }
    }
  3. The Controller
  4. Controllers are the brain of the operation. They process incoming requests, perform validation, and decide which View to return. Action methods within the controller typically return an IActionResult.

    using Microsoft.AspNetCore.Mvc;
    using MyApp.Models;
    
    public class ProductController : Controller
    {
        // GET: /Product/Details/5
        public IActionResult Details(int id)
        {
            // In a real app, this would come from a database
            var product = new Product { Id = id, Name = "Laptop", Price = 999.99m };
    
            if (product == null)
            {
                return NotFound();
            }
    
            return View(product); // Passes the model to the View
        }
    }
  5. The View
  6. The View transforms the Model into a visual representation. In ASP.NET Core, Views use the .cshtml extension and leverage Razor Syntax to transition between HTML and C#.

    @model MyApp.Models.Product
    
    <h1>@Model.Name</h1>
    <table class="table">
        <tr>
            <th>Price</th>
            <td>@Model.Price.ToString("C")</td>
        </tr>
        <tr>
            <th>Status</th>
            <td>@(Model.IsInStock ? "Available" : "Out of Stock")</td>
        </tr>
    </table>

Comparison: MVC vs. Razor Pages

While both are built on the same engine, they suit different project structures. MVC is often preferred for applications with a vast number of complex actions or when building a single controller that manages multiple related views.

Feature MVC Razor Pages
Organization Folder-based (Controllers, Views, Models). Feature-based (Code and UI kept together).
Complexity Higher boilerplate; good for large systems. Leaner; excellent for read/write forms.
Routing Often uses Conventional Routing. Uses File-based Routing.
Separation Strict separation of logic and UI. Logical separation via the PageModel class.

Conventional Routing in MVC

Unlike Razor Pages, which routes based on file location, MVC typically uses a "Convention" defined in Program.cs. This template tells the framework how to map a URL like /Product/Details/5 to the correct code.

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");

Note: The id? in the route pattern indicates that the ID parameter is optional. If the user visits /Product, the framework will look for an Index action by default.

Warning: To avoid "Fat Controllers," ensure that heavy business logic and database queries reside in a Service Layer or within the Model. Controllers should only be responsible for orchestrating the flow between the request, the services, and the view.

The Razor Syntax

Razor is a markup syntax that lets you embed server-side C# code into web pages. It is not a programming language itself, but a templating engine that transitions seamlessly between HTML and C#. The primary goal of Razor is to provide a "fluid" coding workflow, allowing you to mix markup and logic without the need for heavy, explicit delimiters like those found in older technologies.

When a Razor file (.cshtml) is requested, the server executes the C# code blocks within the page before generating the final HTML sent to the browser. This enables the dynamic rendering of data, conditional formatting, and the use of complex loops to generate repetitive UI elements.

Basic Transitions: The @ Character

The @ character is the magic symbol that initiates the transition from HTML to C#. Razor is intelligent enough to infer where a C# expression ends and HTML resumes based on the code's structure.

Syntax Type Example Description
Implicit Expression <span>@DateTime.Now</span> Directly renders the result of a C# expression as a string.
Explicit Expression <span>@(value + 10)</span> Uses parentheses to define the exact boundaries of a complex calculation.
Code Block @{ int x = 5; } Defines a block of code that executes logic but renders nothing directly.
Escaped Symbol Contact @@twitter Uses a double @ to render a literal "at" symbol in the HTML.

Control Structures

Razor supports the full suite of C# control structures, including loops and conditionals. This allows for powerful logic directly within the view to determine what the user sees based on the state of the Model.

Conditionals

You can use if, else if, and else statements to render different HTML fragments. Razor handles the transition back to HTML automatically inside the curly braces.

@if (Model.StockCount > 10)
{
    In Stock
}
else if (Model.StockCount > 0)
{
    Low Stock (@Model.StockCount left)
}
else
{
    Out of Stock
}

Loops

Loops are essential for rendering lists or tables of data. The @foreach loop is the most commonly used structure in ASP.NET Core views.

<ul>
    @foreach (var item in Model.Items)
    {
        <li>@item.Name - @item.Price.ToString("C")</li>
    }
</ul>

Razor Directives

Directives are special keywords that provide instructions to the Razor engine. They typically appear at the very top of the .cshtml file and control how the page is compiled or what data it expects.

Directive Purpose
@model Defines the type of the data object passed to the view.
@using Imports a namespace so you don't have to use fully qualified names.
@inject Injects a service from the DI container directly into the view.
@layout Specifies the master template file for the current page.
@section Defines a block of content to be rendered in a specific place in the layout.

Handling Text, HTML, and Comments

Sometimes you need to render plain text inside a C# code block without wrapping it in an HTML tag. Razor provides the <text> tag or the @: transition for this specific purpose.

  • The <text> tag Used for multi-line plain text.
  • The @: symbol Used for a single line of plain text.
@{
    if (user.IsAdmin)
    {
        <text>The user is an <strong>Administrator</strong>.</text>
    }
    else
    {
        @:The user is a standard member.
    }
}

Razor Comments

Standard HTML comments (``) are sent to the browser and are visible in the "View Source" window. To write comments that are stripped out before the page is sent to the client, use Razor comments.

@* This is a server-side comment. It will not appear in the browser. *@

Warning: Razor automatically HTML-encodes strings rendered via @. This protects your application against Cross-Site Scripting (XSS) attacks. If you explicitly need to render raw HTML from a string variable, you must use @Html.Raw(myVariable), but use this with extreme caution.

Note: Keep your Razor views "clean" by avoiding heavy business logic inside @ { ... } blocks. If you find yourself writing complex algorithms or database queries in a view, move that logic to the PageModel, the Controller, or a dedicated Service.

Tag Helpers and HTML Helpers

In ASP.NET Core, Tag Helpers and HTML Helpers are the two primary mechanisms used to generate HTML elements programmatically within Razor views. While both serve the purpose of bridging C# code and HTML markup, they differ significantly in syntax and philosophy. Tag Helpers are the modern standard, offering an "HTML-friendly" experience that integrates directly into standard tags, whereas HTML Helpers are the legacy approach, utilizing C# method calls to render content.

Tag Helpers

Tag Helpers enable server-side code to participate in creating and rendering HTML elements in Razor files. They look and feel like standard HTML tags, but they are processed by the Razor engine on the server. This makes the transition between designer-friendly HTML and developer-centric logic seamless. Tag Helpers are distinguished by their bold purple syntax in most IDEs (like Visual Studio) and typically use the asp- prefix for their attributes.

Benefits of Tag Helpers

  • HTML Naturalness: Since they look like standard HTML, they do not break the design flow or tooling for front-end developers.
  • IntelliSense Support: They provide rich code completion for both the HTML element and the C# model properties.
  • Cleaner Markup: They reduce the "spaghetti code" feel often associated with mixing C# method calls inside HTML.
<a asp-controller="Product" asp-action="Details" asp-route-id="@Model.Id" class="btn btn-primary">
    View Details
</a>

<form asp-action="Register" method="post">
    <label asp-for="Email"></label>
    <input asp-for="Email" class="form-control" />
    <span asp-validation-for="Email" class="text-danger"></span>
    <button type="submit">Submit</button>
</form>

HTML Helpers

HTML Helpers are older, method-based abstractions. They are invoked as C# methods through the @Html property in a Razor view. While they are still fully supported in ASP.NET Core for backward compatibility, they are generally less preferred for new development because they wrap HTML in C# strings or methods, which can make the UI code harder to read and maintain.

Characteristics of HTML Helpers

  • Explicit C#: They use @Html.ActionLink, @Html.EditorFor, etc.
  • Harder to Style: Adding CSS classes often requires passing an anonymous object (e.g., new { @class = "btn" }), which is syntactically clunky compared to standard HTML attributes.
Feature Tag Helpers HTML Helpers
Syntax Style HTML-like (<input asp-for="...">) C# Method (@Html.TextBoxFor(...))
Front-end Friendly Yes; designers can read/edit easily. No; looks like broken HTML to designers.
IntelliSense Deep integration with HTML and C#. Primarily C# IntelliSense only.
Extensibility Easy to create custom tags/attributes. Requires writing extension methods.

Common Built-in Tag Helpers

ASP.NET Core provides a wide array of built-in Tag Helpers to handle common web development tasks like linking, form processing, and image optimization.

Tag Helper Purpose Key Attributes
Anchor Generates URLs for links. asp-controller, asp-action, asp-route-{value}
Form Manages form submission and anti-forgery tokens. asp-action, asp-controller, asp-area
Input/Label Binds model properties to form fields. asp-for
Validation Displays server-side validation messages. asp-validation-for, asp-validation-summary
Image Adds cache-busting versions to image URLs. asp-append-version="true"
Environment Renders content based on the environment. names="Development,Production"

Enabling Tag Helpers

To use Tag Helpers in your application, you must register them in a special file called _ViewImports.cshtml. This makes the Tag Helpers available to all views in that folder and its subfolders.

@* _ViewImports.cshtml *@
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

Note: The asp-append-version="true" attribute on the Image Tag Helper is a performance "hidden gem." It automatically appends a unique hash to the image URL based on the file content. If the file changes, the hash changes, forcing the browser to download the new version instead of using a cached stale copy.

Warning: Be careful when using Tag Helpers and HTML Helpers together on the same element. While technically possible, it leads to confusing code and unpredictable rendering results. It is a best practice to stick to Tag Helpers for all modern ASP.NET Core projects.

Partial Views and View Components

In ASP.NET Core, building a maintainable UI requires breaking down complex pages into smaller, reusable building blocks. While Layouts provide the overall shell of a site, Partial Views and View Components allow you to encapsulate specific UI fragments. Choosing between them depends on whether the fragment is purely for display or if it requires its own independent logic and data access.


Partial Views

A Partial View is a Razor markup file (.cshtml) that renders a portion of the HTML output. It is essentially a "sub-view" that lives within another view. Partial views are ideal for breaking up large files into manageable pieces or for reusing static/simple UI elements across multiple pages, such as a subscription footer or a standard set of navigation links.

Partial views have access to the ViewData and Model of the parent page, though you can also pass a specific model directly to them.

Implementation: Rendering a Partial View

You use the <partial> Tag Helper to include a partial view. By convention, partial view filenames often start with an underscore (_) to indicate they are not full, routable pages.

<h1>@Model.Product.Name</h1>

<partial name="_ProductSpecifications" model="Model.Product.Specs" />

<div class="reviews">
    <partial name="_UserReviews" />
</div>

View Components

View Components are more powerful than partial views. They are intended for "autonomous" UI logic that doesn't belong in the main Page or Controller. Think of a View Component as a "mini-controller"—it has its own class to handle logic (like fetching data from a database) and its own Razor view to render that data.

Common use cases for View Components include:

  • Dynamic navigation menus.
  • Shopping carts.
  • Login panels.
  • A "Recently Published" sidebar on a blog.

The View Component Class

A View Component consists of a class (typically inheriting from ViewComponent) and a corresponding view file located in a specific folder path: /Pages/Components/{ComponentName}/Default.cshtml.

public class PriorityListViewComponent : ViewComponent
{
    private readonly MyDbContext _db;

    public PriorityListViewComponent(MyDbContext db)
    {
        _db = db;
    }

    // This method is called when the component is rendered
    public async Task<IViewComponentResult> InvokeAsync(int maxPriority)
    {
        var items = await _db.TodoItems
            .Where(x => x.IsDone == false && x.Priority <= maxPriority)
            .ToListAsync();
            
        return View(items);
    }
}

Invoking the View Component

View Components are invoked using the <vc> Tag Helper or the @await Component.InvokeAsync method.

<vc:priority-list max-priority="2"></vc:priority-list>

@await Component.InvokeAsync("PriorityList", new { maxPriority = 2 })

Comparison: Partial Views vs. View Components

Feature Partial Views View Components
Logic Limited; uses logic from the parent. Independent; has its own C# class.
Data Access Relies on parent to provide the model. Can inject services and fetch its own data.
Testability Hard to unit test in isolation. Highly testable as a separate class.
Complexity Low; just a markup fragment. Higher; requires a class and a folder structure.
Use Case Reusable HTML/Static content. Complex, dynamic widgets (e.g., sidebars).

Folder Conventions

The location of these files is critical. If the files are not in the correct directories, the Razor engine will fail to locate them at runtime.

Type Default Search Path
Partial Views Same folder as the calling view OR
/Pages/Shared/_Name.cshtml
View Components /Pages/Components/{Name}/Default.cshtml OR
/Views/Shared/Components/{Name}/Default.cshtml

Warning: Do not put heavy business logic or long-running tasks inside a Partial View. Since Partial Views share the calling view's execution context, a slow partial view will block the rendering of the entire parent page. For data-heavy logic, always use a View Component with an asynchronous InvokeAsync method.

Note: View Components do not participate in the full controller lifecycle. They do not use Filters or Model Binding for the request. They only receive data through the parameters passed during the invocation call.

Model Binding

Model Binding is the automated process that maps data from HTTP requests (query strings, form fields, route values, and headers) directly into action method parameters or properties of a PageModel. This mechanism eliminates the need for manual data extraction from the HttpRequest object, such as calling Request.Form["Email"] or Request.Query["id"]. The model binder is responsible for converting string-based HTTP data into strongly-typed C# objects, including primitives, complex types, and collections.


Sources of Data

The model binding engine looks for data in a specific order of precedence. If multiple sources provide a value for the same parameter name, the binder typically uses the first successful match it finds. You can override this behavior using specific attributes to force the binder to look in a particular location.

Source Attribute Description
Form Values [FromForm] Data posted from an HTML form (application/x-www-form-urlencoded).
Route Values [FromRoute] Data extracted from the URL segments defined in the route template.
Query Strings [FromQuery] Parameters appended to the URL (e.g., ?id=5&name=bob).
Request Body [FromBody] Data sent in the body of the request, usually as JSON or XML.
Headers [FromHeader] Metadata sent in the HTTP headers.
Services [FromServices] Resolves the parameter from the Dependency Injection container.

Binding to Simple vs. Complex Types

The binder handles different data structures based on the signature of your action method or the properties of your PageModel.


Simple Types

For simple types like int, string, bool, or Guid, the binder looks for a match by name. It is case-insensitive, meaning a query string of ?categoryId=10 will successfully bind to a parameter named categoryid.

Complex Types

For classes (POCOs), the binder uses reflection to match the names of the incoming data keys with the property names of the class. It recursively traverses the object graph to bind nested properties.

// The Model Class
public class UserProfile
{
    public string Username { get; set; } = string.Empty;
    public int Age { get; set; }
    public Address Location { get; set; } = new();
}

public class Address
{
    public string City { get; set; } = string.Empty;
}

// The Action Method
[HttpPost]
public IActionResult Update(UserProfile profile) 
{
    // The binder will look for:
    // Username, Age, Location.City
    return Ok(profile);
}

Model Binding in Razor Pages

In Razor Pages, model binding works slightly differently than in MVC. Instead of method parameters, you typically bind data to properties of the PageModel using the [BindProperty] attribute. By default, [BindProperty] only binds data from HTTP POST requests. To enable binding on GET requests (common for search filters), you must set the SupportsGet property to true.

public class SearchModel : PageModel
{
    // Binds on POST by default
    [BindProperty]
    public string Email { get; set; } = string.Empty;

    // Explicitly enable binding for GET requests
    [BindProperty(SupportsGet = true)]
    public string SearchTerm { get; set; } = string.Empty;

    public void OnGet()
    {
        // SearchTerm is already populated here
    }
}

Validation and ModelState

Model binding does not just move data; it also prepares the application for Model Validation. Once the binder completes its work, it updates the ModelState dictionary. This dictionary tracks whether the conversion was successful (e.g., if "abc" was sent for an int field, it marks an error) and whether any Data Annotation rules (like [Required]) were violated.

Property Description
ModelState.IsValid Returns true if all bound values passed both conversion and validation rules.
ModelState.ErrorCount Returns the total number of errors found during binding and validation.
ModelState.Values Contains the raw and attempted values for every property.
[HttpPost]
public IActionResult Create(Product product)
{
    if (!ModelState.IsValid)
    {
        // Return the view so the user can see validation errors
        return View(product);
    }

    // Proceed with saving the data
    return RedirectToAction("Index");
}

Warning: Always check ModelState.IsValid before processing data in a POST or PUT action. Even if the data type conversion is successful, the data may be malicious or logically invalid (e.g., a negative price). Ignoring this check can lead to data corruption or security vulnerabilities.

Note: The [FromBody] attribute is unique because it uses Input Formatters (like JSON.NET or System.Text.Json) rather than the standard model binding logic. You can only have one [FromBody] parameter per action method because the request body is a forward-only stream that can only be read once.

Model Validation (Data Annotations)

Model Validation is the process of ensuring that the data received by an application conforms to specific business rules and security requirements before it is processed or persisted. In ASP.NET Core, this is primarily achieved through Data Annotations, which are declarative attributes applied directly to the properties of a Model or PageModel. This approach centralizes validation logic within the data structure itself, allowing the framework to automatically enforce rules during the model binding process and provide immediate feedback to the user.


Common Validation Attributes

ASP.NET Core provides a comprehensive set of built-in attributes located in the System.ComponentModel.DataAnnotations namespace. These attributes cover the most frequent validation scenarios, from ensuring a field is not empty to enforcing complex regular expression patterns.

Attribute Purpose Example
[Required] Ensures the property is not null or empty. [Required(ErrorMessage = "Name is required")]
[StringLength] Enforces minimum and maximum character limits. [StringLength(100, MinimumLength = 5)]
[Range] Restricts numeric values within a specific span. [Range(1, 500)]
[EmailAddress] Validates that the string follows a valid email format. [EmailAddress]
[Compare] Ensures two properties match (e.g., Password and Confirm). [Compare("Password")]
[RegularExpression] Validates the string against a custom Regex pattern. [RegularExpression(@"^[A-Z]+[a-zA-Z]*$")]

Implementation in the Model

To implement validation, you decorate your class properties with the relevant attributes. You can also customize the error messages displayed to the user by using the ErrorMessage parameter within the attribute.

public class UserRegistration
{
    [Required]
    [Display(Name = "Username")]
    public string Username { get; set; } = string.Empty;

    [Required]
    [EmailAddress]
    public string Email { get; set; } = string.Empty;

    [Required]
    [DataType(DataType.Password)]
    [StringLength(100, MinimumLength = 8)]
    public string Password { get; set; } = string.Empty;

    [Compare("Password", ErrorMessage = "The passwords do not match.")]
    public string ConfirmPassword { get; set; } = string.Empty;
}

Client-Side vs. Server-Side Validation

ASP.NET Core supports a dual-layer validation strategy. While server-side validation is mandatory for security, client-side validation improves the user experience by providing instant feedback without requiring a round-trip to the server.

  1. Server-Side Validation: The framework evaluates Data Annotations after model binding. The results are stored in the ModelState object. You must check ModelState.IsValid in your controller or PageModel to decide whether to save the data or return the form with errors.
  2. Client-Side Validation: By including the jQuery Validation scripts in your view, the framework translates Data Annotations into HTML5 data-val attributes. The browser then enforces these rules via JavaScript before the form is even submitted.

Enabling Client-Side Validation in Razor

To enable this feature, you must reference the validation script partial in your Razor page (usually at the bottom of the file).

@section Scripts {
    <partial name="_ValidationScriptsPartial" />
}

Displaying Validation Errors in the UI

Tag Helpers make it easy to display error messages. The asp-validation-for helper displays the error for a specific field, while the asp-validation-summary helper can display a bulleted list of all errors at the top of the form.

<form asp-action="Register">
    <div asp-validation-summary="ModelOnly" class="text-danger"></div>

    <label asp-for="Email"></label>
    <input asp-for="Email" class="form-control" />
    <span asp-validation-for="Email" class="text-danger"></span>

    <button type="submit">Register</button>
</form>

Custom Validation Logic

If the built-in attributes are insufficient, you can create a Custom Validation Attribute by inheriting from ValidationAttribute and overriding the IsValid method. This is useful for business-specific rules, such as checking if a date is in the future or if a username is already taken (though database checks are often better handled in the controller or service layer).

public class FutureDateAttribute : ValidationAttribute
{
    protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
    {
        if (value is DateTime dateTime && dateTime <= DateTime.Now)
        {
            return new ValidationResult("The date must be in the future.");
        }
        return ValidationResult.Success;
    }
}

Warning: Client-side validation is a convenience, not a security feature. Malicious users can easily bypass JavaScript validation by using tools like Postman or by disabling JS in the browser. Always perform a server-side check using if (!ModelState.IsValid) to protect your application's integrity.

Note: The [DataType] attribute (e.g., [DataType(DataType.Date)]) does not actually provide validation. Instead, it provides a hint to the Razor engine to render the appropriate HTML5 input type (like <input type="date">) and applies default formatting.

Web APIs Last updated: March 1, 2026, 8:50 p.m.

ASP.NET Core is a premier choice for building RESTful services and Web APIs that serve as the backbone for mobile apps, SPAs (Single Page Applications), and microservices. Modern APIs in .NET lean heavily on Minimal APIs, an approach that allows developers to create high-performance endpoints with just a few lines of code, or the traditional Controller-based approach for more structured, large-scale APIs. The framework automatically handles the serialization of data to formats like JSON using high-speed libraries.

A key strength of the Web API section is its deep integration with OpenAPI (Swagger) and the Problem Details standard. These tools ensure that your API is not only easy to build but also easy to consume and debug for other developers. With built-in support for content negotiation, versioning, and link generation, ASP.NET Core provides a comprehensive toolkit for building APIs that are scalable, maintainable, and compliant with modern web standards.

Creating RESTful Services

ASP.NET Core provides a robust framework for building RESTful (Representational State Transfer) services that allow different systems to communicate over HTTP. Unlike traditional web pages that return HTML, Web APIs are designed to return data—typically in JSON format—allowing them to serve as the backend for modern frontend frameworks like React, mobile applications, and IoT devices. The architecture is centered around resources (data entities) and the standard HTTP verbs used to manipulate them.

In ASP.NET Core, Web APIs are built using Controllers that inherit from ControllerBase. This base class provides essential functionality for handling HTTP requests without the overhead of View-related features required by MVC websites.

The Principles of REST in ASP.NET Core

A truly RESTful service adheres to specific constraints, the most important being the use of a uniform interface. This means using the correct HTTP method for the intended action and utilizing status codes to communicate the result of an operation to the client.

HTTP Method CRUD Action Status Code (Success) Description
GET Read 200 OK Retrieves a resource or a collection.
POST Create 201 Created Submits data to create a new resource.
PUT Update 200 OK / 204 No Content Replaces an existing resource entirely.
PATCH Partial Update 200 OK Updates only specific fields of a resource.
DELETE Delete 204 No Content Removes a resource from the system.

Anatomy of an API Controller

To create an API, you must decorate your class with the [ApiController] attribute. This attribute enables several API-specific behaviors, such as automatic model validation (returning a 400 Bad Request if validation fails) and requirement of attribute routing.

The following example demonstrates a standard controller for managing a "Products" resource:

using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;

[ApiController]
[Route("api/[controller]")] // Routes to 'api/products'
public class ProductsController : ControllerBase
{
    private static readonly List<string> Products = new() { "Laptop", "Mouse", "Keyboard" };

    // GET: api/products
    [HttpGet]
    public ActionResult<IEnumerable<string>> GetAll()
    {
        return Ok(Products);
    }

    // GET: api/products/0
    [HttpGet("{id}")]
    public ActionResult<string> GetById(int id)
    {
        if (id < 0 || id >= Products.Count)
        {
            return NotFound(); // Returns 404
        }
        return Ok(Products[id]); // Returns 200
    }

    // POST: api/products
    [HttpPost]
    public IActionResult Create([FromBody] string productName)
    {
        Products.Add(productName);
        // Returns 201 and includes the location header for the new resource
        return CreatedAtAction(nameof(GetById), new { id = Products.Count - 1 }, productName);
    }
}

Content Negotiation and Formatters

One of the core strengths of ASP.NET Core APIs is Content Negotiation. By default, the framework is configured to return JSON using the System.Text.Json library. However, if a client requests a different format (like XML) via the Accept header, the framework can automatically serialize the response into that format, provided the corresponding formatter is registered.

Header Example Value Result
Accept application/json The server returns data as a JSON object.
Content-Type application/xml Informs the server that the incoming body is XML.

To support XML, you must explicitly add it in Program.cs

builder.Services.AddControllers()
    .AddXmlSerializerFormatters();

Returning Results: IActionResult vs. ActionResult<T>

ASP.NET Core offers multiple ways to return data from an API action. Choosing the right one impacts both code readability and the generation of API documentation (like Swagger).

  • IActionResult: Used when an action can return multiple types of results (e.g., Ok(), NotFound(), and BadRequest()) but doesn't need to specify the return type for documentation.
  • ActionResult<T> The preferred approach for modern APIs. It allows you to return a specific type (e.g., Product) while still retaining the ability to return HTTP status codes. This helps tools like Swagger automatically detect the response schema.

Warning: Avoid returning raw List<T> or IEnumerable<T> directly without wrapping them in an Ok() or ActionResult. Without the wrapper, the framework may struggle to correctly handle status codes and metadata required by client-side consumers.

Note: The [ApiController] attribute makes the [FromBody] attribute optional for complex types. The framework assumes that complex types (like a User object) should be read from the request body, while simple types (like int id) should be read from the route or query string.

Controller-based APIs vs Minimal APIs

In modern .NET development, you have two primary ways to build APIs: Controller-based APIs and Minimal APIs. Controller-based APIs are the traditional, structured approach that has existed since the inception of ASP.NET Core. Minimal APIs, introduced in .NET 6, provide a streamlined approach with much less boilerplate, designed for high-performance microservices and small-scale applications.

At a Glance: Key Differences

Feature Controller-based APIs Minimal APIs
Structure Class-based, follows MVC patterns. Function-based, defined directly in Program.cs.
Boilerplate High (requires classes, constructors, attributes). Very low (uses lambdas and extension methods).
Discovery Uses AddControllers() and MapControllers(). Explicitly mapped via MapGet, MapPost, etc.
Performance Slightly higher overhead due to MVC features. Higher throughput; faster startup times.
Organization Grouped by resource (e.g., UsersController). Can be grouped using "Endpoint Groups."

  1. Controller-based APIs
  2. This approach uses classes that inherit from ControllerBase. It is best suited for complex applications that require advanced features like Action Filters, Versioning, or where the team prefers a strict separation of concerns through the traditional MVC folder structure.

    • Pros: Better for large-scale applications; automatic integration with many enterprise patterns; easier for developers coming from Spring or traditional ASP.NET.
    • Cons: More files to manage; slightly slower execution due to the complexity of the MVC action invoker.
    [ApiController]
    [Route("api/[controller]")]
    public class GreeterController : ControllerBase
    {
        [HttpGet("{name}")]
        public IActionResult Greet(string name) => Ok($"Hello, {name}!");
    }
  3. Minimal APIs
  4. Minimal APIs hide the "clutter" of controllers and allow you to define routes and logic in a single file. They are ideal for architectural patterns like Vertical Slice Architecture or when building simple microservices where the ceremony of a controller is unnecessary.

    • Pros: Extremely fast; easier to read for simple logic; perfect for "serverless" or containerized deployments.
    • Cons: Can lead to a messy Program.cs if not organized properly; doesn't support MVC Filters (though it has its own "Endpoint Filters").
    var builder = WebApplication.CreateBuilder(args);
    var app = builder.Build();
    
    app.MapGet("/api/greet/{name}", (string name) => $"Hello, {name}!");
    
    app.Run();

Organizing Minimal APIs

As a project grows, putting every route in Program.cs becomes unmanageable. To solve this, developers use Route Groups and extension methods to keep the code clean.

// Defining a group for all 'User' related endpoints
var users = app.MapGroup("/users");

users.MapGet("/", GetAllUsers);
users.MapGet("/{id}", GetUserById);
users.MapPost("/", CreateUser);

// Handler methods can be defined separately
static IResult GetAllUsers() => TypedResults.Ok(new { Name = "John Doe" });

Feature Comparison Matrix

Choosing the right approach depends on the specific requirements of your project. Often, a project might even use both—Controllers for complex UI management and Minimal APIs for high-speed data endpoints.

Requirement Use Controllers Use Minimal APIs
Microservices No Yes
Complex Action Filters Yes No
OData Support Yes Limited
Rapid Prototyping No Yes
Legacy Migration Yes No

Warning: While Minimal APIs are "minimal," don't forget security. Unlike Controllers with the [Authorize] attribute at the class level, you must remember to append .RequireAuthorization() to your Minimal API endpoints or groups to protect them.

Note: Performance-wise, Minimal APIs can handle significantly more requests per second (RPS) than Controllers because they bypass the expensive MVC action selection and filtering pipeline. For high-traffic public APIs, this difference can lead to lower infrastructure costs.

Attribute Routing

Attribute routing is the primary method for defining routes in Web APIs. Unlike conventional routing, which relies on a centralized template, attribute routing uses C# attributes placed directly on controllers and action methods. This approach provides precise control over the URL space, making it easier to create hierarchical, RESTful URI patterns that map intuitively to your data resources.

Essential Routing Attributes

The routing system uses a combination of the [Route] attribute to define the base path and HTTP Verb attributes (like [HttpGet]) to define specific endpoints.

Attribute Level Purpose
[Route("api/[controller]")] Controller Sets a base prefix for all actions. [controller] is a token replaced by the class name (minus "Controller").
[HttpGet("details")] Action Defines a GET endpoint. Appends to the controller route (e.g., api/products/details).
[HttpPost] Action Defines a POST endpoint. Often used at the root level of the controller route.
[HttpPut("{id}")] Action Defines an update endpoint that requires a URL parameter (e.g., api/products/5).

Route Parameters and Tokens

Tokens allow you to create dynamic routes that extract data directly from the URL. These values are automatically passed to your action method parameters via Model Binding.

  1. Path Parameters
  2. Placeholders in curly braces {} are treated as variables.

    [HttpGet("orders/{orderId}/items/{itemId}")]
    public IActionResult GetItem(int orderId, int itemId) 
    { 
        /* Logic */ 
    }
  3. Reserved Tokens
  4. ASP.NET Core provides special tokens that help reduce hardcoding:

    • [controller]: Replaces with the controller name (e.g., Products).
    • [action]: Replaces with the method name (e.g., GetStock).
    • [area]: Used in larger projects to organize routes into logical "Areas."

Route Constraints

Constraints restrict whether a route matches based on the data type or value of a parameter. This prevents ambiguity—for example, distinguishing between a request for a numeric ID and a request for a string-based username.

Constraint Syntax Description
Type {id:int} Only matches if the segment is a valid integer.
Length {slug:minlength(5)} Only matches if the string is at least 5 characters long.
Range {age:range(18,99)} Matches if the number is within the specified bounds.
Regex {code:regex(^\d{3}$)} Matches a specific pattern (e.g., exactly 3 digits).

Route Order and Precedence

In some cases, multiple attributes might match the same URL. ASP.NET Core resolves this by evaluating routes from most specific to least specific.

  1. Literals: api/products/featured (High priority)
  2. Constraints: api/products/{id:int}
  3. Generic Parameters: api/products/{name} (Low priority)
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
    // Matches: GET api/products/search
    [HttpGet("search")]
    public IActionResult Search() => Ok("Searching...");

    // Matches: GET api/products/5
    // Will NOT match "search" because the int constraint fails.
    [HttpGet("{id:int}")]
    public IActionResult GetById(int id) => Ok($"ID: {id}");
}

Best Practices for API Routing

  • Use Nouns, Not Verbs: Prefer GET api/products over GET api/getProducts.
  • Hierarchical Relationships: Use nesting to show ownership, e.g., api/authors/{authorId}/books.
  • Kebab-Case URLs: While C# uses PascalCase, URLs are traditionally lowercase and hyphenated. You can configure this globally in Program.cs.
  • Version Your APIs: Use a prefix like api/v1/[controller] to avoid breaking changes for clients when your data model evolves.

Warning: Avoid deeply nested routes (e.g., api/users/1/orders/5/items/10/details). These are difficult to maintain and create "fragile" URLs. A depth of 2 or 3 levels is generally considered the limit for clean REST design.

Note: If you have an action that needs to match multiple URL patterns, you can apply multiple [Route] attributes to a single method. The framework will treat them as aliases for the same piece of code.

Content Negotiation and Formatting

Content Negotiation is the process by which the client and server agree on the format of the data being exchanged. In ASP.NET Core, this allows a single API endpoint to serve data in different formats (such as JSON, XML, or Plain Text) based on the client's specific requirements. This is a core pillar of the HTTP specification, ensuring that your API is flexible enough to support diverse consumers—from web browsers to legacy enterprise systems.

How Content Negotiation Works

The process is primarily driven by HTTP headers. When a client sends a request, it uses the Accept header to tell the server which data formats it can understand. The server then examines its list of registered Output Formatters to find a match.

Header Role Example
Accept Sent by client to request a specific response format. Accept: application/xml
Content-Type Sent by client/server to identify the format of the body. Content-Type: application/json
Accept-Language Requests a specific language/culture for the response. Accept-Language: en-US

Built-in and Custom Formatters

ASP.NET Core uses a pluggable "Formatter" architecture. By default, the framework includes a JSON formatter based on System.Text.Json. If a match is found, the server serializes the data and returns a 200 OK. If no matching formatter is found, the server defaults to JSON—unless configured otherwise.

Formatter Type Default Status Registration Requirement
JSON Enabled None (Default).
XML Disabled Must call .AddXmlSerializerFormatters().
Plain Text Enabled Handles simple string return types.
Custom Disabled Requires inheriting from OutputFormatter.

Enabling XML Support

To allow your API to serve XML data, you must modify the controller registration in Program.cs

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers()
    .AddXmlSerializerFormatters(); // Adds XML support to Content Negotiation

Restricting Formats

Sometimes you want to force an endpoint to return a specific format, regardless of what the client asks for. You can achieve this using the [Produces] attribute. Conversely, use [Consumes] to limit what type of data the API will accept in a request body.

[ApiController]
[Route("api/[controller]")]
[Produces("application/json")] // This controller will ONLY return JSON
public class ReportsController : ControllerBase
{
    [HttpPost]
    [Consumes("application/xml")] // This action only accepts XML input
    public IActionResult PostReport(Report report) => Ok();
}

The "406 Not Acceptable" Policy

By default, if a client requests a format the server doesn't support (e.g., Accept: application/yaml), the server ignores the request and returns JSON anyway. For a stricter REST implementation, you can configure the server to return a 406 Not Acceptable status code instead.

builder.Services.AddControllers(options =>
{
    // Return 406 if the requested format is not supported
    options.ReturnHttpNotAcceptable = true; 
});

Global Formatting Settings

Modern .NET uses System.Text.Json as the default engine. You can customize how your data is formatted (e.g., changing property naming from camelCase to PascalCase or handling circular references) globally:

builder.Services.AddControllers()
    .AddJsonOptions(options =>
    {
        // Use the property names exactly as defined in C# (PascalCase)
        options.JsonSerializerOptions.PropertyNamingPolicy = null;
        // Ignore null values in the response to save bandwidth
        options.JsonSerializerOptions.DefaultIgnoreCondition = 
            System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull;
    });

Warning: Be cautious when returning large object graphs. Circular references (e.g., a Parent object containing a Child which points back to the Parent) will cause the JSON serializer to throw an exception unless you explicitly configure it to ignore or preserve references.

Note: For most modern web applications, JSON is the de facto standard. You should only enable XML formatters if you are specifically supporting legacy clients or industry-specific protocols that require it.

OpenAPI (Swagger) Integration

OpenAPI (formerly known as Swagger) is a standard specification for describing RESTful APIs. It creates a machine-readable representation of your API, detailing every endpoint, parameter, and response type. In ASP.NET Core, Swagger integration provides a powerful, interactive UI that allows developers to visualize, test, and document their services without writing manual documentation.

The Components of Swagger

In a .NET environment, Swagger integration is typically handled by the Swashbuckle or NSwag libraries. These tools perform three distinct tasks:

Component Responsibility
Swagger Generator Inspects your code via reflection to build the OpenAPI Document (usually a JSON file).
Swagger UI A web-based interface that parses the JSON document and renders an interactive testing playground.
Swagger ReDoc An alternative, clean, and highly readable documentation viewer for end-users.

Basic Configuration

Since .NET 6, Swagger is included by default in the "Web API" template. It is configured in Program.cs and is typically restricted to the Development environment to prevent exposing internal API structures in production.

var builder = WebApplication.CreateBuilder(args);

// 1. Add services to the DI container
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// 2. Configure the HTTP request pipeline
if (app.Environment.IsDevelopment())
{
    app.UseSwagger(); // Generates the JSON file (e.g., /swagger/v1/swagger.json)
    app.UseSwaggerUI(); // Renders the UI (e.g., /swagger)
}

Enhancing Documentation with Attributes

While Swagger automatically detects routes, you can provide much richer metadata by using standard attributes and XML comments. This helps client-side developers understand exactly what a 400 Bad Request or a 401 Unauthorized means for a specific endpoint.

Using Producing Attributes

The [ProducesResponseType] attribute explicitly defines what status codes and data types an action returns.

[HttpGet("{id}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Product))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult GetById(int id) 
{
    // ... logic
}

Including XML Comments

To include your C# code comments in the Swagger UI, you must enable XML documentation in your project file (.csproj) and tell Swagger to read it.

// Inside Program.cs
builder.Services.AddSwaggerGen(options =>
{
    var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
    options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFilename));
});

Security and Authorization

If your API is protected (e.g., by JWT Bearer tokens), you must configure Swagger to include an "Authorize" button. This allows you to paste a token into the UI so that subsequent test requests include the Authorization header.

builder.Services.AddSwaggerGen(c =>
{
    c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
    {
        Description = "JWT Authorization header using the Bearer scheme. Example: \"Bearer {token}\"",
        Name = "Authorization",
        In = ParameterLocation.Header,
        Type = SecuritySchemeType.ApiKey,
        Scheme = "Bearer"
    });
    // Add Security Requirement globally...
});

Benefits of Swagger Integration

  • Interactive Testing: Execute API calls directly from the browser without needing Postman or cURL.
  • Client Generation: Tools like NSwag or OpenAPI Generator can read your Swagger JSON to automatically create TypeScript or C# client libraries.
  • Standardization: Provides a single source of truth for the API contract between backend and frontend teams.

Warning: Be careful about what information you expose in your Swagger documentation. Avoid including internal implementation details or sensitive metadata in your XML comments, as these will be visible to anyone with access to the Swagger UI.

Note: In production, it is a common best practice to disable the Swagger UI but keep the Swagger JSON enabled if you use a developer portal or an API Gateway (like Azure API Management) to import your API definitions.

API Versioning

API Versioning is the practice of managing changes to an API such that existing clients continue to function while new clients can take advantage of updated features. As your application evolves, you will inevitably need to introduce breaking changes (renaming properties, changing data types, or altering URL structures). Without versioning, these changes would "break" any external application relying on your API.

In ASP.NET Core, versioning is typically implemented using the Asp.Versioning.Http (formerly Microsoft.AspNetCore.Mvc.Versioning) library, which allows you to run multiple versions of the same controller simultaneously.


Common Versioning Strategies

There are several industry-standard ways to communicate the requested version from the client to the server. ASP.NET Core supports all of them, and you can even configure your API to support multiple strategies at once.

Strategy Example Pros Cons
URL Path /api/v1/products Highly visible; easy to cache. Violates the principle that a URI identifies a unique resource.
Query String /api/products?api-version=2.0 Easy to implement; keeps the base URL clean. Can be cumbersome for developers to append to every call.
HTTP Header X-Version: 1.0 Keeps URLs clean and "RESTful." Harder to test directly in a web browser.
Media Type Accept: application/json;v=2.0 Theoretically the most "correct" REST approach. High complexity for client implementation.

Configuring Versioning in Program.cs

To enable versioning, you must register the versioning services and define the default behavior for requests that do not specify a version.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddApiVersioning(options =>
{
    // If the client doesn't specify a version, use the default
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.DefaultApiVersion = new ApiVersion(1, 0);
    
    // Report supported versions in the 'api-supported-versions' response header
    options.ReportApiVersions = true;

    // Combine multiple ways to read the version
    options.ApiVersionReader = ApiVersionReader.Combine(
        new UrlSegmentApiVersionReader(),
        new HeaderApiVersionReader("X-Api-Version"),
        new QueryStringApiVersionReader("api-version")
    );
})
.AddApiExplorer(options =>
{
    // Format the version as "'v'major[.minor][status]" (e.g., v1.0)
    options.GroupNameFormat = "'v'VVV";
    options.SubstituteApiVersionInUrl = true;
});

Implementing Versioned Controllers

Once configured, you use the [ApiVersion] attribute to link a controller to a specific version. You can have two classes with the same name in different namespaces, each handling a different version of the same resource.


Version 1.0 Controller

[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class ProductsController : ControllerBase
{
    [HttpGet]
    public IActionResult Get() => Ok("Products V1 (Legacy)");
}

Version 2.0 Controller (Breaking Change)

[ApiController]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class ProductsController : ControllerBase
{
    [HttpGet]
    public IActionResult Get() => Ok(new { Message = "Products V2 (New Schema)", Timestamp = DateTime.Now });
}

Deprecating Old Versions

As you move toward newer versions, you can mark older ones as "Deprecated." This doesn't shut the version off immediately but informs the client (via response headers) that they should prepare to migrate to a newer version.

[ApiVersion("1.0", Deprecated = true)]

Best Practices

  • Avoid "Version Zero": Start your public API at 1.0.
  • Version the Whole API: It is generally easier for clients if the entire API moves from v1 to v2 together, rather than versioning individual endpoints.
  • Documentation: Ensure your Swagger/OpenAPI UI is configured to show a dropdown for different versions so developers can see the documentation for the specific version they are using.
  • Breaking Changes Only: Only increment the major version (e.g., 1.0 to 2.0) for breaking changes. Use minor versions (1.1) for additive, non-breaking changes.

Warning: Be careful with URL Versioning if you use relative paths in your data (e.g., returning a link to an image). If the base path changes from /v1/ to /v2/, ensure your logic accounts for the dynamic version segment to avoid broken links.

Note: If you are building a small, internal-only microservice, you might not need versioning initially. However, adding it later can be difficult, so it is often better to implement basic versioning from day one.

Blazor (Interactive Web UI) Last updated: March 1, 2026, 8:48 p.m.

Blazor is a revolutionary framework that enables developers to build interactive web UIs using C# instead of JavaScript. By leveraging WebAssembly (WASM), Blazor can run client-side code directly in the browser, providing a near-native performance experience. For apps that require a faster initial load, Blazor Server manages UI interactions over a real-time SignalR connection, keeping the application state on the server while updating the browser dynamically.

The component-based architecture of Blazor encourages the creation of reusable UI elements that can be shared across different projects. With the introduction of Blazor Hybrid, developers can even take these web components and wrap them in native mobile and desktop shells using .NET MAUI. This unified "C# everywhere" approach drastically reduces the context-switching for full-stack developers and allows for a shared logic layer between the frontend and the backend.

Blazor Hosting Models (Server vs WebAssembly vs Au

Blazor is a web framework that allows developers to build interactive client-side web UIs using C# instead of JavaScript. The core of Blazor's flexibility lies in its Hosting Models. While the component code you write is largely the same, where that code executes and how the UI updates can vary significantly.

Beginning with .NET 8, the "Blazor Web App" template introduced the Auto render mode, which intelligently combines the strengths of both Server and WebAssembly models.


The Three Primary Hosting Models

Choosing a hosting model involves balancing performance, latency, and the specific needs of your users.

Model Where it Runs Communication Primary Advantage
Blazor Server On the Server (.NET Runtime) Real-time SignalR (WebSockets) Instant startup; full access to server resources.
Blazor WebAssembly In the Browser (Mono/Wasm) REST APIs / SignalR Offline support; zero server overhead after download.
Blazor Auto Both (Dynamic) SignalR, then local execution Best of both worlds: fast start + client-side speed.

  1. Blazor Server
  2. In this model, the application is executed on the server. The browser acts as a "thin client." When a user interacts with the page (e.g., clicks a button), the event is sent to the server over a persistent SignalR connection. The server calculates the UI change and sends a small "diff" back to the browser to update the DOM.

    • Pros: Small download size; code remains secure on the server; full access to databases/services.
    • Cons: Requires an active connection; higher server memory usage (one connection per user); latency on every UI interaction.
  3. Blazor WebAssembly (WASM)
  4. This is a true Client-Side Rendering (CSR) model. The entire .NET runtime, the application assemblies, and dependencies are downloaded to the browser and executed using WebAssembly.

    • Pros: Works offline once loaded; high performance for UI-intensive tasks; can be hosted as a static site (e.g., GitHub Pages).
    • Cons: Large initial download ("payload"); browser security restrictions (cannot connect directly to a database); slower initial "Time to Interactive."
  5. Blazor Auto (Interactive Auto)
  6. Introduced to solve the "loading" problem of WebAssembly. The page initially renders using Blazor Server to provide an instant UI. While the user is interacting with the server-side version, the WebAssembly assets are downloaded in the background. On the next visit, the app automatically switches to WebAssembly for client-side execution.


Comparison of Performance Metrics

Metric Server WebAssembly Auto
Startup Speed Very Fast Slow Fast
UI Responsiveness Latency-dependent Near-Instant Mixed to Instant
Offline Capability No Yes Yes (eventually)
Server Resource Usage High Minimal Moderate

Render Modes in Code

In modern Blazor applications, you can apply these models at a per-page or per-component level using the @rendermode directive.

@page "/counter"
@* Forces this specific page to run on the client browser via WASM *@
@rendermode InteractiveWebAssembly

<h1>Counter</h1>

Warning: When using Blazor WebAssembly, your C# code is downloaded to the user's machine. Never include secrets, connection strings, or sensitive business logic inside a WebAssembly component, as it can be decompiled or inspected by the user.

Note: For applications that require high SEO (Search Engine Optimization), the default Static Server Rendering (Static SSR) is used. This renders the HTML on the server without any persistent connection, providing the fastest "First Contentful Paint" for search engine crawlers.

Blazor Components and Lifecycle

Blazor applications are built using Razor Components. A component is a self-contained chunk of user interface (UI), such as a navigation menu, a data entry form, or a login dialog. Components are defined in .razor files and consist of a mix of HTML markup and C# logic.

In Blazor, the UI is a tree of components. Data flows down from parents to children via Parameters, and information flows up via EventCallbacks.


Anatomy of a Component

A standard component is split into two sections: the Markup (HTML + Razor) and the Logic (C# inside a @code block).

@* MyComponent.razor *@
<div class="card">
    <h3>@Title</h3>
    <p>@ChildContent</p>
    <button @onclick="HandleClick" class="btn btn-primary">Click Me</button>
</div>

@code {
    [Parameter] public string Title { get; set; } = "Default Title";
    [Parameter] public RenderFragment? ChildContent { get; set; }
    [Parameter] public EventCallback OnClickAction { get; set; }

    private async Task HandleClick()
    {
        await OnClickAction.InvokeAsync();
    }
}

Component Parameters and Communication

To make components reusable, they must accept data and notify their parents of changes.

Feature Syntax Purpose
Parameters [Parameter] Public properties that allow a parent to pass data into the component.
ChildContent RenderFragment Allows a parent to pass HTML or other components into a specific area.
EventCallback EventCallback<T> A delegate used to expose events to the parent (e.g., "Button Clicked").
Two-Way Binding @bind-Value Synchronizes a variable between the UI and the C# code in real-time.

The Component Lifecycle

Blazor components go through a series of steps from the moment they are initialized until they are removed from the UI. Understanding these "hooks" is essential for tasks like fetching data from an API or setting up subscriptions.

Lifecycle Method Description Common Use Case
OnInitialized[Async] Executed after the component is first created and parameters are assigned. Fetching initial data from a database or API.
OnParametersSet[Async] Called when the component first renders AND every time the parent updates parameters. Reacting to URL parameter changes (e.g., /product/5 to /product/6).
OnAfterRender[Async] Executed after the UI has been updated in the browser. Initializing JavaScript libraries or focusing an input field.
Dispose Called when the component is being removed from the UI. Unsubscribing from events or cancelling timers to prevent memory leaks.

State Management and Re-rendering

Unlike traditional JavaScript frameworks that require manual DOM manipulation, Blazor uses a Render Tree. When a component's state changes (e.g., a variable is updated), Blazor automatically detects the change and re-renders that specific component and its children.

  • StateHasChanged(): Usually, Blazor calls this automatically after event handlers. However, if you update the UI from a background thread or a timer, you must call InvokeAsync(StateHasChanged) to tell the framework to refresh the view.

Key Directives

Directives provide special instructions to the Razor compiler regarding how a component should behave or be routed.

Directive Function
@page Makes the component a "Page" accessible via a URL (e.g., @page "/counter").
@layout Specifies which master layout template to wrap around the component.
@inject Injects a service (like a database context or HTTP client) into the component.
@attribute Adds metadata, such as [Authorize], to the component class.

Warning: Avoid performing long-running tasks inside OnInitialized. Because this method blocks the initial render in Blazor Server, it can make the application feel sluggish. Always use the Async versions (OnInitializedAsync) and await your tasks to keep the UI responsive.

Note: OnAfterRender includes a firstRender boolean parameter. Use this to ensure that setup logic (like calling JS Interop) only runs once, rather than every time the component updates.

Data Binding and Event Handling

In Blazor, data binding and event handling are the mechanisms that synchronize your C# code with the UI. Instead of manually updating the DOM (as you would in jQuery), you bind your UI elements to C# variables. When the variable changes, the UI updates automatically. Conversely, when a user interacts with the UI, events trigger C# methods to update the state.


  1. Data Binding
  2. Data binding connects an HTML element's property to a C# field, property, or expression. Blazor supports two types of binding: One-way and Two-way.

    One-way Binding

    One-way binding flows from the C# code to the HTML. If the C# value changes, the UI updates, but the user cannot change the C# value through the UI element (e.g., a read-only <span> or <div>).

    <p>Current count: @currentCount</p>
    
    @code {
        private int currentCount = 10;
    }

    Two-way Binding

    Two-way binding allows data to flow in both directions. It is most commonly used in forms (inputs, checkboxes, selects). When the user types in a text box, the C# variable is updated; if the C# variable is updated via code, the text box reflects the new value.

    <input @bind="userName" />
    <p>Hello, @userName!</p>
    
    @code {
        private string userName = "Guest";
    }
    • @bind:event: By default, text inputs update the C# variable when the element loses focus (onchange). You can change this to update as the user types by using @bind:event="oninput".

  3. Event Handling
  4. Event handling allows you to respond to user actions like clicks, key presses, and mouse movements. In Blazor, event attributes match standard HTML events but are prefixed with the @ symbol (e.g., @onclick, @onchange, @onmouseover).

    Basic Event Handler

    You can point an event directly to a C# method.

    <button @onclick="IncrementCount">Click Me</button>
    
    @code {
        private int count = 0;
        private void IncrementCount() => count++;
    }

    Lambda Expressions and Arguments

    If you need to pass extra information to a method, you can use a lambda expression. You can also capture the event arguments (like MouseEventArgs or KeyboardEventArgs) to get details about the user's action.

    @foreach (var item in items)
    {
        <button @onclick="@(e => DeleteItem(e, item.Id))">
            Delete @item.Name
        </button>
    }
    
    @code {
        private void DeleteItem(MouseEventArgs e, int id)
        {
            // e.ClientX gives the mouse position
            Console.WriteLine($"Deleting item {id} at {e.ClientX}");
        }
    }

  5. EventCallback (Parent-Child Communication)
  6. While standard C# events can be used, Blazor provides EventCallback specifically for component parameters. EventCallback is designed to be "aware" of the Blazor rendering lifecycle—it automatically triggers a re-render of the parent component when the callback is executed.

    ChildComponent.razor

    <button @onclick="OnButtonClicked">Notify Parent</button>
    
    @code {
        [Parameter] public EventCallback<string> OnAction { get; set; }
    
        private async Task OnButtonClicked()
        {
            await OnAction.InvokeAsync("Data from Child");
        }
    }

    ParentComponent.razor

    <ChildComponent OnAction="HandleChildAction" />
    
    @code {
        private void HandleChildAction(string message)
        {
            Console.WriteLine(message);
        }
    }

  7. Preventing Default and Stop Propagation
  8. Sometimes you need to prevent the browser's default behavior (like a form submitting or a link navigating) or stop an event from bubbling up to parent elements.

    • @onclick:preventDefault: Prevents the default browser action.
    • @onclick:stopPropagation: Prevents the event from bubbling up the DOM tree.
    • <div @onclick="ParentDivClick">
          @* Clicking this button will NOT trigger ParentDivClick *@
          <button @onclick="ButtonClick" @onclick:stopPropagation>
              Independent Button
          </button>
      </div>

Warning: Be careful with high-frequency events like @onmousemove. Handling these in Blazor Server can cause significant network lag because every tiny mouse movement sends a SignalR message to the server. For these scenarios, JavaScript Interop is usually a better choice.

Note: If you are updating state from an external source (like a timer or a background thread), Blazor won't know the UI needs to refresh. In those cases, you must manually call StateHasChanged() to trigger a re-render.

Routing and Navigation in Blazor

Routing in Blazor is the process of mapping a browser URL to a specific Razor component. When you navigate to a URL, the Router component intercepts the request, identifies the component with the matching address, and renders it within the current layout. Because Blazor is a Single Page Application (SPA) framework, navigation happens on the client side without a full page reload, resulting in a smooth, desktop-like user experience.


The @page Directive

Any Razor component can become a "page" by adding the @page directive at the top of the file. A single component can support multiple routes by defining multiple directives.

Feature Syntax Example
Simple Route @page "/path" @page "/contact"
Multiple Routes Multiple @page entries @page "/home" and @page "/"
Route Parameters {variable} @page "/user/{Id}"
Optional Params {variable?} @page "/search/{term?}"

Route Parameters and Constraints

Data can be passed through the URL segments and captured in the component using properties decorated with the [Parameter] attribute. To prevent invalid data from matching a route, you can apply Route Constraints.

@page "/user/{Id:int}"

<h3>User Profile</h3>
<p>Viewing user with ID: @Id</p>

@code {
    [Parameter]
    public int Id { get; set; }
}

Common constraints include:

  • int, long, \float, double: Numeric types.
  • bool: Boolean values (true/false).
  • guid: Globally Unique Identifiers.
  • datetime: Date and time strings.

Programmatic Navigation (NavigationManager)

While the <NavLink> component handles user-initiated clicks, you often need to navigate via code (e.g., after a successful form submission). The NavigationManager service is injected into your component to handle these tasks.

Method Description
NavigateTo(string uri) Navigates to the specified URI.
Uri Returns the current absolute URI.
BaseUri Returns the base URI of the app.
ToAbsoluteUri(string) Converts a relative URI to an absolute one.

Implementation Example:

@inject NavigationManager NavManager

<button @onclick="GoToDashboard">Go to Dashboard</button>

@code {
    void GoToDashboard()
    {
        // Second parameter 'true' forces a full page reload if needed
        NavManager.NavigateTo("/dashboard");
    }
}

NavLink vs. Standard Anchor Tags

In Blazor, you should generally use the <NavLink> component instead of the standard <a> tag for navigation links. The <NavLink> component automatically toggles an active CSS class on the element when the current URL matches the link's destination.

  • Match="NavLinkMatch.All": The link is active only if it matches the entire current URL.
  • Match="NavLinkMatch.Prefix": The link is active if it matches any prefix of the current URL (default).
<NavLink class="nav-link" href="counter" Match="NavLinkMatch.All">
    Counter
</NavLink>

Query String Parameters

As of .NET 6+, you can bind query string values (e.g., ?search=blazor&page=1) directly to component parameters using the [SupplyParameterFromQuery] attribute.

@code {
    [Parameter]
    [SupplyParameterFromQuery(Name = "search")]
    public string? SearchTerm { get; set; }
}

Warning: Blazor routing is case-insensitive for the route path, but the parameters passed via the URL are passed as strings. If you use a constraint like :int, the router will return a 404 if the value cannot be parsed, protecting your code from type errors.

Note: To prevent a user from navigating away from a page with unsaved changes, you can use the NavigationLock component. This allows you to intercept navigation attempts and display a confirmation dialog.

JavaScript Interoperability (JS Interop)

While Blazor allows you to write the majority of your logic in C#, there are scenarios where you must interact with the browser's native capabilities or existing JavaScript libraries (like Google Maps, Chart.js, or local storage). JavaScript Interoperability, or JS Interop, is the bridge that allows C# code to call JavaScript functions and vice versa.

Key Interfaces

Blazor provides two primary interfaces for handling these interactions, depending on whether you are working in a synchronous or asynchronous context.

Interface Usage Environment
IJSRuntime The standard interface for calling JS from C#. Server and WebAssembly.
IJSInProcessRuntime Allows synchronous calls for better performance. WebAssembly only.
IJSObjectReference Represents a reference to a specific JS object or module. Useful for JS Isolation (Modules).

Calling JavaScript from C#

To call a JavaScript function, you must first inject the IJSRuntime service into your component. You then use the InvokeAsync<T> method, where T is the expected return type from the JavaScript function.

  1. The JavaScript Function
  2. First, ensure your JS function is accessible globally (typically in index.html or _Host.cshtml).

    window.showBrowserAlert = (message) => {
        alert(message);
        return "User clicked OK";
    };
  3. The Blazor Component
  4. @inject IJSRuntime JS
    
    <button @onclick="TriggerAlert">Call JS</button>
    
    @code {
        private async Task TriggerAlert()
        {
            // The first argument is the function name; the second is the parameter
            string result = await JS.InvokeAsync<string>("showBrowserAlert", "Hello from C#!");
            Console.WriteLine(result);
        }
    }

Calling C# from JavaScript

To allow JavaScript to call a C# method, the method must be decorated with the [JSInvokable] attribute and must be public.

  • Static Methods: Called using the assembly name and method name.
  • Instance Methods: Require passing a DotNetObjectReference to JavaScript first.

Implementation Example (Instance Method):

// In the Blazor Component
private DotNetObjectReference<MyComponent>? _objRef;

protected override void OnInitialized()
{
    _objRef = DotNetObjectReference.Create(this);
}

[JSInvokable]
public void ProcessData(string data) => Console.WriteLine($"JS sent: {data}");

// Passing the reference to JS
// await JS.InvokeVoidAsync("setupListener", _objRef);

JavaScript Isolation (Modules)

For modern applications, it is a best practice to use JavaScript Isolation. This allows you to load JS files as ES6 modules only when a specific component needs them, preventing global namespace pollution and improving performance.

private IJSObjectReference? _module;

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender)
    {
        // Load the JS file as a module
        _module = await JS.InvokeAsync("import", "./scripts/myScript.js");
    }
}

private async Task CallModuleFunction()
{
    if (_module is not null)
    {
        await _module.InvokeVoidAsync("moduleFunction");
    }
}

Comparison: When to use JS Interop

Use Case Recommended Approach
Browser APIs (Geolocation, Storage) Use JS Interop with IJSRuntime.
Large JS Libraries (Charts, Maps) Use JS Isolation (Modules).
DOM Manipulation Avoid. Let Blazor handle the DOM via Razor syntax.
Focus/Scroll Use JS Interop (small helpers).

Warning: You cannot call JS Interop during the OnInitialized or OnInitializedAsync lifecycle methods in Blazor Server. The JavaScript runtime is not available until the browser has established the SignalR connection. Always perform JS initialization inside OnAfterRenderAsync when firstRender is true.

Note: To avoid memory leaks, always implement IAsyncDisposable in components that use IJSObjectReference to properly dispose of the JavaScript module when the component is destroyed.

Real-Time Web (SignalR) Last updated: March 1, 2026, 8:49 p.m.

SignalR simplifies the process of adding real-time web functionality to applications, allowing the server to push content to connected clients instantly. This is essential for features like live dashboards, chat applications, and collaborative editing tools. SignalR handles the complexity of connection management automatically, choosing the best available transport mechanism—such as WebSockets, Server-Sent Events, or Long Polling—based on the capabilities of both the client and the server.

The framework utilizes a "Hubs" model, which acts as a high-level pipeline that allows the server and client to call methods on each other seamlessly. SignalR is highly scalable, with built-in support for backplanes like Redis, ensuring that even if your application is spread across multiple servers, messages are delivered to all connected users regardless of which server they are connected to.

Introduction to SignalR

SignalR is an open-source library for ASP.NET Core that simplifies adding real-time web functionality to applications. Real-time web functionality is the ability of server-side code to push content to connected clients instantly as events occur, rather than having the server wait for a client to request new data.

While traditional HTTP follows a "request-response" model, SignalR establishes a persistent connection, allowing for full-duplex (two-way) communication.


Key Features of SignalR

SignalR handles the complexities of connection management automatically, providing several high-level features:

  • Automatic Reconnection: If a client drops their connection (e.g., walking through a tunnel), SignalR attempts to reconnect automatically.
  • Simultaneous Broadcast: Send messages to all connected clients at once (e.g., a breaking news alert).
  • Targeted Messaging: Send messages to specific users, specific groups (like a chat room), or a single specific connection.
  • Fallback Transports: It intelligently chooses the best way to communicate based on the capabilities of the browser and server.

Transport Protocols

SignalR uses a technique called Graceful Degradation. It prefers the most efficient transport but falls back to older methods if the environment doesn't support them.

Transport Type Description
WebSockets Full-Duplex The only true persistent, two-way connection. Lowest latency.
Server-Sent Events (SSE) One-Way The server pushes updates to the client; the client uses standard HTTP to talk back.
Long Polling Simulated The client opens a request and the server "holds" it open until it has data to send.

The Concept of Hubs

In SignalR, communication happens through Hubs. A Hub is a high-level pipeline built on the Hub class that allows the client and server to call methods on each other.

  • Server-to-Client: The server calls a method on the client side (e.g., ReceiveMessage).
  • Client-to-Server: The client calls a method on the server side (e.g., SendMessage).

Basic Hub Implementation

using Microsoft.AspNetCore.SignalR;

public class ChatHub : Hub
{
    public async Task SendMessage(string user, string message)
    {
        // Broadcasts the message to EVERYONE connected to this hub
        await Clients.All.SendAsync("ReceiveMessage", user, message);
    }
}

Use Cases for SignalR

SignalR is not just for chat apps; it is used whenever data needs to be updated frequently without user intervention.

Industry/Category Use Case
Finance Real-time stock tickers and currency exchange rates.
Gaming Multiplayer movement and lobby status updates.
Collaboration Simultaneous document editing (like Google Docs).
Monitoring Live server health dashboards and IoT sensor telemetry.
E-commerce Live bidding in auctions or real-time inventory count updates.

Scaling SignalR

Because SignalR maintains persistent connections, a single server has a limit to how many users it can handle. To scale out across multiple servers, SignalR requires a Backplane to ensure that a message sent to Server A is also sent to clients connected to Server B.

  • Azure SignalR Service: The recommended approach for cloud scaling; it offloads connection management.
  • Redis Backplane: Used for self-hosted environments to sync messages across the server farm.

Note SignalR is built into the ASP.NET Core framework, so you do not need to install a separate NuGet package for the server-side components. However, you will need the @microsoft/signalr package for JavaScript clients or the Microsoft.AspNetCore.SignalR.Client package for .NET clients.

Warning: While SignalR provides real-time "feeling" updates, it is not a "hard real-time" system (like those used in aviation or medical robotics). Latency is still subject to network conditions and internet hops.

Creating Hubs and Clients

Building a real-time feature involves two distinct parts: creating the Hub on the server to manage connections and logic, and configuring the Client to listen for and send messages. SignalR handles the underlying plumbing, allowing you to focus on the application logic.


  1. Creating the Server-Side Hub
  2. A Hub is a class that inherits from Microsoft.AspNetCore.SignalR.Hub. It acts as the central engine for your real-time communication. You define public methods in this class that clients can invoke.

    using Microsoft.AspNetCore.SignalR;
    
    public class NotificationHub : Hub
    {
        // Method called by clients to join a specific group (e.g., a "News" room)
        public async Task JoinGroup(string groupName)
        {
            await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
            await Clients.Group(groupName).SendAsync("ReceiveMessage", $"{Context.ConnectionId} has joined.");
        }
    
        // Method called by clients to send data to everyone else
        public async Task SendNotification(string message)
        {
            await Clients.All.SendAsync("BroadcastMessage", message);
        }
    }

    Mapping the Hub

    You must register the Hub in Program.cs so the application knows which URL path should be handled by the SignalR engine.

    var builder = WebApplication.CreateBuilder(args);
    builder.Services.AddSignalR(); // Add SignalR services
    
    var app = builder.Build();
    app.MapHub<NotificationHub>("/notifications"); // Set the endpoint

  3. Configuring the Client
  4. SignalR supports multiple client types, including JavaScript (for web apps), .NET (for desktop or mobile), and Java. The client must establish a connection to the Hub URL defined in the server configuration.

    JavaScript Client Example

    The JavaScript client uses the @microsoft/signalr library. It follows a "Build -> On -> Start" pattern.

    // 1. Build the connection
    const connection = new hubConnectionBuilder()
        .withUrl("/notifications")
        .withAutomaticReconnect()
        .build();
    
    // 2. Register handlers for messages sent FROM the server
    connection.on("BroadcastMessage", (message) => {
        console.log("New notification: " + message);
    });
    
    // 3. Start the connection
    async function start() {
        try {
            await connection.start();
            console.log("SignalR Connected.");
        } catch (err) {
            setTimeout(start, 5000); // Retry on failure
        }
    };
    
    start();

Comparison of Client Messaging Methods

The server can target messages with high precision using the Clients property within the Hub.

Targeting Method Syntax Use Case
All Clients.All Global announcements or system-wide alerts.
Caller Clients.Caller Confirming an action only to the person who triggered it.
Others Clients.Others Notifying everyone except the sender (e.g., "User X is typing").
User Clients.User(userId) Private messages or specific account notifications.
Group Clients.Group(name) Chat rooms, specific stock symbols, or department updates.

Connection Management and Security

SignalR provides lifecycle hooks to track when users connect or disconnect. This is useful for maintaining "Who's Online" lists.

  • OnConnectedAsync(): Triggered when a new connection is established.
  • OnDisconnectedAsync(exception): Triggered when a client closes the tab or loses internet.

Warning: By default, Hub methods are public. If your Hub handles sensitive data, you must apply the [Authorize] attribute to the class or specific methods. SignalR works seamlessly with standard ASP.NET Core Identity and JWT Bearer authentication.

Note: Use withAutomaticReconnect() in your client-side code. It implements a back-off strategy (0, 2, 10, and 30 seconds) to try and restore the connection if it's lost, preserving the user experience during minor network hiccups.

Broadcasting and Targeting Groups

SignalR's true power lies in its ability to manage sophisticated messaging patterns. Instead of just sending data back and forth between one client and the server, you can categorize connections into Groups. This allows you to scale your real-time features efficiently, ensuring that users only receive the data that is relevant to them.


Understanding the Messaging Scope

The Clients property in a SignalR Hub provides several entry points to define who receives a message. These methods are asynchronous and return a Task.

Targeting Scope Method Call Description
Broadcast Clients.All Sends to every client currently connected to the Hub.
Self-Only Clients.Caller Sends only back to the client that invoked the current Hub method.
Exclusionary Clients.Others Sends to everyone except the client that invoked the method.
Specific User Clients.User(id) Sends to all connections associated with a specific User ID.
Groups Clients.Group(name) Sends to all clients that have been added to a named group.

Working with Groups

Groups in SignalR are not persisted on the server. They are a logical collection of connection IDs. If the server restarts, group memberships are lost, though SignalR's automatic reconnection usually handles the re-joining logic if scripted correctly in the client.

  1. Adding and Removing Users
  2. Group management is performed using the Groups object. Since these methods are asynchronous, they must be awaited.

    public async Task JoinChatRoom(string roomName)
    {
        // Add the current connection to the group
        await Groups.AddToGroupAsync(Context.ConnectionId, roomName);
    
        // Notify others in the room
        await Clients.Group(roomName).SendAsync("UserJoined", Context.ConnectionId);
    }
    
    public async Task LeaveChatRoom(string roomName)
    {
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, roomName);
    }
  3. Sending Messages to a Group
  4. Once a group is formed, sending a message is a one-line operation.

    public async Task SendToRoom(string roomName, string message)
    {
        await Clients.Group(roomName).SendAsync("ReceiveRoomMessage", message);
    }

    User-Targeted Messaging

    While Groups are flexible, User-Targeted messaging is the standard for private notifications. SignalR uses an IUserIdProvider to map a connection to a specific user. By default, it uses the ClaimTypes.NameIdentifier from the user's ClaimsPrincipal (the identity they logged in with).

    • Multi-device support: If a user is logged in on both their laptop and phone, Clients.User("user123") will automatically send the message to both devices.
    • Security: This is more secure than manually managing connection IDs, as it relies on the authenticated identity of the user.

    Practical Use Cases for Groups

    Use Case Implementation Strategy
    Stock Ticker Create a group for each symbol (e.g., Group("MSFT")). Users join groups for the stocks they watch.
    Document Editing Each document ID is a group. Only users currently viewing Doc_45 receive update events.
    Regional Alerts Group users by zip code or city to push localized weather or traffic alerts.
    Gaming A "Match ID" acts as a group to sync player movements within a specific game session.

Best Practices

  • Cleanup: You don't strictly need to remove a user from a group when they disconnect; SignalR cleans up stale connection IDs automatically. However, explicit removal is good practice for logical "Leave" actions.
  • Naming: Group names are strings and are case-sensitive. Use a consistent naming convention (e.g., room:101).
  • Avoid over-broadcasting: Sending messages to Clients.All in a high-traffic app can cause "broadcast storms" that overwhelm client-side processing. Use groups to segment traffic.

Warning: Group membership is not stored in a database by SignalR. If you need to know "who is in a room" after a server reboot, you must track that membership in your own database (like SQL Server or Redis).

Note: If you are using a load balancer with multiple server instances, you must use a backplane (like Azure SignalR or Redis) so that a message sent to a group on Server A reaches members of that group who are connected to Server B.

SignalR Security and Authentication

Securing a real-time application is just as critical as securing standard web pages. Because SignalR establishes a persistent connection, the authentication process happens at the start of the connection, and the security context (the user's identity) is maintained for the duration of that session. ASP.NET Core SignalR integrates seamlessly with the standard Microsoft.AspNetCore.Authorization framework.

Authentication vs. Authorization in SignalR

While the terms are often used interchangeably, they represent two distinct steps in the security pipeline:

Concept SignalR Implementation Goal
Authentication Identifies who the user is via Cookies or JWT Bearer tokens. Establish the Context.User.
Authorization Determines if the identified user has permission to access the Hub. Prevent unauthorized access to methods.

Protecting the Hub with Attributes

You can protect your SignalR Hubs using the [Authorize] attribute, exactly like you would with an MVC Controller or a Razor Page. This can be applied to the entire class or specific methods.

[Authorize] // Only authenticated users can connect to this hub
public class SecureChatHub : Hub
{
    public async Task SendMessage(string message)
    {
        // Hub logic
    }

    [Authorize(Roles = "Admin")] // Only Admins can invoke this specific method
    public async Task BanUser(string userId)
    {
        // Admin-only logic
    }
}

JWT Authentication and the Access Token

A common challenge with SignalR and JWT (JSON Web Tokens) is that WebSockets (one of SignalR's primary transports) do not support custom HTTP headers in the browser. To solve this, the SignalR client sends the token as a query string parameter, and the server must be configured to extract it from there.

  1. Server Configuration (Program.cs)
  2. You must tell the JWT Bearer middleware to look for the token in the "access_token" query string if the request is for a SignalR hub.

    builder.Services.AddAuthentication()
        .AddJwtBearer(options =>
        {
            options.Events = new JwtBearerEvents
            {
                OnMessageReceived = context =>
                {
                    var accessToken = context.Request.Query["access_token"];
                    var path = context.HttpContext.Request.Path;
                    
                    // If the request is for our hub...
                    if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/chatHub"))
                    {
                        context.Token = accessToken;
                    }
                    return Task.CompletedTask;
                }
            };
        });
  3. Client Configuration (JavaScript)
  4. The client-side code must provide the token via the accessTokenFactory.

    const connection = new signalR.HubConnectionBuilder()
        .withUrl("/chatHub", {
            accessTokenFactory: () => "YOUR_JWT_TOKEN_HERE"
        })
        .build();

Identifying Users via Context.User

Inside a Hub, you can access information about the connected user via the Context.User property. This allows you to perform logic based on their identity or claims.

  • Context.UserIdentifier: Returns the unique ID of the user (usually the NameIdentifier claim).
  • Context.User.Identity.Name: Returns the username.
  • Context.User.IsInRole("Admin"): Checks for specific roles.
public override async Task OnConnectedAsync()
{
    var name = Context.User.Identity.Name;
    await Groups.AddToGroupAsync(Context.ConnectionId, "AuthenticatedUsers");
    await base.OnConnectedAsync();
}

Advanced Security Considerations

Feature Description
CORS You must explicitly allow the origin of your client-side app in Program.cs to prevent Cross-Site Request Forgery.
Resource Authorization Use IAuthorizationService within a Hub method to check if a user has permission to a specific resource (e.g., "Can this user post to this specific chat room?").
Message Size Limits To prevent Denial of Service (DoS) attacks, configure MaximumReceiveMessageSize in your Hub options.

Warning: Never send sensitive information (like passwords or private keys) through a SignalR Hub without ensuring the connection is encrypted via HTTPS. SignalR does not provide its own encryption; it relies on the underlying transport's security.

Note: If you are using Blazor Server, you don't need to manually configure JWT for SignalR. Blazor Server manages the SignalR connection automatically using the authentication state of the circuit.

Data Access (Entity Framework Core) Last updated: March 1, 2026, 8:49 p.m.

Entity Framework (EF) Core is the official Object-Relational Mapper (ORM) for ASP.NET Core, acting as a bridge between your C# objects and the underlying database. It allows developers to perform data operations using LINQ (Language Integrated Query), which keeps the code type-safe and readable. EF Core supports a wide range of databases, including SQL Server, PostgreSQL, MySQL, and SQLite, and includes a powerful Migrations system to manage database schema changes over time without losing data.

Beyond simple CRUD operations, EF Core provides advanced features like Change Tracking, Lazy Loading, and Global Query Filters. It is designed to be highly performant, offering "compiled queries" and "no-tracking" options for high-traffic read scenarios. By automating the repetitive task of writing raw SQL, EF Core significantly increases developer productivity while reducing the likelihood of common errors like SQL injection.

Introduction to EF Core

Entity Framework (EF) Core is the official Object-Relational Mapper (ORM) for .NET. It acts as a bridge between the object-oriented code in your C# application and the relational data stored in a database (like SQL Server, PostgreSQL, or SQLite). EF Core allows you to interact with data using C# objects (Entities), eliminating the need to write most of the data-access code that developers otherwise have to write manually.


Core Concepts

To understand EF Core, you must be familiar with its three primary building blocks:

Component Responsibility
The Model (Entities) Standard C# classes that represent your data structure. Each class typically maps to a table.
The DbContext The primary class responsible for interacting with the database. It manages connections and tracks changes.
Database Providers Library-specific plug-ins that allow EF Core to "speak" to different database engines (SQL Server, MySQL, etc.).

Development Approaches

EF Core supports two primary workflows for aligning your code with your database schema.

  1. Code-First (Recommended)
  2. You define your domain model using C# classes. EF Core then generates the database schema for you. This is the preferred approach for new projects because it keeps the "source of truth" within your code and version control.

  3. Database-First
  4. If you have an existing database, you can use EF Core tools to "reverse engineer" the schema. The tools generate C# classes and a DbContext that match your existing tables.


Basic Anatomy of an EF Core Setup

The Entity

A simple class representing a record in the database.

public class Product
{
    public int Id { get; set; } // Recognized as Primary Key by convention
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
}

The DbContext

This class acts as the gateway to the database. You define DbSet<T> properties for each table you want to query.

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    public DbSet<Product> Products => Set<Product>();
}

Key Features of EF Core

Feature Description
LINQ Queries Allows you to write database queries using C# syntax instead of raw SQL strings.
Change Tracking EF Core monitors changes made to your entity objects and automatically generates UPDATE statements.
Migrations A version control system for your database schema. It tracks changes to your classes and updates the DB.
Relationship Management Easily handles One-to-Many and Many-to-Many relationships between tables.

Why use an ORM?

  • Productivity: You write less code. CRUD (Create, Read, Update, Delete) operations are handled for you.
  • Maintainability: Refactoring a property name in C# is easier than hunting down strings in SQL queries.
  • Database Abstraction: You can often switch from SQL Server to PostgreSQL by simply changing the Provider in your configuration, without rewriting your business logic.

Warning: While EF Core is powerful, it can lead to performance issues if used blindly. For example, the "N+1 Problem" (making multiple database calls when one would suffice) is a common pitfall. Always monitor the SQL being generated during development.

Note: EF Core is cross-platform. You can develop on macOS or Linux and deploy to a Windows Server running SQL Server, or a Linux container running SQLite.

The DbContext and Entity Models

The foundation of any EF Core application lies in two specific code structures: Entities, which define the shape of your data, and the DbContext, which coordinates how that data is saved to and retrieved from the database. Together, they form the "Data Access Layer" of your application.


  1. Defining Entity Models
  2. An Entity is a plain C# class (POCO) that maps to a database table. By following specific naming conventions, EF Core can automatically determine which properties are primary keys, which are required, and how they relate to other tables.

    Convention-Based Mapping

    • Primary Key: A property named Id or [ClassName]Id (e.g., ProductId) is automatically treated as the Primary Key.
    • Table Name: By default, the table name will match the name of the DbSet property in your DbContext (usually pluralized).
    • Nullability: A string? (nullable) allows NULLs in the database, while a string (non-nullable) creates a NOT NULL column.
    • public class Category
      {
          public int Id { get; set; }
          public string Name { get; set; } = string.Empty;
          
          // Navigation Property: One Category has many Products
          public List<Product> Products { get; set; } = new();
      }
      
      public class Product
      {
          public int Id { get; set; }
          public string Name { get; set; } = string.Empty;
          public decimal Price { get; set; }
          
          // Foreign Key and Navigation Property
          public int CategoryId { get; set; }
          public Category? Category { get; set; }
      }
  3. The DbContext Class
  4. The DbContext is the most important class in EF Core. It represents a session with the database and provides an API for querying and saving data. It acts as a combination of the Unit of Work and Repository patterns.

    Responsibilities of the DbContext:

    • DbSet Properties: Each DbSet<T> represents a table in the database.
    • Change Tracking: It keeps track of which objects have been modified, deleted, or added since they were loaded.
    • Configuration: It defines how entities map to the database schema (using Data Annotations or the Fluent API).
    • using Microsoft.EntityFrameworkCore;
      
      public class StoreContext : DbContext
      {
          public StoreContext(DbContextOptions<StoreContext> options) : base(options) { }
      
          // Table definitions
          public DbSet<Product> Products => Set<Product>();
          public DbSet<Category> Categories => Set<Category>();
      
          // Overriding this method allows for advanced configuration (Fluent API)
          protected override void OnModelCreating(ModelBuilder modelBuilder)
          {
              modelBuilder.Entity<Product>()
                  .Property(p => p.Price)
                  .HasPrecision(18, 2); // Ensures high precision for currency
          }
      }
  5. Configuring the DbContext in Program.cs
  6. To use the DbContext within your application, you must register it with the Dependency Injection (DI) container. This tells ASP.NET Core which database provider to use and where the connection string is located.

    var builder = WebApplication.CreateBuilder(args);
    
    // Retrieve connection string from appsettings.json
    var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
    
    // Register the DbContext with SQL Server
    builder.Services.AddDbContext<StoreContext>(options =>
        options.UseSqlServer(connectionString));

Data Annotations vs. Fluent API

There are two ways to configure how your C# classes map to the database.

Method Syntax Style Pros Cons
Data Annotations Attributes on properties (e.g., [Required]). Easy to read; keeps config with the class. Limited in complexity; "pollutes" domain models with DB metadata.
Fluent API Code inside OnModelCreating. Extremely powerful; keeps domain models "clean." Can become a very large, complex method; more boilerplate.

Warning: Be careful when using DbContext in multi-threaded scenarios. A single DbContext instance is not thread-safe. In ASP.NET Core, the DI container injects a "Scoped" instance, meaning a new context is created for every HTTP request and disposed of at the end, which prevents most threading issues.

Note: Always use Set<T>() or initialize your DbSet properties to avoid null warnings in modern C# versions.

Managing Database Migrations

Migrations are the version control system for your database schema. Instead of manually writing SQL scripts to create tables or add columns, you use EF Core Migrations to track changes made to your C# entity models and propagate those changes to the database. This ensures that every developer on a team—and every environment (Dev, Staging, Production)—has a consistent database schema.


The Migration Workflow

Managing your database with migrations follows a specific, repeatable three-step cycle:

  1. Modify the Code: You update your C# entity classes (e.g., adding a Sku property to the Product class).
  2. Add a Migration: You run a command that inspects the difference between your current code and the last known state. EF Core generates a C# script containing the Up() and Down() methods.
  3. Update the Database: You apply the migration, which executes the generated script against your target database.

Essential Migration Commands

Migrations are managed via the .NET CLI or the Package Manager Console (PMC) in Visual Studio.

Action .NET CLI Command PMC Command (VS)
Create a Migration dotnet ef migrations add Name Add-Migration Name
Apply to Database dotnet ef database update Update-Database
Remove Last Migration dotnet ef migrations remove Remove-Migration
Generate SQL Script dotnet ef migrations script Script-Migration

Anatomy of a Migration File

When you add a migration, EF Core creates a file in your project with two primary methods:

  • Up: Contains the code required to apply the changes to the database (e.g., CreateTable or AddColumn).
  • Down: Contains the code required to revert the changes, returning the database to its previous state.
protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.AddColumn<string>(
        name: "Sku",
        table: "Products",
        type: "nvarchar(max)",
        nullable: true);
}

protected override void Down(MigrationBuilder migrationBuilder)
{
    migrationBuilder.DropColumn(
        name: "Sku",
        table: "Products");
}

Production Strategies

Applying migrations in a production environment requires more care than in development.

Strategy Description Best For
SQL Scripts Generate a raw .sql file and execute it via your standard DBA tools. Regulated environments; high-security production DBs.
Runtime Migration Call context.Database.Migrate() at application startup. Small apps; cloud-native services where the DB is managed by the app.
Idempotent Scripts A script that checks if a change has already been applied before running it. CI/CD pipelines where the script might run multiple times.

Best Practices

  • Review your migrations: Always inspect the generated C# code before applying it. EF Core can sometimes misinterpret a "rename" as a "drop and create," which would result in data loss.
  • Keep migrations in Source Control: Treat migration files as source code. They should be checked into Git so the entire team stays in sync.
  • Small, frequent changes: Avoid making massive changes to your models at once. Smaller migrations are easier to debug and roll back if something goes wrong.
  • Handle Data Migrations: If you need to transform existing data (not just schema), you can add custom SQL inside the Up method using migrationBuilder.Sql("UPDATE ...").

Warning: Never delete your Migration Snapshot (the file ending in ModelSnapshot.cs). This file is used by EF Core to determine what has changed since the last migration. If it's lost, EF Core will try to recreate the entire database from scratch in the next migration.

Note: If you are working in a team, you may encounter merge conflicts in the ModelSnapshot. Usually, the best way to resolve this is to delete the problematic migration, pull the latest code, and re-add the migration.

Querying Data with LINQ

LINQ (Language Integrated Query) allows you to write queries for your database directly in C#. When you write a LINQ query against a DbSet, EF Core translates that C# code into highly optimized SQL for your specific database provider. This allows you to work with strongly typed objects while leveraging the performance of the database engine.


The Two Syntax Styles

There are two ways to write LINQ queries. While they achieve the same result, Method Syntax is the most common in modern .NET development.

Feature Method Syntax (Fluent) Query Syntax (SQL-like)
Appearance Uses extension methods and lambdas. Uses keywords like from, where, select.
Popularity De facto standard for Web APIs. Preferred by developers with heavy SQL backgrounds.
Example db.Products.Where(p => p.Price > 10) from p in db.Products where p.Price > 10 select p

Basic CRUD Operations

The following table summarizes how to perform the four core database operations using EF Core.

Operation LINQ / EF Core Method Description
Create db.Add(entity) Tracks a new object to be inserted.
Read db.Products.ToList() Retrieves records from the database.
Update entity.Property = value Updates tracked objects in memory.
Delete db.Remove(entity) Marks a tracked object for deletion.

Execution: The Importance of SaveChangesAsync()

EF Core uses the Unit of Work pattern. Changes are only "staged" in memory until you call SaveChangesAsync(). This wraps all pending changes into a single database transaction.

// Example: Creating and Saving a Product
var product = new Product { Name = "Keyboard", Price = 49.99m };
_context.Products.Add(product);
await _context.SaveChangesAsync(); // SQL INSERT happens here

Common Query Patterns

  1. Filtering and Ordering
  2. var expensiveItems = await _context.Products
        .Where(p => p.Price > 100)        // SQL: WHERE Price > 100
        .OrderBy(p => p.Name)             // SQL: ORDER BY Name
        .ToListAsync();
  3. Selecting a Single Item
    • FirstAsync(): Returns the first item; throws an exception if none are found.
    • FirstOrDefaultAsync() Returns the first item or null if none are found (Safest).
    • FindAsync(id): Optimized for looking up items by their Primary Key.
  4. Projections (Selecting specific columns)
  5. To improve performance, only retrieve the columns you actually need by projecting into an anonymous type or a DTO (Data Transfer Object).

    var productNames = await _context.Products
        .Select(p => new { p.Id, p.Name }) // SQL: SELECT Id, Name (Price is ignored)
        .ToListAsync();

    Loading Related Data

    By default, EF Core does not load related data (navigation properties) to save bandwidth. This is called Lazy Loading (disabled by default in EF Core). You must explicitly tell EF Core to fetch related records.

    • Eager Loading: Uses .Include() to fetch related data in the initial query (JOIN).
    • Explicit Loading: Fetching related data for an entity that has already been loaded.
    // Eager Loading: Fetch products AND their categories in one SQL call
    var productsWithCategory = await _context.Products
        .Include(p => p.Category)
        .ToListAsync();

IQueryable vs. IEnumerable

Understanding the difference between these two interfaces is critical for performance.

Interface Where execution happens Best For
IQueryable<T> On the Database Server. Filtering, sorting, and paging large datasets.
IEnumerable<T> In the Application Memory. Operating on data after it has been retrieved.

Warning: Avoid calling .ToList() too early in a query chain. Doing so converts the query to IEnumerable, meaning all subsequent filters (like Where) will happen in your app's memory instead of the database, potentially downloading thousands of unnecessary rows.

Note: For read-only scenarios, use .AsNoTracking(). This tells EF Core not to waste resources monitoring the objects for changes, making the query significantly faster.

Managing Relationships and Loading Strategies

Relational databases are defined by how tables connect to one another. EF Core makes it easy to navigate these connections using Navigation Properties. However, because loading related data can be resource-intensive, EF Core provides several strategies to control exactly when and how that data is retrieved.

  1. Defining Relationships
  2. EF Core can usually infer relationships based on your property names, but you can also define them explicitly using the Fluent API.

    Relationship Type Example Implementation
    One-to-Many One Category → Many Products A List<Product> in Category; a CategoryId in Product.
    One-to-One One User → One Profile A Profile property in User; a UserId as PK/FK in Profile.
    Many-to-Many Many Students ↔ Many Courses A List<T> in both classes. EF Core automatically creates a "Join Table."

  3. Loading Related Data
  4. When you query an entity, its navigation properties are null by default. You must choose a strategy to populate them.

    Eager Loading

    This is the most common strategy. It uses the .Include() method to fetch related data as part of the initial SQL query using a JOIN.

    // Fetches Products and their Category in a single database round-trip
    var products = await _context.Products
        .Include(p => p.Category)
        .ThenInclude(c => c.Department) // Multi-level loading
        .ToListAsync();

    Explicit Loading

    If you already have an entity in memory, you can manually load a related property later. This is useful when you only need the data based on a specific condition in your code.

    var product = await _context.Products.FirstAsync(p => p.Id == 1);
    
    // Manually load the Category only if needed
    await _context.Entry(product).Reference(p => p.Category).LoadAsync();

    Lazy Loading

    Related data is automatically loaded from the database the first time the navigation property is accessed.

    • Requirement: Requires the Microsoft.EntityFrameworkCore.Proxies package and virtual properties.
    • Warning: Highly discouraged in web applications as it can lead to the N+1 Query Problem, where the app makes dozens of tiny, inefficient database calls inside a loop.
  5. Comparison of Loading Strategies
  6. Strategy Performance Complexity Best For...
    Eager High Low Most standard web API requests.
    Explicit Moderate High Scenarios with complex branching logic.
    Lazy Low Low Small desktop apps or rapid prototyping.

  7. Best Practices for Relationships
    • Use Foreign Key Properties: Always include an explicit FK property (e.g., public int CategoryId { get; set; }) alongside the navigation property. This makes it easier to update relationships without loading the entire related object.
    • Avoid Circular References: When serializing entities to JSON in a Web API, circular references (Category -> Product -> Category) will cause errors. Use DTOs (Data Transfer Objects) or configure the JSON serializer to ignore cycles.
    • Filtering Includes: You can now filter data within an .Include() call (e.g., .Include(p => p.Comments.Where(c => c.IsApproved))).

Warning: Be careful with Eager Loading too many levels deep. Each .Include() adds another JOIN to the SQL statement, which can significantly slow down the query if the tables are large.

Note: For read-only displays, combine your loading strategy with .AsNoTracking(). This ensures that EF Core doesn't waste memory tracking the related entities for changes.

Security & Identity Last updated: March 1, 2026, 8:49 p.m.

Security is a top-tier priority in ASP.NET Core, centered around the Identity membership system and a robust Authentication/Authorization stack. Identity provides a complete UI and logic set for managing users, passwords, roles, and two-factor authentication (2FA). The framework is designed to handle modern security standards, including OAuth2 and OpenID Connect, allowing users to sign in using external providers like Google, Microsoft, or Facebook.

Authorization goes beyond simple roles; it uses a Policy-based model that allows for complex, logic-driven access control. You can define requirements based on user claims, age, or even resource-specific data (like "is this user the owner of this document?"). Combined with built-in protections against common web threats like Cross-Site Request Forgery (CSRF) and Cross-Site Scripting (XSS), the framework provides a "secure by default" environment for sensitive applications.

Authentication Concepts

In ASP.NET Core, Authentication is the process of determining a user's identity. It answers the question: "Who are you?" The framework provides a flexible, middleware-based system that can handle everything from simple cookie-based logins to complex token-based systems used by mobile apps and microservices.


The Three Pillars of Identity

To understand how security works in .NET, you must distinguish between these three core objects:

Object Analogy Description
Claim A piece of info on a Driver's License. A single statement about the user (e.g., Email, Date of Birth, or Role).
ClaimsIdentity The Driver's License itself. A collection of claims issued by a trusted authority. A user can have multiple identities (e.g., a Passport and a Work ID).
ClaimsPrincipal The Person holding the licenses. The "wrapper" that holds all identities for the current user. Accessible via User in controllers.

Common Authentication Schemes

An Authentication Scheme defines how the user's identity is transmitted and validated during an HTTP request.

Scheme Primary Use Case How it Works
Cookies Traditional Web Apps (MVC/Razor Pages). The server sends a cookie to the browser; the browser sends it back with every request.
JWT Bearer Web APIs / SPAs (React, Angular). The client sends a "Token" in the HTTP Authorization header (Bearer <token>).
OAuth2 / OIDC External Logins (Google, Microsoft). The user logs in on a third-party site, which sends a code back to your app.

The Authentication Middleware

Authentication in ASP.NET Core is handled by a dedicated service and middleware. It must be registered in the correct order within Program.cs to function properly.

var builder = WebApplication.CreateBuilder(args);

// 1. Add Authentication Services
builder.Services.AddAuthentication("CookieAuth")
    .AddCookie("CookieAuth", config =>
    {
        config.Cookie.Name = "User.Session";
        config.LoginPath = "/Account/Login";
    });

var app = builder.Build();

// 2. Add Middleware to the Pipeline
app.UseRouting();

app.UseAuthentication(); // Must come after UseRouting
app.UseAuthorization();  // Must come after UseAuthentication

app.MapControllers();
app.Run();

Multi-Factor Authentication (MFA)

Modern security standards often require more than just a password. ASP.NET Core Identity supports MFA out of the box, typically using:

  • TOTP (Time-based One-Time Password): Using apps like Google Authenticator.
  • SMS/Email Codes: Sending a short-lived code to a verified device.
  • Recovery Codes: Static codes used if the user loses access to their MFA device.

Comparison: Statefull vs. Stateless

Feature Cookie-Based (Stateful) Token-Based (Stateless)
Storage Browser Cookie storage. LocalStorage or Memory.
Server Burden Higher (session must be tracked). Lower (server just validates the token signature).
CORS Issues Complex (Cookies are tied to domains). Simple (Tokens are sent manually in headers).
Revocation Easy (Delete the session on server). Difficult (Tokens are valid until they expire).

Warning: Always use HTTPS when handling authentication. Without encryption, sensitive data like passwords, cookies, and tokens can be intercepted via "Man-in-the-Middle" attacks.

Note: The ClaimsPrincipal is available in every request through the HttpContext.User property. You can check if a user is authenticated using User.Identity.IsAuthenticated.

ASP.NET Core Identity Setup

ASP.NET Core Identity is a complete membership system that handles users, passwords, roles, and profile data. It is pre-built with security best practices, including password hashing, account lockout, and two-factor authentication. Unlike a custom solution, Identity manages the complex logic of security tokens and database persistence for you.

  1. Core Components of Identity
  2. Identity relies on several key classes to manage different aspects of security. These are typically injected into your controllers or services via Dependency Injection.

    Component Responsibility
    UserManager<TUser> Handles user-related logic: creating users, hashing passwords, and finding users by email.
    SignInManager<TUser> Manages the login/logout process and handles multi-factor authentication challenges.
    RoleManager<TRole> Manages roles (e.g., "Admin", "User") and permissions within the application.
    IdentityUser The base class for a user. It includes properties like UserName, Email, and PasswordHash.
  3. Configuration in Program.cs
  4. To set up Identity, you must link it to an Entity Framework DbContext and configure the security requirements (password complexity, lockout settings, etc.).

    // 1. Define the DbContext using IdentityDbContext
    public class ApplicationDbContext : IdentityDbContext<IdentityUser>
    {
        public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
            : base(options) { }
    }
    
    // 2. Register Identity in Program.cs
    builder.Services.AddDefaultIdentity<IdentityUser>(options => {
        // Password settings
        options.Password.RequireDigit = true;
        options.Password.RequiredLength = 8;
        options.Password.RequireNonAlphanumeric = true;
    
        // Lockout settings
        options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
        options.Lockout.MaxFailedAccessAttempts = 5;
    
        // User settings
        options.User.RequireUniqueEmail = true;
    })
    .AddEntityFrameworkStores<ApplicationDbContext>();
  5. Customizing the User Model
  6. Most applications need more than just an email and password. You can extend the ,IdentityUser class to add custom properties like FirstName, astName, or SubscriptionTier.

    public class ApplicationUser : IdentityUser
    {
        public string FirstName { get; set; } = string.Empty;
        public string LastName { get; set; } = string.Empty;
        public DateTime DateJoined { get; set; } = DateTime.UtcNow;
    }
    
    // Ensure you update your DbContext and Program.cs to use ApplicationUser instead of IdentityUser
  7. Database Schema
  8. When you run migrations for an Identity-enabled project, EF Core creates several tables to store the membership data.

    Table Name Description
    AspNetUsers Stores the main user account information.
    AspNetRoles Stores the different access levels (Roles).
    AspNetUserRoles A join table mapping users to their respective roles (Many-to-Many).
    AspNetUserClaims Stores individual claims (pieces of info) about a user.
    AspNetUserLogins Stores info for external logins (e.g., Google or Facebook).

    Implementation Options

    Depending on your project type, you can implement the UI for Identity in different ways:

    • Scaffolded Identity: Generates pre-built Razor Pages for Login, Register, and Account Management. This is the fastest way to get started with MVC or Blazor.
    • Identity API Endpoints: (Introduced in .NET 8) Provides a set of built-in REST API endpoints (/register, /login) for SPAs like React or Angular without needing to build custom controllers.

Warning: Never attempt to store passwords in plain text or write your own hashing algorithm. ASP.NET Core Identity uses PBKDF2 with a unique salt per user by default, which is an industry standard for protecting against rainbow table attacks.

Note: If you are building a Blazor Web App, use the "Individual Accounts" authentication type during project creation. This automatically sets up the entire Identity system, including the UI components.

Cookie Authentication

While ASP.NET Core Identity is a full-featured membership system, Cookie Authentication is the underlying mechanism used for stateful web applications (like MVC, Razor Pages, or Blazor Server). It allows the server to remember a user's identity across multiple HTTP requests without requiring them to log in every time they click a link.


How Cookie Authentication Works

Cookie authentication relies on an encrypted "ticket" stored in a browser cookie. Unlike a database-backed session, the cookie itself contains the user's claims, which the server decrypts on every request.

  1. Login: The user provides credentials. The server validates them and creates a ClaimsPrincipal.
  2. Issue: The server serializes the principal into an encrypted string and sends it to the browser as a cookie (usually named .AspNetCore.Cookies).
  3. Request: For every subsequent request, the browser automatically attaches this cookie.
  4. Validate: The Authentication Middleware intercepts the cookie, decrypts it, and re-populates the User (ClaimsPrincipal) object in the HttpContext.

Configuration in Program.cs

To enable manual cookie authentication (without using the full Identity framework), you must register the service and specify how the cookie should behave.

using Microsoft.AspNetCore.Authentication.Cookies;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.Cookie.Name = "MyAuthCookie";
        options.LoginPath = "/Account/Login";     // Redirect here if unauthorized
        options.AccessDeniedPath = "/Account/Forbidden";
        options.ExpireTimeSpan = TimeSpan.FromMinutes(60); // Cookie life
        options.SlidingExpiration = true;        // Resets expiration on activity
    });

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

Signing In and Out Programmatically

When using manual cookie authentication, you are responsible for calling the SignInAsync and SignOutAsync methods.

  1. Signing In
  2. You create a list of claims, wrap them in an identity, and "sign in" the user.

    public async Task<IActionResult> Login(string username)
    {
        var claims = new List<Claim>
        {
            new Claim(ClaimTypes.Name, username),
            new Claim(ClaimTypes.Role, "User"),
            new Claim("LastLogin", DateTime.Now.ToString())
        };
    
        var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
    
        await HttpContext.SignInAsync(
            CookieAuthenticationDefaults.AuthenticationScheme, 
            new ClaimsPrincipal(claimsIdentity));
    
        return RedirectToAction("Index", "Home");
    }
  3. Signing Out
  4. This command instructs the browser to delete the authentication cookie.

    public async Task<IActionResult> Logout()
    {
        await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
        return RedirectToAction("Login");
    }

    Security Properties

    Cookies are highly vulnerable to certain types of attacks if not configured correctly. ASP.NET Core sets secure defaults, but it is important to understand these properties:

    Property Description Benefit
    HttpOnly Prevents client-side scripts from accessing the cookie. Mitigates XSS.
    Secure Cookie is only sent over HTTPS connections. Prevents Man-in-the-Middle.
    SameSite Controls cookie sending with cross-site requests. Mitigates CSRF.

    Persistent vs. Session Cookies

    • Session Cookie: Stored only in the browser's memory. It is deleted when the browser is closed.
    • Persistent Cookie: Stored on the user's hard drive. It survives browser restarts. This is achieved by setting IsPersistent = true in the AuthenticationProperties during sign-in (often tied to a "Remember Me" checkbox).

Warning: Encrypted cookies can become quite large if you store too many claims. Since cookies are sent with every request (including images and CSS), large cookies can slow down your site. Only store essential identification data in claims.

Note: Cookie authentication is generally not suitable for mobile apps or third-party integrations, as they may not support cookie-based state management. For those scenarios, use JWT Bearer Authentication.

JWT Bearer Tokens

JSON Web Tokens (JWT) are the industry standard for securing stateless communications, primarily in Web APIs and Single Page Applications (SPAs). Unlike Cookie Authentication, which relies on the browser to manage state, JWTs are "bearer" tokens. This means that whoever "bears" the token is granted access, making them ideal for mobile apps and cross-domain microservices.


The Structure of a JWT

A JWT is a string composed of three parts separated by dots (.): Header, Payload, and Signature.

Part Content Purpose
Payload Claims (User ID, Roles, Expiration). Contains the actual user data.
Signature Hash of Header + Payload + Secret Key. Ensures the token hasn't been tampered with.

Configuration in Program.cs

To protect an API with JWTs, you must configure the JwtBearer authentication handler. This handler intercepts the Authorization: Bearer <token> header and validates the signature and expiration.

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = "your-app",
            ValidAudience = "your-api",
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("YourSecretSuperKey123!"))
        };
    });

Generating a Token

When a user logs in successfully, the server creates a token using the JwtSecurityTokenHandler.

public string GenerateJwtToken(string username)
{
    var claims = new[] {
        new Claim(JwtRegisteredClaimNames.Sub, username),
        new Claim(ClaimTypes.Role, "Admin"),
        new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
    };

    var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("YourSecretSuperKey123!"));
    var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

    var token = new JwtSecurityToken(
        issuer: "your-app",
        audience: "your-api",
        claims: claims,
        expires: DateTime.Now.AddHours(1),
        signingCredentials: creds
    );

    return new JwtSecurityTokenHandler().WriteToken(token);
}

Advantages and Disadvantages

Feature JWT (Stateless) Cookies (Stateful)
Scalability High (no session storage needed). Moderate (needs session sync).
CORS Easy (handled via headers). Difficult (domain restrictions).
Revocation Hard (token is valid until expiry). Easy (delete session on server).
Security Requires secure storage (XSS risk). Vulnerable to CSRF.

Token Revocation: Refresh Tokens

Since JWTs are stateless, you cannot easily "log out" a user once a token is issued. To balance security and usability, developers use two tokens:

  1. Access Token: Short-lived (e.g., 15 minutes). Used for every API call.
  2. Refresh Token: Long-lived (e.g., 7 days). Stored in a database and used to get a new Access Token without re-entering a password.

Warning: Never store sensitive information like passwords or PII (Personally Identifiable Information) inside a JWT payload. While the token is signed, it is not encrypted; anyone with the token can decode the payload using tools like jwt.io.

Note: For maximum security, use an Asymmetric signing algorithm (like RSA). This allows the Auth Server to sign the token with a Private Key, while the Web API only needs a Public Key to verify it.

Authorization (Roles, Claims, Policies)

While authentication identifies the user, Authorization determines what an identified user is allowed to do. ASP.NET Core provides a tiered approach to authorization, ranging from simple role-based checks to complex, requirement-driven policies.


  1. Role-Based Authorization
  2. The simplest form of authorization. It checks if a user belongs to a specific group (e.g., "Admin", "Manager"). This is typically used for broad access control.

    • Usage: Apply the [Authorize] attribute with the Roles property.
    • Code Example:

    • [Authorize(Roles = "Admin, Editor")]
      public class AdminController : Controller { ... }

  3. Claims-Based Authorization
  4. Claims are key-value pairs assigned to a user (e.g., DateOfBirth: 1990-01-01). This allows for more granular control than roles. Instead of checking if someone is an "Admin," you check if they have the "EmployeeId" claim.

    • Logic: A claim is a statement about the user, not what they can do.
    • Benefit: Highly flexible; you can store any piece of metadata as a claim.
  5. Policy-Based Authorization
  6. Policies are the modern, recommended way to handle authorization in ASP.NET Core. A Policy decouples the authorization logic from the controller. It can combine roles, claims, and custom code into a single named requirement.

    Register the Policy (Program.cs)

    builder.Services.AddAuthorization(options =>
    {
        options.AddPolicy("AtLeast18", policy => 
            policy.RequireClaim("Age", "18", "19", "20", "21"));
            
        options.AddPolicy("AdminOnly", policy => 
            policy.RequireRole("Admin"));
    });

    Apply the Policy

    [Authorize(Policy = "AtLeast18")]
    public IActionResult AdultContent() { ... }

    Comparison of Authorization Strategies

    Strategy Logic Location Flexibility Best For...
    Role-Based Controller / Method Low Simple, static permission groups.
    Claims-Based Controller / Method Specific user attributes (e.g., Department).
    Policy-Based Centralized Config High Complex business rules and reusable logic.
  7. Custom Requirement Handlers
  8. For scenarios that cannot be solved with simple claims (e.g., "User must be the owner of the document they are trying to edit"), you can create Custom Requirements.

    1. Requirement: A class that holds data for the policy.
    2. Handler A class containing the logic to evaluate the requirement.
public class MinimumAgeRequirement : IAuthorizationRequirement
{
    public int MinimumAge { get; }
    public MinimumAgeRequirement(int age) => MinimumAge = age;
}

// Logic to check if user meets the age requirement
public class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, MinimumAgeRequirement requirement)
    {
        // Logic to extract age claim and compare
        if (/* valid */) context.Succeed(requirement);
        return Task.CompletedTask;
    }
}

Resource-Based Authorization

Standard authorization occurs before the action executes. Resource-Based Authorization occurs inside the action because you need to load the data (the "Resource") before you can decide if the user has access to it.

Example: A user can edit a "Post" only if Post.AuthorId == CurrentUserId.

var post = await _context.Posts.FindAsync(id);
var authorizationResult = await _authService.AuthorizeAsync(User, post, "EditPolicy");

if (!authorizationResult.Succeeded) return Forbid();

Warning: Avoid putting complex database queries inside your Authorization Handlers if possible, as these handlers may run frequently, potentially slowing down your application.

Note: You can use the [AllowAnonymous] attribute to bypass authorization for specific methods in a controller that is otherwise protected.

CORS (Cross-Origin Resource Sharing)

CORS is a browser security feature that restricts web pages from making requests to a different domain than the one that served the web page. This "Same-Origin Policy" prevents malicious sites from reading sensitive data from another site. However, for modern applications where a frontend (e.g., localhost:3000) calls a Web API (e.g., api.myapp.com), you must explicitly configure the server to allow these cross-origin requests.


How CORS Works (The Preflight)

When a browser makes a "non-simple" request (like an API call with a PUT method or custom headers), it first sends an OPTIONS request, known as a Preflight. The server must respond with headers confirming that it allows the origin, the method, and the headers being sent.

Header Description
Access-Control-Allow-Origin Specifies which domains are allowed (e.g., https://example.com).
Access-Control-Allow-Methods Specifies allowed HTTP verbs (e.g., GET, POST, DELETE).
Access-Control-Allow-Headers Specifies which custom headers can be sent (e.g., Authorization).
Access-Control-Allow-Credentials Indicates if the browser should send cookies or auth headers.

Configuring CORS in ASP.NET Core

CORS is configured in two steps in Program.cs: first by defining the Policy in the services container, and then by applying it as Middleware.

  1. Defining the Policy
  2. var builder = WebApplication.CreateBuilder(args);
    
    builder.Services.AddCors(options =>
    {
        options.AddPolicy("AllowFrontendApp",
            policy =>
            {
                policy.WithOrigins("https://www.myapp.com", "http://localhost:3000")
                      .AllowAnyHeader()
                      .AllowAnyMethod()
                      .AllowCredentials(); // Required if using Cookies or Windows Auth
            });
    });
  3. Applying the Middleware
  4. The middleware must be placed after UseRouting but before UseAuthorization (and before any endpoints).

    var app = builder.Build();
    
    app.UseRouting();
    
    // Order is critical!
    app.UseCors("AllowFrontendApp");
    
    app.UseAuthentication();
    app.UseAuthorization();
    
    app.MapControllers();
    app.Run();

Applying CORS to Specific Controllers

If you don't want to apply CORS globally, you can use the [EnableCors] attribute to target specific controllers or individual action methods.

[ApiController]
[Route("api/[controller]")]
[EnableCors("AllowFrontendApp")] // Only this controller uses this policy
public class ProductsController : ControllerBase { ... }

Common Pitfalls and Best Practices

Pitfall Solution
Using AllowAnyOrigin with AllowCredentials This is prohibited by browsers. If you need credentials, you must list specific origins.
Middleware Order If UseCors is placed after MapControllers, the browser will receive a 404 or a blocked CORS error.
Trailing Slashes https://example.com and https://example.com/ are treated as different origins. Be precise.
Development vs Production Use different policies for dev (allowing localhost) and production (strict domains).

Troubleshooting Tips

  • Browser Console: If CORS fails, the browser console provides the most accurate error message (e.g., "No 'Access-Control-Allow-Origin' header is present").
  • Fiddler/Postman: CORS is a browser-only restriction. Postman will often succeed where a browser fails because Postman does not enforce the Same-Origin Policy.
  • Network Tab: Check the "Headers" of the OPTIONS request. If it doesn't return a 200 OK with the correct CORS headers, the server configuration is likely incorrect.

Warning: Never use policy.AllowAnyOrigin() in a production environment unless your API is intended to be completely public (like a public weather API). Allowing any origin makes your users vulnerable to CSRF-style attacks.

Note: If you are using SignalR, CORS configuration is mandatory if the client is hosted on a different domain, as SignalR requires both WebSockets and potentially long-polling (which involves standard HTTP requests).

Data Protection and Secret Manager

Developing secure applications requires more than just authentication; it requires a strategy for protecting sensitive information both in the database and within your source code. ASP.NET Core provides the Data Protection API for encrypting data at rest and the Secret Manager to keep sensitive credentials out of your version control system.


The Secret Manager (Development Only)

One of the most common security failures is committing sensitive "secrets"—like API keys, database passwords, or private encryption keys—into a Git repository. The Secret Manager tool stores this sensitive data in a JSON file outside of your project folder.

  • How it works: It creates a secrets.json file in the user profile folder of your machine.
  • Scope: It is only intended for Development. In production, you should use environment variables or services like Azure Key Vault.

Usage via .NET CLI:

  1. Initialize: dotnet user-secrets init (Adds a UserSecretsId to your .csproj).
  2. Access in Code:
// Accessed exactly like appsettings.json
var dbPass = builder.Configuration["DbPassword"];

ASP.NET Core Data Protection API

The Data Protection API is used to protect data that needs to be "round-tripped"—encrypted so it can be safely stored or sent to a client, and then decrypted later by the server.

Common Use Cases:

  • Authentication Cookies: The ticket inside your auth cookie is encrypted using this API.
  • Password Reset Tokens: Ensuring the token sent via email hasn't been tampered with.
  • CSRF Tokens: Protecting against Cross-Site Request Forgery.

Manual Usage:

You can inject the IDataProtectionProvider to encrypt your own sensitive strings, such as a user's private ID in a URL.

public class CheckoutController : Controller
{
    private readonly IDataProtector _protector;

    public CheckoutController(IDataProtectionProvider provider)
    {
        // Use a "Purpose" string to isolate different types of data
        _protector = provider.CreateProtector("Order.Tracking.v1");
    }

    public string GetEncryptedId(int orderId)
    {
        return _protector.Protect(orderId.ToString());
    }
}

Key Management and Persistence

By default, the Data Protection API generates a "Master Key" and stores it in the user profile folder. However, this causes issues in professional deployments.

Deployment Scenario Problem Solution
IIS / Windows Server Key might be lost if profile is deleted. Configure to store keys in the Registry or a specific folder.
Azure Web Apps Multiple instances need the same key. Use Azure Key Vault or Azure Storage for key persistence.
Docker / Containers Keys are lost when the container restarts. Map a persistent volume or use a shared network store.

Secret Storage Comparison

Feature Secret Manager Environment Variables Azure Key Vault / AWS Secrets
Environment Development Testing / Production High-Security Production
Storage Location Local Disk (Plain text) System Memory Secure Cloud Hardware (HSM)
Team Sharing Manual (must be shared) CI/CD Pipeline Centralized Cloud Access

Best Practices

  • Never commit secrets.json: Ensure it is ignored by your .gitignore (though usually, it lives outside the project directory anyway).
  • Use Purpose Strings: When using the Data Protection API, always provide a unique "purpose" string. This ensures that a token encrypted for "Password Reset" cannot be used for "Delete Account."
  • Key Rotation: Be aware that keys expire (default is 90 days). The API handles rotation automatically, but you must ensure your storage location is persistent so old data can still be decrypted.

Warning: Do not use the Data Protection API for long-term storage (like encrypting credit cards in a database for years). It is designed for short-to-medium-term transient data. For long-term database encryption, use specialized database features like Always Encrypted in SQL Server.

Content goes here...

Performance & Caching Last updated: March 1, 2026, 8:49 p.m.

ASP.NET Core is consistently ranked among the fastest web frameworks in the world, thanks to its optimized request pipeline and memory management. Caching is a vital strategy within this section, providing multiple layers—Response Caching for the browser, In-Memory Caching for local server speed, and Distributed Caching (Redis) for cloud scalability. By reducing the number of database round-trips and expensive computations, these tools ensure that applications remain responsive under heavy load.

Performance tuning also involves Rate Limiting and Compression. Rate limiting protects resources from being overwhelmed by too many requests, while compression reduces the size of data transmitted over the wire, improving load times for users with slower connections. The framework's ability to handle thousands of concurrent requests with a low memory footprint makes it an ideal choice for high-scale microservices and enterprise applications.

Response Caching

Response Caching is a performance optimization technique that stores the output of an HTTP request (the HTML, JSON, or image) so that subsequent requests for the same resource can be served significantly faster. By serving a cached response, the server avoids re-executing expensive database queries, complex business logic, or heavy Razor rendering.


Types of Response Caching

In ASP.NET Core, caching can happen in two primary locations:

Type Location Managed By Best For...
Client-Side Browser HTTP Headers (Cache-Control) Static assets and user-specific data.
Server-Side Web Server Response Caching Middleware Shared data that is expensive to generate.

The [ResponseCache] Attribute

The easiest way to implement caching is by applying the [ResponseCache] attribute to your controller or specific action methods. This attribute sets the appropriate headers in the HTTP response.

[ResponseCache(Duration = 60, Location = ResponseCacheLocation.Any)]
public IActionResult GetProducts()
{
    // This logic only runs once every 60 seconds
    var products = _context.Products.ToList();
    return Ok(products);
}

Key Parameters:

  • Duration: The number of seconds the response should be cached.
  • Location: * Any: Cached by both the browser and proxy servers.
    • Client: Cached only by the browser.
    • None: Instructs the browser not to cache the response.
  • VaryByQueryKeys: (Middleware only) Caches different versions based on URL parameters (e.g., ?page=1 vs ?page=2).

Response Caching Middleware

While the attribute sets headers for the browser, the Response Caching Middleware allows the server itself to store the response in memory and serve it to other users.

Configuration in Program.cs:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddResponseCaching(); // 1. Add services

var app = builder.Build();

app.UseResponseCaching(); // 2. Add middleware (must be after UseRouting)

app.MapControllers();
app.Run();

Cache Profiles

Instead of hardcoding duration values throughout your application, you can define Cache Profiles in Program.cs and reference them by name. This makes it easier to update caching logic across the entire app.

builder.Services.AddControllers(options =>
{
    options.CacheProfiles.Add("Default30",
        new CacheProfile()
        {
            Duration = 30,
            Location = ResponseCacheLocation.Any
        });
});

// Usage
[ResponseCache(CacheProfileName = "Default30")]
public IActionResult Index() { ... }

When Response Caching is Skipped

To ensure security and data integrity, the middleware will not cache a response if any of the following are true:

  • The request is not a GET or HEAD method.
  • The response contains a Set-Cookie header.
  • The user is authenticated (caching authenticated pages could leak private data to other users).
  • The response code is not a 200 OK.

Best Practices

  • Avoid Caching Private Data: Never use ResponseCacheLocation.Any for pages that display user-specific information (like a profile page), as a proxy server might serve one user's data to another.
  • Use for Static-ish Data: Ideal for product catalogs, news articles, or configuration data that changes infrequently.
  • Monitor Memory: Since the middleware stores responses in the server's RAM, caching very large responses or many unique variations can lead to high memory consumption.

Warning: Response Caching is distinct from Output Caching (introduced in .NET 7). While Response Caching is based on HTTP standards, Output Caching is more powerful, allowing for manual cache invalidation and database-driven triggers.

In-Memory Caching

While Response Caching stores entire HTTP responses, In-Memory Caching allows you to store specific pieces of data (objects, lists, or strings) in the server's memory. This is ideal for frequently accessed data that is expensive to retrieve or compute but doesn't change often, such as configuration settings or a list of categories.


Key Characteristics

In-memory caching is the fastest form of caching because it avoids network latency. However, it has two major constraints:

  • Volatile: If the app restarts or the server crashes, the cache is lost.
  • Local: In a multi-server environment (server farm), each server has its own independent cache. This can lead to "data inconsistency" where Server A has updated data but Server B is still serving old cached data.

Implementation with IMemoryCache

To use in-memory caching, you must register the service in Program.cs and then inject IMemoryCache into your controllers or services.

Configuration

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMemoryCache(); // Register the service

Usage Pattern (Get or Create)

The most common pattern is to check if the data exists in the cache; if not, fetch it from the source and add it to the cache.

public class ProductService
{
    private readonly IMemoryCache _cache;
    private readonly AppDbContext _context;

    public ProductService(IMemoryCache cache, AppDbContext context)
    {
        _cache = cache;
        _context = context;
    }

    public async Task<List<Category>> GetCategoriesAsync()
    {
        const string cacheKey = "categoryList";

        // Attempt to get data from cache
        if (!_cache.TryGetValue(cacheKey, out List<Category>? categories))
        {
            // Data not in cache; fetch from Database
            categories = await _context.Categories.ToListAsync();

            // Configure cache options
            var cacheOptions = new MemoryCacheEntryOptions()
                .SetAbsoluteExpiration(TimeSpan.FromHours(1))
                .SetSlidingExpiration(TimeSpan.FromMinutes(20))
                .SetPriority(CacheItemPriority.High);

            // Save data in cache
            _cache.Set(cacheKey, categories, cacheOptions);
        }

        return categories!;
    }
}

Eviction Policies

Since server memory is limited, you must define when data should be removed from the cache.

Policy Description Use Case
Absolute Expiration The item expires after a fixed duration, regardless of how often it's accessed. Data that changes on a predictable schedule.
Sliding Expiration The expiration timer resets every time the item is accessed. Data that should stay "alive" as long as it's popular.
Size Limit Limits the total number of entries or memory used by the cache. Preventing "Out of Memory" errors on the server.
Priority During memory pressure, low-priority items are removed first. Ensuring critical data stays cached longer.

When to use In-Memory vs. Distributed Cache

Feature In-Memory Cache Distributed Cache (Redis)
Speed Extremely Fast (No network) Fast (Network overhead)
Scalability Single Server Only Shared across multiple servers
Consistency May vary between instances Same data for all instances
Survival Lost on app restart Persists after app restart

Best Practices

  • Cache Nulls: If a database query returns no results, cache a special "null" or "empty" value. This prevents "Cache Miss Attacks" where the app repeatedly hits the DB for data that doesn't exist.
  • Key Naming: Use a consistent naming convention for keys (e.g., User_Profile_123) to avoid accidental overwrites.
  • Don't Over-Cache: Only cache data that is truly expensive to get. Caching small, cheap-to-retrieve strings adds unnecessary complexity and memory overhead.

Warning: Always use Absolute Expiration alongside Sliding Expiration. If you only use Sliding, a frequently accessed item might stay in memory forever, even if the underlying database data has changed significantly.

Distributed Caching (Redis/SQL)

A Distributed Cache is a cache shared by multiple app servers, typically maintained as an external service. While In-Memory caching is limited to a single server instance, a distributed cache ensures that all servers in a "web farm" have access to the same cached data, providing consistency and higher availability.


Why Use Distributed Caching?

When scaling an application horizontally (adding more servers), In-Memory caching fails because each server has its own "silo" of data. Distributed caching solves this by providing:

  • Data Consistency: If Server A updates a cached item, Server B immediately sees that update.
  • Survival: The cache lives in a separate process. If your application server restarts or crashes, the cached data remains intact.
  • Memory Management: Offloads memory usage from the application server to a dedicated caching server (like Redis).

Supported Providers

ASP.NET Core provides a unified interface, IDistributedCache, which allows you to switch between different backends with minimal code changes.

Provider Backend Best For...
Redis High-performance, in-memory key-value store. Production. It is the industry standard for speed and features.
SQL Server A dedicated table in your relational database. Environments where Redis isn't available, but persistence is needed.
Distributed Memory A simulated distributed cache using local memory. Development/Testing. It behaves like a distributed cache but isn't shared.

Implementation with Redis

To use Redis, you typically install the Microsoft.Extensions.Caching.StackExchangeRedis NuGet package.

Configuration in Program.cs

builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("RedisConnection");
    options.InstanceName = "ShopApp_"; // Prefixes keys to avoid collisions
});

Usage with IDistributedCache

Unlike IMemoryCache, which stores C# objects directly, IDistributedCache stores data as byte arrays (byte[]). You must serialize your objects (usually to JSON) before saving.

public async Task<Product?> GetProductAsync(int id)
{
    string cacheKey = $"product_{id}";
    
    // 1. Try to get the byte array from Redis
    var cachedData = await _distCache.GetAsync(cacheKey);
    
    if (cachedData != null)
    {
        // 2. Deserialize back to an object
        return JsonSerializer.Deserialize<Product>(cachedData);
    }

    // 3. If miss, get from DB and save to Redis
    var product = await _context.Products.FindAsync(id);
    if (product != null)
    {
        var serializedData = JsonSerializer.SerializeToUtf8Bytes(product);
        await _distCache.SetAsync(cacheKey, serializedData, new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
        });
    }

    return product;
}

Comparison: In-Memory vs. Distributed

Feature In-Memory Cache Distributed Cache
Latency Negligible (Local RAM) Low (Network call required)
Storage Capacity Limited by Server RAM Scalable (Dedicated Cluster)
Data Types Any C# Object Byte arrays / Strings only
Complexity Low Moderate (Requires infrastructure)

Best Practices

  • Serialization Overhead: Since data must be serialized/deserialized, avoid caching very small objects that are cheap to retrieve from a database; the network call to Redis might actually be slower.
  • Connection Multiplexing: Reuse the connection to Redis. The built-in ASP.NET Core provider handles this automatically.
  • Key Namespacing: Use InstanceName or manual prefixes (e.g., v1:products:101) so that multiple apps can share one Redis instance without overwriting each other's data.

Warning: Do not use the SQL Server implementation for high-traffic real-time caching. Because it relies on disk I/O and table locking, it can become a bottleneck under heavy load, defeating the purpose of a cache.

Note: For even better performance, consider a Hybrid Approach: Check the local In-Memory cache first (L1), and if the data is missing, check the Distributed Redis cache (L2).

Output Caching

Introduced in .NET 7, Output Caching is a more powerful and flexible successor to Response Caching. While Response Caching strictly follows HTTP standards to tell browsers and proxies how to cache, Output Caching gives the server full control over how responses are stored, grouped, and—most importantly—invalidated.


  1. Key Advantages over Response Caching
  2. Output Caching solves many of the limitations found in traditional response caching:

    Feature Response Caching Output Caching
    Storage HTTP Headers / Middleware Memory, Redis, or Custom
    Invalidation Based on Expiration only Manual Invalidation (via Tags)
    Locking No (Cache Stampede risk) Cache Revalidation (Prevents redundant work)
    Extensibility Limited High (Custom policies and storage)
    Bypass Hard to control Built-in support for "no-cache" logic

  3. Basic Setup
  4. To use Output Caching, you must register the service and the middleware in Program.cs.

    var builder = WebApplication.CreateBuilder(args);
    
    builder.Services.AddOutputCache(); // 1. Add Service
    
    var app = builder.Build();
    
    app.UseOutputCache(); // 2. Add Middleware
    
    app.MapControllers();
    app.Run();
  5. Usage Patterns
  6. Basic Attribute Usage

    You can apply caching to a specific endpoint with a simple attribute.

    [HttpGet("stock")]
    [OutputCache(Duration = 300)] // Cache for 5 minutes
    public async Task<List<Stock>> GetStockPrices() 
    {
        return await _stockService.GetLatestAsync();
    }

    Cache Invalidation using Tags

    This is the "killer feature" of Output Caching. You can tag cached responses and purge them all at once when data changes (e.g., when a new product is added).

    // 1. Tag the cache
    [HttpGet("products")]
    [OutputCache(Tags = new[] { "tag-products" })]
    public async Task<IActionResult> Get() => Ok(await _db.Products.ToListAsync());
    
    // 2. Invalidate the tag when data changes
    [HttpPost("products")]
    public async Task<IActionResult> Create(Product p, IOutputCacheStore cache)
    {
        _db.Products.Add(p);
        await _db.SaveChangesAsync();
        
        // This clears every cached response associated with "tag-products"
        await cache.EvictByTagAsync("tag-products", default); 
        
        return Ok();
    }

  7. Advanced Features
    • VaryByValue: Caches different versions of the page based on specific values (e.g., a "Culture" header or a "Theme" cookie).
    • Cache Profiles (Policies): Define complex rules in Program.cs and reuse them.
    • builder.Services.AddOutputCache(options =>
      {
          options.AddPolicy("ExpireInTen", builder => builder.Expire(TimeSpan.FromSeconds(10)));
      });
    • Resource Locking: If 100 users request the same expired page at the exact same millisecond, Output Caching ensures the server only performs the work once and serves the result to all 100 users (mitigating the "Thundering Herd" or "Cache Stampede" problem).
  8. Best Practices
    • Use for Heavily Read Data: Best for homepages, product listings, and blog posts.
    • Be Careful with Authentication: By default, Output Caching disables caching for authenticated requests to prevent security leaks. If you need to cache for authenticated users, you must explicitly enable it in your policy and ensure the cache is varied by the User ID.
    • Combine with Redis: In production, use the Redis backing store for Output Caching so that your cache survives app restarts and is shared across your web farm.

Warning: Output Caching stores the raw bytes of the response. If you are caching large HTML pages for thousands of different URL combinations, monitor your server's memory usage closely to avoid performance degradation.

Note: Output Caching can be used in Minimal APIs, MVC, and Razor Pages. It is the recommended choice for .NET 7+ applications unless you specifically need to adhere to older HTTP proxy caching standards.

Rate Limiting

Rate Limiting is a security and resource management technique used to control the rate of traffic sent or received by a network interface or a specific API endpoint. Introduced as built-in middleware in .NET 7, it protects your application from being overwhelmed by too many requests—whether from malicious "Denial of Service" (DoS) attacks, poorly configured client scripts, or "noisy neighbors" in a multi-tenant environment.


  1. Core Rate Limiter Algorithms
  2. ASP.NET Core provides four built-in algorithms to handle different traffic shaping needs.

    Algorithm How it Works Best For...
    Fixed Window Uses a fixed time slot (e.g., 1 minute). Once the limit is hit, all further requests are blocked until the next window starts. General API protection.
    Sliding Window Similar to fixed, but divides the window into segments. Provides a smoother transition between time periods. Avoiding "bursts" at the edge of window boundaries.
    Token Bucket Tokens are added to a "bucket" at a fixed rate. Each request consumes a token. Allowing occasional bursts of traffic while maintaining a steady average.
    Concurrency Simply limits the number of simultaneous active requests. Protecting resource-heavy operations (e.g., image processing).

  3. Configuration in Program.cs
  4. To implement rate limiting, you define a Policy and then apply the middleware to your request pipeline.

    using Microsoft.AspNetCore.RateLimiting;
    using System.Threading.RateLimiting;
    
    var builder = WebApplication.CreateBuilder(args);
    
    // 1. Define Rate Limit Policies
    builder.Services.AddRateLimiter(options =>
    {
        options.AddFixedWindowLimiter(policyName: "fixed", opt =>
        {
            opt.PermitLimit = 10;           // Max 10 requests
            opt.Window = TimeSpan.FromSeconds(30); // per 30 seconds
            opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
            opt.QueueLimit = 2;             // Allow 2 requests to wait in line
        });
    
        // Customizing the rejection response
        options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    });
    
    var app = builder.Build();
    
    // 2. Enable Middleware
    app.UseRateLimiter();
    
    app.MapControllers();
    app.Run();
  5. Applying Policies
  6. You can apply rate limiting globally, to specific controllers, or to individual Minimal API endpoints.

    • On Controllers:
    • [EnableRateLimiting("fixed")]
      public class ProductsController : ControllerBase { ... }
    • On Minimal APIs:
    • app.MapGet("/info", () => "Hello!").RequireRateLimiting("fixed");

  7. Partitioned Rate Limiting
  8. A common requirement is to limit requests based on a specific attribute, such as an IP Address or an Authenticated User ID. This ensures that one "bad actor" doesn't block access for everyone else.

    builder.Services.AddRateLimiter(options =>
    {
        options.AddPolicy("IPBased", httpContext =>
            RateLimitPartition.GetFixedWindowLimiter(
                partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
                factory: _ => new FixedWindowRateLimiterOptions
                {
                    PermitLimit = 5,
                    Window = TimeSpan.FromMinutes(1)
                }));
    });

  9. Best Practices
    • Use 429 Status Code: Always return 429 Too Many Requests so clients know they are being throttled and not encountering a server error.
      • Inform the Client: Use the Retry-After header to tell the client exactly how long to wait before trying again.
      • Monitor Limits: Log when rate limits are hit. If legitimate users are being throttled, you may need to adjust your thresholds.
      • Layered Defense: Rate limiting at the application level is great, but for high-scale apps, you should also implement rate limiting at the Network Edge (e.g., Azure Front Door, Cloudflare, or Nginx).

Comparison: Rate Limiting vs. Quotas

Feature Rate Limiting Quotas
Duration Short-term (seconds/minutes). Long-term (days/months).
Primary Goal System stability and availability. Monetization and usage tracking.
Enforcement Middleware / Load Balancer. Database / Billing System.

Warning: Be careful with Concurrency Limiting on endpoints that call external services. If the external service slows down, your concurrency slots will fill up quickly, causing your own API to start rejecting requests.

Testing Last updated: March 1, 2026, 8:49 p.m.

A robust application is only as good as its test suite, and ASP.NET Core is built from the ground up to be testable. Unit Testing allows you to verify individual pieces of logic using frameworks like xUnit or NUnit, while Mocking tools like Moq or NSubstitute help isolate your code from external dependencies. This ensures that your business logic is verified in a controlled, repeatable manner.

The framework shines in Integration Testing through the WebApplicationFactory. This utility allows you to run your entire application in-memory during a test, simulating real HTTP requests and responses without the overhead of a real web server. This provides a high-fidelity environment to test your routing, middleware, and database interactions, ensuring that all components of the system work together correctly before moving to production.

Unit Testing (xUnit, NUnit, MSTest)

Unit Testing is the practice of testing the smallest "units" of your code (usually individual methods or classes) in isolation. The goal is to ensure that a specific piece of logic behaves exactly as expected. In ASP.NET Core, unit tests are the first line of defense against regressions—bugs that appear after you change or refactor code.

  1. The Three Main Testing Frameworks
  2. While all three frameworks achieve the same goal, xUnit is the current industry standard and the framework used by the .NET team to build ASP.NET Core itself.

    Framework Popularity Key Features
    xUnit High Modern, minimalist, and uses "Constructors" for setup rather than attributes.
    NUnit Moderate Long-standing history; uses the [SetUp] and [TearDown] pattern.
    MSTest Lower The built-in Microsoft framework; reliable but often slower to adopt new features.

  3. The AAA Pattern
  4. Regardless of the framework, almost all unit tests follow the AAA (Arrange, Act, Assert) pattern. This structure ensures tests are readable and maintainable.

    [Fact] // Defines a test in xUnit
    public void CalculateDiscount_ShouldReturnHalfPrice_WhenCodeIsVALID50()
    {
        // 1. ARRANGE: Set up the objects and data
        var service = new DiscountService();
        var price = 100m;
        var code = "VALID50";
    
        // 2. ACT: Execute the method being tested
        var result = service.CalculateDiscount(price, code);
    
        // 3. ASSERT: Verify the result is what you expected
        Assert.Equal(50m, result);
    }

  5. Theory vs. Fact (Data-Driven Testing)
  6. In xUnit, you can run the same test logic with multiple sets of data using Theories. This prevents code duplication when testing different edge cases.

    • [Fact]: A test that is always true. It takes no arguments.
    • [Theory]: A test that is true for a specific set of data provided via [InlineData].
    • [Theory]
      [InlineData(100, "SAVE10", 90)]
      [InlineData(100, "SAVE20", 80)]
      [InlineData(100, "INVALID", 100)]
      public void CalculateDiscount_ShouldReturnExpectedPrice(decimal price, string code, decimal expected)
      {
          var service = new DiscountService();
          var result = service.CalculateDiscount(price, code);
          Assert.Equal(expected, result);
      }

  7. Mocking Dependencies (Moq / NSubstitute)
  8. Unit tests must be isolated. If you are testing a Controller that calls a Database, you shouldn't actually hit the database. Instead, you create a "Mock" (a fake version) of the database service.

    • Moq: The most popular mocking library.
    • NSubstitute: Gaining popularity for its simpler, "cleaner" syntax.
    [Fact]
    public async Task GetProduct_ReturnsProduct_WhenIdExists()
    {
        // Arrange
        var mockRepo = new Mock<IProductRepository>();
        mockRepo.Setup(repo => repo.GetByIdAsync(1))
                .ReturnsAsync(new Product { Id = 1, Name = "Test Product" });
    
        var controller = new ProductsController(mockRepo.Object);
    
        // Act
        var result = await controller.GetById(1);
    
        // Assert
        var okResult = Assert.IsType<OkObjectResult>(result);
        var product = Assert.IsType<Product>(okResult.Value);
        Assert.Equal("Test Product", product.Name);
    }

  9. Best Practices for Unit Testing
    • One Assert per Test: Ideally, a test should fail for only one reason.
    • Fast Execution: Unit tests should run in milliseconds. If they take seconds, they are likely "Integration Tests" (hitting a DB or File System).
    • Test Logic, Not Frameworks: Don't test built-in .NET features (like List.Add). Test your own business rules, calculations, and data transformations.
    • Naming Conventions: Use a descriptive name like MethodName_StateUnderTest_ExpectedBehavior (e.g., Withdraw_InsufficientFunds_ThrowsException).

Note: For modern .NET development, consider using FluentAssertions. It allows you to write assertions that read like English: result.Should().Be(50).And.NotBeNull();.

Integration Testing with WebApplicationFactory

While Unit Tests focus on isolated methods, Integration Tests verify that multiple components of your application work together—including the routing, middleware, dependency injection, and database access. In ASP.NET Core, the WebApplicationFactory is the gold standard for this, as it allows you to run your entire application in-memory during testing.


  1. What is WebApplicationFactory?
  2. WebApplicationFactory<TEntryPoint> creates an in-memory version of your web host. It allows you to simulate HTTP requests and receive HTTP responses without ever actually opening a network port or hosting the app on a real web server like IIS or Kestrel.

    Feature Unit Testing Integration Testing (with WebAppFactory)
    Scope Single class/method. The entire request pipeline (Controllers, Filters, Middleware).
    Speed ? Extremely Fast. ???? Fast (but slower than Unit Tests).
    Dependencies Mocked (Fake). Real (or swapped with test-specific versions).
    Target Logic/Algorithms. Routing, JSON Serialization, Auth, Database.

  3. Setting Up a Test Base
  4. To avoid repeating setup code, it is common to create a base class that configures the in-memory server and, if necessary, swaps out a real database for an in-memory one (like SQLite In-Memory or EF Core In-Memory).

    public class ApiTestBase : IClassFixture<WebApplicationFactory<Program>>
    {
        protected readonly HttpClient _client;
    
        public ApiTestBase(WebApplicationFactory<Program> factory)
        {
            // Create an HTTP client that communicates with the in-memory server
            _client = factory.CreateClient();
        }
    }

  5. Writing an Integration Test
  6. Integration tests look like real HTTP calls. You use methods like GetAsync, PostAsync, and DeleteAsync to interact with your endpoints.

    public class ProductIntegrationTests : ApiTestBase
    {
        public ProductIntegrationTests(WebApplicationFactory<Program> factory) : base(factory) { }
    
        [Fact]
        public async Task GetProducts_ReturnsSuccessAndCorrectContentType()
        {
            // Act
            var response = await _client.GetAsync("/api/products");
    
            // Assert
            response.EnsureSuccessStatusCode(); // Status Code 200-299
            Assert.Equal("application/json; charset=utf-8", 
                response.Content.Headers.ContentType?.ToString());
                
            var content = await response.Content.ReadAsStringAsync();
            Assert.Contains("Keyboard", content);
        }
    }

  7. Overriding Services for Testing
  8. Sometimes you want the "real" app, but you need to replace a specific service (like an Email Sender or a Payment Gateway) with a fake version so you don't send real emails during a test.

    var client = _factory.WithWebHostBuilder(builder =>
    {
        builder.ConfigureServices(services =>
        {
            // Remove the real EmailService
            var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IEmailService));
            if (descriptor != null) services.Remove(descriptor);
    
            // Add a Mock or Fake version
            services.AddSingleton<IEmailService, FakeEmailService>();
        });
    }).CreateClient();
  9. Managing the Database in Tests
  10. The biggest challenge in integration testing is the database. There are three common strategies:

    Strategy Pros Cons
    EF Core In-Memory Very fast; no setup required. Does not support relational features (FKs, Raw SQL, Constraints).
    SQLite In-Memory Faster than SQL Server; supports SQL. Slightly different syntax/behavior than Production SQL Server.
    Testcontainers (Docker) Best Practice. Uses a real SQL Server instance in a container. Requires Docker; slightly slower to start.

    Best Practices

    • Seed your data: Use a separate "Seeding" method to populate your test database with a known state before each test.
    • Clean up: Ensure each test starts with a fresh database or use transactions that roll back after the test finishes to prevent "leaking" data between tests.
    • Test the "Happy Path" and "Edge Cases" Use integration tests to ensure your 404 Not Found and 401 Unauthorized responses are working correctly across the whole pipeline.

Warning: When testing with WebApplicationFactory, ensure your Program class is accessible. In modern .NET apps using top-level statements, you may need to add public partial class Program { } to the end of your Program.cs file.

Note: Integration tests are the best way to test Middleware and Custom Action Filters, as they allow you to see exactly how the request is modified as it passes through the pipeline.

Testing Minimal APIs

Testing Minimal APIs in ASP.NET Core is remarkably similar to testing Controller-based APIs, but with a few unique advantages. Because Minimal APIs are designed to be lightweight, they are often easier to test in isolation. You can choose between Unit Testing the handler delegates or Integration Testing the entire endpoint using WebApplicationFactory.


  1. Unit Testing Handler Methods
  2. To unit test a Minimal API without the overhead of a web server, you should extract the logic into a separate method (a "named handler"). This allows you to instantiate the class or call the method directly and assert the results.

    The "Pattern":

    1. Move the logic from the lambda expression into a static or instance method.
    2. Use the TypedResults class (introduced in .NET 7) to return results. This makes assertions much easier because it provides strongly-typed result objects.
    // In Program.cs
    app.MapGet("/todo/{id}", TodoHandlers.GetTodo);
    
    // In a separate file
    public static class TodoHandlers
    {
        public static async Task<IResult> GetTodo(int id, ITodoService service)
        {
            var todo = await service.GetByIdAsync(id);
            return todo is not null ? TypedResults.Ok(todo) : TypedResults.NotFound();
        }
    }

    The Unit Test:

    [Fact]
    public async Task GetTodo_ReturnsOk_WhenProductExists()
    {
        // Arrange
        var mockService = new Mock<ITodoService>();
        mockService.Setup(s => s.GetByIdAsync(1)).ReturnsAsync(new Todo { Id = 1, Title = "Test" });
    
        // Act
        var result = await TodoHandlers.GetTodo(1, mockService.Object);
    
        // Assert
        var okResult = Assert.IsType<Ok<Todo>>(result); // TypedResults makes this possible
        Assert.Equal(1, okResult.Value?.Id);
    }

  3. Integration Testing with WebApplicationFactory
  4. For Minimal APIs, integration testing is often preferred because it verifies the routing and parameter binding (how the API handles [FromRoute], [FromBody], etc.), which are common failure points.

    Setup:

    Since Minimal APIs typically use a Program.cs with top-level statements, you must make the Program class visible to your test project. Add this line to the bottom of your Program.cs:

    public partial class Program { }

    The Integration Test:

    public class TodoApiTests : IClassFixture<WebApplicationFactory<Program>>
    {
        private readonly HttpClient _client;
    
        public TodoApiTests(WebApplicationFactory<Program> factory)
        {
            _client = factory.CreateClient();
        }
    
        [Fact]
        public async Task GetTodo_Returns404_WhenTodoDoesNotExist()
        {
            // Act
            var response = await _client.GetAsync("/todo/999");
    
            // Assert
            Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
        }
    }

  5. Comparison of Testing Approaches
  6. Feature Unit Testing (Handlers) Integration Testing (WebAppFactory)
    Speed Instant Fast
    Tests Routing? No Yes
    Tests Dependency Injection? No Yes
    Complexity Low (Logic only) Moderate (Full pipeline)
    Best For... Complex business logic/math Verifying API contracts and security
  7. Testing with TypedResults vs Results
  8. When writing Minimal APIs, you should prefer TypedResults over the generic Results class.


Best Practices

  • Avoid over-mocking: In Minimal API integration tests, try to use a real database (like SQLite In-Memory) if possible, as it provides a more realistic test of the data layer.
  • Test JSON Serialization: Use integration tests to ensure your C# properties are being serialized to the correct JSON names (camelCase vs. PascalCase).
  • Keep Handlers Small: If a Minimal API handler grows beyond 10-15 lines, extract it into a service. This makes both the API and the tests cleaner.

Note: If you are using Authentication in your Minimal APIs, you can use WebApplicationFactory to inject a "Test Authentication Handler" that simulates a logged-in user without needing real JWT tokens or cookies.

Mocking Dependencies (Moq/NSubstitute)

In unit testing, Mocking is the process of creating "fake" versions of external dependencies (like databases, web services, or file systems). This allows you to isolate the code you want to test, ensuring that a test failure is actually caused by a bug in your logic, not an issue with an external system.

Why Use Mocks?

If you try to unit test a service that calls a real database, your tests will be slow, fragile, and require complex setup. Mocks solve this by:

  • Speed: Mocks run entirely in memory.
  • Consistency: Mocks always return the same data, regardless of the environment.
  • Isolation: You can simulate edge cases (like a database timeout or a 500 error) that are hard to trigger with real systems.
  • Control: You can verify that your code interacted with the dependency correctly (e.g., "Did my code actually call the Save method?").

Moq vs. NSubstitute

These are the two most popular libraries for .NET. While they do the same thing, their syntax (style of writing code) differs.

Feature Moq NSubstitute
Popularity Industry Standard Rapidly Growing
Syntax Style Functional (.Setup(x => x...)) Natural Language (.Returns(...))
Learning Curve Moderate (More boilerplate) Low (Very intuitive)
Verification mock.Verify(...) sub.Received()...

Implementation Examples

Imagine we are testing a ProductService that depends on an IProductRepository.

Using Moq

Moq uses a "Wrapper" approach. You create a Mock<T> object and access the fake instance via the .Object property.

[Fact]
public async Task GetPrice_ShouldApplyDiscount_WhenProductExists()
{
    // 1. Arrange
    var mockRepo = new Mock<IProductRepository>();
    mockRepo.Setup(repo => repo.GetPriceAsync(1))
            .ReturnsAsync(100m);

    var service = new ProductService(mockRepo.Object);

    // 2. Act
    var result = await service.GetDiscountedPrice(1);

    // 3. Assert
    Assert.Equal(90m, result); // Assuming a 10% discount logic
}

Using NSubstitute

NSubstitute uses extension methods directly on the interface, making the code look more like standard C#.

[Fact]
public async Task GetPrice_ShouldApplyDiscount_WithNSubstitute()
{
    // 1. Arrange
    var subRepo = Substitute.For<IProductRepository>();
    subRepo.GetPriceAsync(1).Returns(100m);

    var service = new ProductService(subRepo);

    // 2. Act
    var result = await service.GetDiscountedPrice(1);

    // 3. Assert
    Assert.Equal(90m, result);
}

Advanced Mocking Techniques

Verifying Behavior

Sometimes you don't just care about the result, but the action. For example, ensuring an email was sent only once.

  • Moq: mockEmail.Verify(x => x.Send(It.IsAny<string>()), Times.Once);
  • NSubstitute: subEmail.Received(1).Send(Arg.Any<string>());

Throwing Exceptions

You can test how your app handles errors by forcing a mock to throw an exception.

  • Moq: .ThrowsAsync(new Exception("DB Down"));
  • NSubstitute: .Throws(new Exception("DB Down"));

Best Practices

  • Don't Mock Everything: If a class is a simple data holder (like a DTO or a Model), just create a real instance. Only mock "Services" or "Repositories."
  • Mock Interfaces, Not Classes: It is much easier to mock an interface (IProductService) than a concrete class.
  • Avoid "Over-Mocking": If your test setup requires 20 lines of mock configurations, your class might have too many dependencies and should be refactored (violating the Single Responsibility Principle).

Warning: Be careful with Strict Mocks. By default, mocks are "Loose," meaning they return default values (null/zero) for calls you didn't set up. Strict mocks throw an exception for any unplanned call, which can make tests brittle and hard to maintain.

Note: When testing ASP.NET Core Controllers, you often need to mock HttpContext, User, or IUrlHelper. Libraries like TestServer or ControllerContext helpers are often better than manually mocking these complex internal objects.

Deployment & Hosting Last updated: March 1, 2026, 8:50 p.m.

The final stage of the application lifecycle is Deployment and Hosting. ASP.NET Core is uniquely flexible here, as it can be hosted "In-Process" within IIS on Windows or "Out-of-Process" behind a reverse proxy like Nginx or Apache on Linux. The built-in Kestrel web server handles the heavy lifting of HTTP processing, while the reverse proxy manages SSL termination and load balancing, providing a production-grade hosting environment.

Modern deployment often involves Containerization with Docker, which packages the app and its environment into a single image that runs anywhere. This is typically automated through CI/CD pipelines (like GitHub Actions or Azure DevOps), which build, test, and deploy the application to cloud platforms like Azure App Service or Kubernetes. This automation ensures that updates are delivered reliably and frequently with minimal manual intervention.

Hosting Models (Kestrel, IIS, Nginx, Apache)

In ASP.NET Core, hosting is decoupled from the web server environment. This means your application contains its own managed web server (Kestrel) and can run on almost any platform. However, for production environments, Kestrel is typically paired with a Reverse Proxy to provide extra security and manageability.


Kestrel: The Core Web Server

Kestrel is the cross-platform, open-source web server that is included by default in ASP.NET Core templates.

  • Role: It processes the raw HTTP requests and passes them into the middleware pipeline.
  • Performance: It is designed for high-performance edge scenarios.
  • Limitation: While Kestrel can stand alone, it lacks "enterprise" features like port sharing, SSL management (simplified), and request limiting, which is why a reverse proxy is recommended.

Reverse Proxy Hosting Models

A reverse proxy sits in front of your application, intercepts incoming traffic, and forwards it to Kestrel.

Proxy Server OS Platform Best For...
IIS (Internet Information Services) Windows Enterprise Windows environments; supports Windows Auth and easy GUI management.
Nginx Linux / Docker High-concurrency static file serving and load balancing.
Apache Linux Legacy environments or apps requiring specific Apache modules.
YARP (Yet Another Reverse Proxy) Cross-platform A .NET-based proxy for high-level customization of routing logic.

Comparison of Hosting Approaches

Feature In-Process (IIS) Out-of-Process (Kestrel + Proxy)
Performance Highest (No network hop). High (Minor overhead).
Setup Simple (Directly inside IIS). Standard (Kestrel sits behind Nginx/IIS).
Flexibility Windows Only. Cross-platform (Linux/Windows/Docker).
Process App runs in w3wp.exe. App runs in its own .exe or dotnet process.

Configuration Requirements

When hosting behind a proxy (like Nginx), the application needs to know the original client's IP address and protocol (HTTP vs HTTPS), as these are often "lost" during the handoff from the proxy to Kestrel.


Forwarded Headers Middleware

You must add this to your Program.cs to ensure that HttpContext.Connection.RemoteIpAddress remains accurate.

using Microsoft.AspNetCore.HttpOverrides;

var app = builder.Build();

app.UseForwardedHeaders(new ForwardedHeadersOptions
{
    ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});

app.UseAuthentication();

Deployment Strategies

  • Self-Contained Deployment (SCD): Includes the .NET runtime in the published folder. The target machine does not need .NET installed.
  • Framework-Dependent Deployment (FDD): Only includes the application code. The target machine must have the correct .NET Runtime version installed. This results in smaller deployment packages.
  • [Image comparing Self-Contained vs Framework-Dependent deployment file structures]


Best Practices

  • Use HTTPS: Always terminate SSL at the Reverse Proxy level (e.g., Nginx or IIS) to reduce the CPU load on your application server.
  • Environment Check: Use app.Environment.IsProduction() to ensure that sensitive pages (like developer exception pages or Swagger) are disabled in live environments.
  • App Offline: Use an app_offline.htm file in the root directory when deploying to IIS to gracefully shut down the application during updates.

Warning: Never expose Kestrel directly to the internet in a high-risk production environment without a firewall or reverse proxy. A reverse proxy acts as a "buffer" against slow-client attacks and malformed HTTP headers.

Publishing to Azure App Service

Azure App Service is a Platform-as-a-Service (PaaS) offering that allows you to host ASP.NET Core applications without managing the underlying virtual machines or web servers. It handles patching, scaling, and security automatically, making it the preferred choice for most .NET enterprise deployments.


Key Features of App Service

Feature Description
Autoscaling Automatically increases or decreases CPU/RAM based on traffic demands.
Deployment Slots Create "Staging" environments to test code before swapping it to "Production" with zero downtime.
Continuous Deployment Connects directly to GitHub or Azure DevOps for automatic updates on every push.
Managed Identities Allows your app to securely connect to Azure SQL or Key Vault without storing passwords in code.

Publishing Methods

There are several ways to move your ASP.NET Core application from your local machine to Azure.

Method Best For...
Visual Studio Publish Quick demos and small projects (right-click -> Publish).
GitHub Actions Best Practice. Automates testing and deployment on every code commit.
Azure CLI / Zip Deploy Automation scripts and command-line fans.
Docker Container When you need absolute consistency across local, dev, and prod environments.

Step-by-Step: Visual Studio Publish

While CI/CD is preferred for teams, the Visual Studio wizard is the fastest way to get an app live.

  • Right-click the Project and select Publish.
  • Target: Select Azure, then Azure App Service (Windows or Linux).
  • Instance: Select your Subscription and Resource Group. Create a new App Service if needed.
  • Deployment Settings:
    • Configuration: Release
    • Target Framework: e.g., .NET 8.0
    • Deployment Mode: Framework-Dependent (SCD is larger but safer).
  • Finish & Publish: Visual Studio compiles the app, zips the files, and uploads them to Azure.

Configuring Application Settings

In Azure, you should not use appsettings.json for sensitive data or environment-specific values. Instead, use Environment Variables in the Azure Portal.

  • Portal Navigation: Go to your App Service -> Configuration (or Environment Variables in newer UI).
  • Format: Hierarchical settings use double underscores. For example, a setting for ConnectionStrings:DefaultConnection becomes ConnectionStrings__DefaultConnection in Azure.
  • Security: For highly sensitive data, reference Azure Key Vault directly within the configuration settings.

Deployment Slots (Zero Downtime)

Deployment slots allow you to host a version of your app at a different URL (e.g., myapp-staging.azurewebsites.net).

  1. Deploy your new code to the Staging Slot.
  2. Verify the app works in the Azure environment.
  3. Perform a Swap. Azure redirects traffic to the new version instantly. If issues occur, you can swap back immediately.

Best Practices

  • Use Linux Plans: Unless you specifically require Windows-only features (like GDI+ or COM components), Linux App Service plans are typically cheaper and faster for ASP.NET Core.
  • Enable Application Insights: This provides deep telemetry, including failed request traces, database performance, and CPU spikes.
  • Always use HTTPS: Enable the "HTTPS Only" toggle in the Azure Portal settings to force secure connections.
  • Health Checks: Configure a health check path (e.g., /health) so Azure knows if an instance has crashed and can restart it automatically.

Warning: Be careful with the Free/Shared tiers. These tiers "sleep" after inactivity, leading to slow "Cold Starts" for the first user who visits after a break. Use the Basic or Premium tiers for production to keep the app "Always On."

Docker Containerization

Docker is a platform that packages an application and all its dependencies (runtime, libraries, system tools) into a single, standardized unit called a Container. For ASP.NET Core, Docker solves the "it works on my machine" problem by ensuring the environment in development is identical to the environment in production.


Key Docker Concepts

Concept Description Analogy
Dockerfile A text script containing instructions to build an image. The Recipe.
Image A read-only snapshot of your application and its environment. The Frozen Meal.
Container A running instance of an image. The Prepared Dinner.
Docker Hub A registry for sharing and storing images (like GitHub for images). The Grocery Store.

The Multi-Stage Dockerfile

ASP.NET Core projects use Multi-Stage Builds. This allows you to use a large image (SDK) to compile your code, but then copy only the compiled binaries to a much smaller image (Runtime) for production. This results in faster deployments and a smaller security attack surface.

# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["MyApp.csproj", "./"]
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app

# Stage 2: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
COPY --from=build /app .
ENTRYPOINT ["dotnet", "MyApp.dll"]

Docker Compose for Local Development

Most ASP.NET Core apps don't run in isolation; they need a database (SQL Server), a cache (Redis), or an identity provider. Docker Compose allows you to define and run multi-container applications using a single YAML file.

services:
  web-app:
    build: .
    ports:
      - "8080:80"
    depends_on:
      - db
  db:
    image: mcr.microsoft.com/mssql/server
    environment:
      - ACCEPT_EULA=Y
      - SA_PASSWORD=YourStrongPassword123!

Benefits of Containerizing ASP.NET Core

  • Isolation: You can run multiple versions of .NET on the same server without conflicts.
  • Portability: Move your app from a developer's laptop to an on-premises server or to Azure Kubernetes Service (AKS) without changing code.
  • Scalability: Containers start in seconds, making it easy to "spin up" more instances during high traffic.
  • DevOps Integration: CI/CD pipelines can build a single image that is promoted through Testing, Staging, and Production.

Common Docker Commands

Command Action
docker build -t myapp . Builds an image named "myapp" from the current folder.
docker run -p 5000:80 myapp Runs the image, mapping local port 5000 to container port 80.
docker ps Lists all currently running containers.
docker stop <id> Safely shuts down a running container.

Best Practices

  • Use .dockerignore: Similar to .gitignore, this prevents large folders like bin/, obj/, and .git/ from being sent to the Docker daemon, significantly speeding up build times.
  • Keep Images Small: Use the -alpine versions of base images if possible for even smaller footprints.
  • Environment Variables: Never hardcode connection strings in your Dockerfile. Pass them in at runtime using the -e flag or a .env file.
  • Run as Non-Root: For security, configure your Dockerfile to run the application as a non-privileged user.

Warning: Do not store persistent data (like database files or user uploads) inside a container's local file system. If the container is deleted or updated, that data is lost forever. Use Docker Volumes to map container paths to persistent storage on the host.

Note: Visual Studio provides excellent "Docker Support." By right-clicking your project and selecting Add > Docker Support, it will automatically generate a high-quality, production-ready Dockerfile for you.

CI/CD Pipelines with GitHub Actions

CI/CD (Continuous Integration / Continuous Deployment) is the backbone of modern software engineering. It automates the building, testing, and deployment of your ASP.NET Core application. GitHub Actions allows you to create these automated workflows directly within your GitHub repository, triggered by events like a "push" to the main branch or a "pull request."


Understanding the Workflow Components

Component Description
Workflow An automated process defined in a .yml file in the .github/workflows directory.
Trigger The event that starts the workflow (e.g., push, pull_request).
Runner The virtual machine (Windows, Linux, or macOS) that executes the steps.
Actions Individual tasks (e.g., "Checkout Code," "Setup .NET," "Dotnet Publish").

A Standard ASP.NET Core Workflow

A typical CI/CD pipeline for .NET involves checking out the code, restoring dependencies, building, running tests, and finally publishing the artifact.

name: .NET Core CI/CD

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v4
    
    - name: Setup .NET
      uses: actions/setup-dotnet@v4
      with:
        dotnet-version: '8.0.x'

    - name: Restore dependencies
      run: dotnet restore

    - name: Build
      run: dotnet build --no-restore --configuration Release

    - name: Test
      run: dotnet test --no-build --verbosity normal

Continuous Deployment (CD) to Azure

To deploy to Azure App Service, you need to securely connect GitHub to your Azure account. The most secure way is using an Azure Service Principal and GitHub Secrets.


Steps to Configure:

  1. Generate Credentials: Use the Azure CLI to create a service principal that has "Contributor" access to your App Service.
  2. Add Secrets: Copy the JSON output from the CLI and save it as a Secret in your GitHub Repo (e.g., AZURE_CREDENTIALS).
  3. Add Deploy Step:
  4. - name: Deploy to Azure Web App
      uses: azure/webapps-deploy@v2
      with:
        app-name: 'my-cool-web-app'
        publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }}
        package: .

CI vs. CD: The Difference

Phase Responsibility Goal
CI (Continuous Integration) Restore, Build, Unit Tests, Linting. Ensure new code doesn't break the existing build.
CD (Continuous Delivery) Deployment to Staging/QA. Ensure the code is ready for release at any time.
CD (Continuous Deployment) Deployment to Production. Automate the release of every change to the end users.

Best Practices

  • Fail Fast: Place unit tests at the very beginning of the pipeline. If a test fails, the build should stop immediately before wasting time on deployment.
  • Environment Secrets: Never hardcode API keys or connection strings in your YAML file. Use GitHub Secrets (${{ secrets.MY_SECRET }}).
  • Artifact Storage: Use the upload-artifact action to save your compiled binaries. This allows you to download the exact same files that were deployed if you need to debug.
  • Matrix Builds: If your app must support multiple .NET versions (e.g., .NET 6 and .NET 8), use a "matrix" to run tests against both versions simultaneously.

Warning: Be mindful of "Build Minutes." While GitHub Actions is free for public repositories, private repositories have a monthly limit on free runner minutes. Optimize your pipeline by caching NuGet dependencies to reduce build times.

Note: If you are using Docker, your CI/CD pipeline will change slightly: instead of dotnet publish, you will run docker build and docker push to send your image to a container registry like Azure Container Registry (ACR) or Docker Hub.

DocsAllOver

Where knowledge is just a click away ! DocsAllOver is a one-stop-shop for all your software programming needs, from beginner tutorials to advanced documentation

Get In Touch

We'd love to hear from you! Get in touch and let's collaborate on something great

Copyright copyright © Docsallover - Your One Shop Stop For Documentation