ASP.NET Core: Building High-Performance Web Applications and APIs
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.
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 |
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.
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.
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)
{
/* ... */
}
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.
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.
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
Comments
No comments yet. Start the discussion.