DEV Community

Cover image for ASP.NET Core: Building High-Performance Web Applications and APIs
Rhuturaj Takle
Rhuturaj Takle

Posted on

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

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

  1. Introduction
  2. Architecture Overview
  3. Minimal APIs
  4. MVC and Controllers
  5. Middleware Pipeline
  6. Dependency Injection
  7. Configuration and Options
  8. Authentication and Authorization
  9. Performance Features
  10. Testing and Observability
  11. Quick Reference Table
  12. 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();
Enter fullscreen mode Exit fullscreen mode

Two phases matter here:

  1. Service registration (builder.Services...) — declares what your app needs (DI container setup).
  2. 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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode
@* Pages/Products/Index.cshtml *@
@model IndexModel
<ul>
@foreach (var product in Model.Products)
{
    <li>@product.Name — @product.Price.ToString("C")</li>
}
</ul>
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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) { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode
public class EmailSender(IOptions<EmailSettings> options)
{
    private readonly EmailSettings _settings = options.Value;
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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)));
});
Enter fullscreen mode Exit fullscreen mode
[Authorize(Policy = "RequireAdmin")]
[HttpDelete("{id}")]
public IActionResult Delete(int id) => NoContent();
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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>();
});
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

OpenTelemetry (built-in support since .NET 8)

builder.Services.AddOpenTelemetry()
    .WithTracing(tracing => tracing.AddAspNetCoreInstrumentation().AddHttpClientInstrumentation())
    .WithMetrics(metrics => metrics.AddAspNetCoreInstrumentation());
Enter fullscreen mode Exit fullscreen mode

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)