ASP.NET Core: Building High-Performance Web Applications and APIs
A practical guide to ASP.NET Core — the cross-platform framework for building REST APIs, MVC applications, and backend services on .NET, covering architecture, minimal APIs, middleware, performance, and modern patterns.
Table of Contents
- Introduction
- Architecture Overview
- Minimal APIs
- MVC and Controllers
- Middleware Pipeline
- Dependency Injection
- Configuration and Options
- Authentication and Authorization
- Performance Features
- Testing and Observability
- Quick Reference Table
- Conclusion
Introduction
ASP.NET Core is a free, open-source, cross-platform framework for building web apps, APIs, and backend services. It's a ground-up rewrite of the original ASP.NET, designed around three priorities:
- Performance — it's consistently one of the fastest mainstream web frameworks in independent benchmarks (e.g., TechEmpower).
- Modularity — you opt into only the middleware and services your app actually needs, instead of a fixed, heavyweight pipeline.
- Cross-platform — runs identically on Windows, Linux, and macOS, and deploys to containers, serverless, or bare metal.
This guide covers the core building blocks you'll use in almost any ASP.NET Core project, from a five-line minimal API to a full MVC application with authentication and background services.
1. Architecture Overview
Every ASP.NET Core app starts from a unified entry point — Program.cs — using the minimal hosting model introduced in .NET 6.
var builder = WebApplication.CreateBuilder(args);
// Register services (dependency injection container)
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Configure the HTTP request pipeline (middleware)
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
Two phases matter here:
-
Service registration (
builder.Services...) — declares what your app needs (DI container setup). -
Pipeline configuration (
app.Use.../app.Map...) — declares what happens to each incoming request.
This single-file, top-level-statements style replaced the old Startup.cs + Program.cs split, cutting boilerplate significantly while keeping the same underlying concepts.
2. Minimal APIs
Introduced in .NET 6, minimal APIs let you build HTTP APIs without controllers, attributes, or a large amount of scaffolding — ideal for microservices, small services, and prototypes.
A basic minimal API
var app = builder.Build();
app.MapGet("/products/{id:int}", (int id, IProductRepository repo) =>
{
var product = repo.GetById(id);
return product is not null ? Results.Ok(product) : Results.NotFound();
});
app.MapPost("/products", (Product product, IProductRepository repo) =>
{
repo.Add(product);
return Results.Created($"/products/{product.Id}", product);
});
app.Run();
Route parameters, request bodies, and services are all bound automatically from the method signature — no [FromRoute]/[FromBody] attributes required in the common case (though they're available when binding is ambiguous).
Route groups (.NET 7+)
Group related endpoints, apply shared configuration (auth, versioning, tags) once:
var products = app.MapGroup("/products").WithTags("Products");
products.MapGet("/", (IProductRepository repo) => repo.GetAll());
products.MapGet("/{id:int}", (int id, IProductRepository repo) => repo.GetById(id));
products.MapPost("/", (Product p, IProductRepository repo) => repo.Add(p))
.RequireAuthorization();
Typed results (.NET 7+)
Results<T1, T2> gives you strongly-typed, OpenAPI-friendly return types instead of the untyped IResult:
app.MapGet("/products/{id}", Results<Ok<Product>, NotFound> (int id, IProductRepository repo) =>
{
var product = repo.GetById(id);
return product is not null
? TypedResults.Ok(product)
: TypedResults.NotFound();
});
When to choose minimal APIs vs. controllers
| Minimal APIs | MVC Controllers | |
|---|---|---|
| Best for | Microservices, small APIs, high-throughput endpoints | Larger apps, complex validation, filters, conventions |
| Boilerplate | Very low | Moderate (attributes, base class) |
| Startup performance | Faster (less reflection) | Slightly slower |
| Structure at scale | Requires discipline (route groups, extension methods) | Built-in via controllers/areas |
3. MVC and Controllers
For larger applications, the Model-View-Controller pattern still provides useful structure — action filters, model binding conventions, and view rendering.
A typical API controller
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly IOrderService _orderService;
public OrdersController(IOrderService orderService) => _orderService = orderService;
[HttpGet("{id:int}")]
public async Task<ActionResult<OrderDto>> GetById(int id)
{
var order = await _orderService.GetByIdAsync(id);
return order is not null ? Ok(order) : NotFound();
}
[HttpPost]
public async Task<ActionResult<OrderDto>> Create(CreateOrderRequest request)
{
var order = await _orderService.CreateAsync(request);
return CreatedAtAction(nameof(GetById), new { id = order.Id }, order);
}
}
The [ApiController] attribute enables automatic model-state validation (400 responses on invalid input), binding source inference, and problem-details error responses — all without extra code.
Razor Pages and MVC Views
For server-rendered HTML, ASP.NET Core supports both Razor Pages (page-focused, one file per page) and traditional MVC views (controller + shared view folder):
// Razor Page handler (Pages/Products/Index.cshtml.cs)
public class IndexModel : PageModel
{
private readonly IProductRepository _repo;
public IndexModel(IProductRepository repo) => _repo = repo;
public IList<Product> Products { get; set; } = [];
public async Task OnGetAsync() => Products = await _repo.GetAllAsync();
}
@* Pages/Products/Index.cshtml *@
@model IndexModel
<ul>
@foreach (var product in Model.Products)
{
<li>@product.Name — @product.Price.ToString("C")</li>
}
</ul>
Blazor as an alternative UI model
For interactive UIs without hand-written JavaScript, Blazor (Server or WebAssembly) lets you write component-based UI in C# that runs either on the server over SignalR or directly in the browser via WebAssembly. It's a separate topic in its own right, but it plugs into the same ASP.NET Core hosting model.
4. Middleware Pipeline
Middleware is the heart of how ASP.NET Core processes requests: each piece of middleware can inspect, short-circuit, or pass along the request to the next one, forming a chain.
The pipeline as nested delegates
app.Use(async (context, next) =>
{
var stopwatch = Stopwatch.StartNew();
await next(context); // call the next middleware in the chain
stopwatch.Stop();
Console.WriteLine($"{context.Request.Path} took {stopwatch.ElapsedMilliseconds}ms");
});
app.UseExceptionHandler("/error");
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
Order matters. Authentication must run before authorization; exception handling should wrap almost everything; static files are usually served before routing kicks in for dynamic endpoints.
Writing custom middleware as a class
public class RequestLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestLoggingMiddleware> _logger;
public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
_logger.LogInformation("Handling {Method} {Path}", context.Request.Method, context.Request.Path);
await _next(context);
_logger.LogInformation("Response: {StatusCode}", context.Response.StatusCode);
}
}
// registration
app.UseMiddleware<RequestLoggingMiddleware>();
Endpoint routing
Since ASP.NET Core 3.0, routing is decoupled from MVC — UseRouting() and UseEndpoints() (or the simplified Map* calls) let middleware run with full knowledge of which endpoint will ultimately handle the request, enabling things like endpoint-specific authorization policies.
5. Dependency Injection
ASP.NET Core has a built-in DI container — no third-party library required for the common cases.
Service lifetimes
builder.Services.AddSingleton<ICacheService, MemoryCacheService>(); // one instance for the app's lifetime
builder.Services.AddScoped<IOrderService, OrderService>(); // one instance per HTTP request
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>(); // new instance every time it's requested
| Lifetime | Instance created | Typical use |
|---|---|---|
Singleton |
Once, shared everywhere | Caches, configuration, thread-safe stateless services |
Scoped |
Once per request |
DbContext, per-request state |
Transient |
Every injection | Lightweight, stateless helpers |
Constructor injection
public class OrderService : IOrderService
{
private readonly AppDbContext _db;
private readonly IEmailSender _emailSender;
public OrderService(AppDbContext db, IEmailSender emailSender)
{
_db = db;
_emailSender = emailSender;
}
public async Task<Order> CreateAsync(CreateOrderRequest request)
{
var order = new Order { /* ... */ };
_db.Orders.Add(order);
await _db.SaveChangesAsync();
await _emailSender.SendOrderConfirmationAsync(order);
return order;
}
}
Keyed services (.NET 8+)
Register multiple implementations of the same interface and resolve by key:
builder.Services.AddKeyedSingleton<INotifier, EmailNotifier>("email");
builder.Services.AddKeyedSingleton<INotifier, SmsNotifier>("sms");
public class AlertService([FromKeyedServices("sms")] INotifier notifier) { /* ... */ }
6. Configuration and Options
Configuration in ASP.NET Core is layered: appsettings.json, environment-specific overrides, environment variables, and command-line args all merge together automatically.
appsettings.json
{
"ConnectionStrings": {
"Default": "Server=...;Database=...;"
},
"EmailSettings": {
"SmtpHost": "smtp.example.com",
"Port": 587
}
}
Strongly-typed options with validation
public class EmailSettings
{
public required string SmtpHost { get; set; }
public int Port { get; set; }
}
builder.Services
.AddOptions<EmailSettings>()
.Bind(builder.Configuration.GetSection("EmailSettings"))
.ValidateDataAnnotations()
.ValidateOnStart();
public class EmailSender(IOptions<EmailSettings> options)
{
private readonly EmailSettings _settings = options.Value;
}
ValidateOnStart() (added in .NET 8) fails fast at application startup if configuration is invalid, instead of surfacing the error deep inside a request months later.
7. Authentication and Authorization
JWT Bearer authentication
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://your-identity-provider.com";
options.Audience = "your-api";
});
builder.Services.AddAuthorization();
app.UseAuthentication();
app.UseAuthorization();
Policy-based authorization
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("RequireAdmin", policy => policy.RequireRole("Admin"));
options.AddPolicy("MinimumAge", policy =>
policy.RequireAssertion(context =>
context.User.HasClaim(c => c.Type == "Age" && int.Parse(c.Value) >= 18)));
});
[Authorize(Policy = "RequireAdmin")]
[HttpDelete("{id}")]
public IActionResult Delete(int id) => NoContent();
Identity and ASP.NET Core Identity
For apps needing full user management (registration, password reset, external logins), ASP.NET Core Identity provides a ready-made user store, and .NET 8 added built-in Identity API endpoints (MapIdentityApi<TUser>()) for token-based auth without hand-rolling the endpoints yourself.
8. Performance Features
ASP.NET Core's performance reputation comes from a combination of the runtime (Kestrel, the built-in web server) and framework-level design choices.
Kestrel: the built-in web server
Kestrel is a cross-platform, event-driven web server built directly on top of libuv/managed sockets. It's fast enough to be used directly in production (often behind a reverse proxy like Nginx, YARP, or a cloud load balancer for TLS termination and multiplexing).
Response caching and output caching
builder.Services.AddOutputCache(options =>
{
options.AddPolicy("Expire60", b => b.Expire(TimeSpan.FromSeconds(60)));
});
app.UseOutputCache();
app.MapGet("/weather", () => GetWeatherForecast())
.CacheOutput("Expire60");
Output caching (added in .NET 7) caches the full response server-side, avoiding recomputation for repeated identical requests — a major win for read-heavy endpoints.
Response compression
builder.Services.AddResponseCompression(options =>
{
options.EnableForHttps = true;
options.Providers.Add<BrotliCompressionProvider>();
});
Rate limiting (.NET 7+)
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("fixed", opt =>
{
opt.Window = TimeSpan.FromSeconds(10);
opt.PermitLimit = 20;
});
});
app.UseRateLimiter();
app.MapGet("/data", () => "ok").RequireRateLimiting("fixed");
Built-in rate limiting removes the need for a third-party package for common scenarios (fixed window, sliding window, token bucket, concurrency limiting).
Native AOT (.NET 8+)
Ahead-of-time compilation produces a self-contained native executable with no JIT step, drastically reducing cold-start time and memory footprint — ideal for containers and serverless functions:
dotnet publish -r linux-x64 -c Release /p:PublishAot=true
Native AOT currently works best with minimal APIs and trimmable code (some reflection-heavy libraries need adaptation).
HTTP/2, HTTP/3, and gRPC
Kestrel supports HTTP/2 and HTTP/3 (QUIC) out of the box, and ASP.NET Core has first-class support for gRPC for high-performance, strongly-typed service-to-service communication — a common backend-for-backend alternative to REST.
9. Testing and Observability
Integration testing with WebApplicationFactory
public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public ProductsApiTests(WebApplicationFactory<Program> factory) =>
_client = factory.CreateClient();
[Fact]
public async Task GetProduct_ReturnsOk()
{
var response = await _client.GetAsync("/products/1");
response.EnsureSuccessStatusCode();
}
}
WebApplicationFactory<TEntryPoint> spins up an in-memory test server using your real Program.cs configuration, letting you test the full middleware pipeline without a real network hop.
Health checks
builder.Services.AddHealthChecks()
.AddSqlServer(connectionString)
.AddCheck<CustomHealthCheck>("custom");
app.MapHealthChecks("/health");
OpenTelemetry (built-in support since .NET 8)
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing.AddAspNetCoreInstrumentation().AddHttpClientInstrumentation())
.WithMetrics(metrics => metrics.AddAspNetCoreInstrumentation());
Distributed tracing and metrics are now first-class, framework-level concerns rather than something bolted on via a separate agent.
Quick Reference Table
| Feature | Introduced | Problem it Solves |
|---|---|---|
| Minimal hosting model | .NET 6 | Program.cs/Startup.cs split boilerplate |
| Minimal APIs | .NET 6 | Heavyweight controllers for small services |
| Route groups | .NET 7 | Repeating config across related endpoints |
| Typed results | .NET 7 | Untyped IResult return values |
| Keyed DI services | .NET 8 | Multiple implementations of one interface |
ValidateOnStart() |
.NET 8 | Late-discovered configuration errors |
| Output caching | .NET 7 | Recomputing identical responses |
| Rate limiting middleware | .NET 7 | Third-party rate-limit packages |
| Native AOT | .NET 8 | Slow cold starts, high memory footprint |
| Built-in OpenTelemetry | .NET 8 | Bolted-on tracing/metrics agents |
| Identity API endpoints | .NET 8 | Hand-rolled auth endpoints |
Conclusion
ASP.NET Core's evolution has been about removing friction without removing power: minimal APIs and the unified Program.cs cut boilerplate for small services, while controllers, Razor Pages, and Blazor remain available for larger, more structured applications. Underneath it all, the framework keeps investing in things that matter at scale — Kestrel's raw throughput, built-in caching and rate limiting, native AOT for fast cold starts, and first-class observability — so that the same framework can comfortably run a five-line prototype and a production system handling millions of requests a day.
The common thread is that ASP.NET Core increasingly lets you pay only for the complexity you actually need, while making the on-ramp to production-grade performance, security, and observability a built-in part of the framework rather than a pile of third-party packages.
Found this useful? Feel free to star the repo, open an issue with corrections, or share which ASP.NET Core feature has saved you the most time.
Top comments (0)