ASP.NET Core: Building High-Performance Web Applications and APIs
DEV Community

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.