LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

ASP.NET Core Web Development Primer

aspnet-coredotnetcsharpweb-developmentmicrosoftbackend

If your mental model of .NET is still “Windows-only, IIS, Visual Studio, and a lot of XML,” it is a decade out of date. Modern ASP.NET Core is a cross-platform, open-source, genuinely fast web framework that runs the same on Linux, macOS, and Windows, deploys happily in a container, and is built command-line-first. This post is a primer for engineers who already know how to build a backend somewhere else — Go, Node/Express, Python/FastAPI, Java/Spring — and want the shape of ASP.NET Core without wading through beginner tutorials.

It is deliberately distinct from two neighboring topics: this is not the legacy .NET Framework 4.8 / IIS workflow, and it is not a general C# language tour. It is about building web applications with modern .NET, which as of mid-2026 means .NET 10 — the Long-Term Support release that shipped in November 2025 and is supported into 2028. ASP.NET Core on .NET 10 is meaningfully leaner than recent versions: Microsoft reports roughly 15% more requests per second than .NET 8 and dramatically lower memory use, which matters directly for your container density and cloud bill.


The runtime and release cadence, briefly

.NET ships once a year in November. Even-numbered releases are LTS (3 years of support); odd-numbered are STS (Standard Term, ~18 months). So .NET 8 and .NET 10 are LTS; .NET 9 was the in-between STS. The practical advice: target an LTS for anything you have to maintain — at the moment, .NET 10. The framework version, the C# language version (C# 14 with .NET 10), and the ASP.NET Core version all move together, which keeps the matrix simpler than it sounds.

1
2
3
4
5
# The whole toolchain is the `dotnet` CLI — no GUI required
dotnet --version                      # confirm 10.x
dotnet new web -o MyApi               # scaffold a minimal web app
cd MyApi
dotnet run                            # build + run; serves on http://localhost:5000

That dotnet new web template is your npx create, your cargo new, your spring init. There are many templates (web, webapi, mvc, razor, blazor); dotnet new list shows them all.


The hosting model: Kestrel and the pipeline

Every ASP.NET Core app is a console program with a Main that builds and runs a host. The host wires up configuration, logging, dependency injection, and the web server. The web server is Kestrel — a fast, cross-platform HTTP server written for .NET Core. Coming from other ecosystems, Kestrel is your built-in server (like Go’s net/http server or Node’s http), not a separate Tomcat/Gunicorn you bolt on.

In production you often still put a reverse proxy (nginx, YARP, or a cloud load balancer) in front of Kestrel for TLS termination, but Kestrel itself is production-grade and can face the internet directly. This is a real shift from the old world, where IIS was the server and ASP.NET was a guest inside it.

The entire app is built around a middleware pipeline — an ordered chain of components, each of which can inspect the request, do work, call the next component, and then act on the response on the way back out. If you have written Express middleware or Go http.Handler wrappers, this is exactly that idea.

  Request
     │
     ▼
  [ Exception handler ]  ──► [ HTTPS redirect ] ──► [ Routing ]
     │                                                  │
     │                          [ Authentication ] ◄────┘
     │                                  │
     │                          [ Authorization ]
     │                                  │
     │                          [ Endpoint (your handler) ]
     │                                  │
     ▼                                  ▼
  Response  ◄──────────────────────  (unwinds back out)

Order matters enormously: authentication must come before authorization, routing before endpoints, exception handling near the outside so it can catch everything inside it. A Program.cs makes this explicit:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
var builder = WebApplication.CreateBuilder(args);

// --- service registration (the DI container) goes here ---
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();                       // OpenAPI 3.1 in .NET 10

var app = builder.Build();

// --- middleware pipeline, in order ---
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/health", () => Results.Ok("healthy"));   // an endpoint

app.Run();

That two-phase shape — register services into the container, then compose the pipeline, then run — is the spine of every ASP.NET Core app. Internalize it and the rest is detail.


Three ways to build: Minimal APIs, MVC, Razor Pages

ASP.NET Core gives you three programming models. They are not competitors so much as different fits, and they can coexist in one app.

Model Best for Shape
Minimal APIs JSON/HTTP APIs, microservices, the lightest path app.MapGet("/x", handler) — endpoints as lambdas/functions
MVC (controllers) Larger APIs and apps that benefit from convention, filters, and structure [ApiController] classes with action methods and attribute routing
Razor Pages Server-rendered, page-focused web UIs A .cshtml page + a PageModel code-behind per URL

Minimal APIs

The modern default for services. Low ceremony, fast, and in .NET 10 substantially more capable — built-in validation, first-class record type support for request bodies, and tight integration with IProblemDetailsService for consistent RFC-9457 error responses.

1
2
3
4
5
6
7
app.MapPost("/orders", (CreateOrder cmd, IOrderService svc) =>
{
    var id = svc.Place(cmd);
    return Results.Created($"/orders/{id}", new { id });
});

record CreateOrder(int CustomerId, string Sku, int Qty);

Notice IOrderService simply appears as a parameter — that is dependency injection resolving it for you (more below). The CreateOrder record is bound from the JSON body automatically.

MVC

When an API grows, controllers give you a conventional home for grouping, shared filters (cross-cutting concerns like validation or caching applied declaratively), and model binding with attributes. It is the same routing and DI underneath; just more structure.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductRepo _repo;
    public ProductsController(IProductRepo repo) => _repo = repo;   // constructor injection

    [HttpGet("{id:int}")]
    public async Task<IActionResult> Get(int id)
    {
        var p = await _repo.FindAsync(id);
        return p is null ? NotFound() : Ok(p);
    }
}

Razor Pages

For server-rendered HTML where each URL maps to a page, Razor Pages is the clean choice (the older MVC-views approach still exists but Razor Pages is the recommended page-centric model). Each page is a .cshtml template plus a PageModel class holding its handlers and data — a tidy page-per-feature layout.

How to choose: building a JSON API? Start with Minimal APIs and reach for controllers only when the structure earns its keep. Building server-rendered pages? Razor Pages. Building a rich interactive UI? That is Blazor, below.


Dependency injection is built in

Unlike ecosystems where DI is a third-party library you opt into, ASP.NET Core has a DI container at its core, and the entire framework uses it. You register services with a lifetime and the framework injects them wherever they are needed — into endpoint handlers, controllers, other services.

1
2
3
builder.Services.AddSingleton<IClock, SystemClock>();      // one instance for app lifetime
builder.Services.AddScoped<IOrderService, OrderService>(); // one per HTTP request
builder.Services.AddTransient<IEmailSender, SmtpSender>(); // new every time it's resolved

The three lifetimes are the thing to get right:

  • Singleton — one shared instance forever. Must be thread-safe. Good for stateless helpers, caches, clocks.
  • Scoped — one instance per HTTP request, shared across everything handling that request. The default home for things like a unit-of-work or a DbContext.
  • Transient — a fresh instance every resolution. For lightweight, stateless services.

The classic bug: injecting a scoped service (like a DbContext) into a singleton. The singleton captures one request’s instance and reuses it forever — a “captive dependency.” The framework can detect some of these at startup in development; understanding the lifetimes prevents the rest.


Configuration and the options pattern

Configuration is layered and provider-based. Values come from appsettings.json, then appsettings.{Environment}.json, then environment variables, then command-line args — each layer overriding the last. This means the same build runs in dev and prod with behavior driven by environment, the twelve-factor way.

1
2
3
4
5
// appsettings.json
{
  "ConnectionStrings": { "AppDb": "Server=...;Database=App;..." },
  "Smtp": { "Host": "mail.example.com", "Port": 587 }
}

Rather than reading config keys by string everywhere, bind a section to a typed class — the options pattern:

1
2
3
4
5
public class SmtpOptions { public string Host { get; set; } = ""; public int Port { get; set; } }

builder.Services.Configure<SmtpOptions>(builder.Configuration.GetSection("Smtp"));

// then inject IOptions<SmtpOptions> and read .Value.Host — strongly typed, testable

Environment variables override JSON using __ (double underscore) as the nesting separator — Smtp__Host=... — which is how you inject secrets and per-environment values in containers without baking them into the image. Never put real secrets in appsettings.json; use environment variables, user-secrets in development, or a vault/secret store in production.


Data access with Entity Framework Core

EF Core is the default ORM — a code-first, LINQ-based mapper roughly analogous to Hibernate (Java) or an advanced SQLAlchemy/Prisma. You model entities as plain C# classes, expose them through a DbContext, and query with LINQ that EF translates to SQL.

1
2
3
4
5
6
7
8
public class AppDb : DbContext
{
    public AppDb(DbContextOptions<AppDb> o) : base(o) { }
    public DbSet<Product> Products => Set<Product>();
}

builder.Services.AddDbContext<AppDb>(o =>
    o.UseSqlServer(builder.Configuration.GetConnectionString("AppDb")));
1
2
3
4
5
// LINQ query → parameterized SQL, async all the way down
var cheap = await db.Products
    .Where(p => p.Price < 20m)
    .OrderBy(p => p.Name)
    .ToListAsync();

Schema changes go through migrations, a versioned, code-generated history of your database shape:

1
2
dotnet ef migrations add AddProductTable    # generate a migration from model changes
dotnet ef database update                   # apply pending migrations

EF Core speaks SQL Server (its most natural pairing — see the SQL Server quick start), PostgreSQL, MySQL, SQLite, and others through providers. Two warnings from experience: watch for N+1 query patterns (use Include() to eager-load related data, or project with Select), and be aware of client-vs-server evaluation — if EF cannot translate part of your LINQ to SQL it may pull rows into memory. For hot paths, log the generated SQL and read it.


Authentication and authorization

ASP.NET Core cleanly separates authentication (who are you) from authorization (what may you do), and they are distinct middleware in that order.

  • Authentication is handled by schemes: cookies for server-rendered apps, JWT bearer tokens for APIs, and OpenID Connect / OAuth2 for federating to an external identity provider (Entra ID, Auth0, Keycloak, etc.).
  • Authorization runs on top, via simple [Authorize] attributes, role checks, or richer policy-based rules (claims, requirements, custom handlers).
1
2
3
4
5
6
builder.Services.AddAuthentication("Bearer").AddJwtBearer();
builder.Services.AddAuthorization(o =>
    o.AddPolicy("AdminOnly", p => p.RequireRole("Admin")));

app.MapGet("/admin/stats", () => GetStats())
   .RequireAuthorization("AdminOnly");

For apps that own their users, ASP.NET Core Identity provides the user store, password hashing, and account flows; .NET 10 adds passkey (WebAuthn) support to Identity, which is the current direction of travel for passwordless auth. For most API work, though, you will validate JWTs issued by an external IdP and never run your own user store at all.


Blazor: interactive UI in C#

Blazor lets you build interactive web UIs in C# instead of JavaScript, with several render modes you mix per component:

  • Server — UI runs on the server, DOM diffs pushed over a SignalR (WebSocket) circuit. Tiny download, needs a live connection.
  • WebAssembly (WASM) — your C# runs in the browser via Wasm. Works offline, larger initial payload.
  • Auto — start server-side for a fast first paint, then transparently shift to WASM once it has downloaded.

.NET 10 is the release where Blazor “finally feels complete”: WebAssembly preloading for faster startup, circuit state persistence so a dropped connection can resume rather than losing the user’s session, better diagnostics, and improved form validation. Blazor is genuinely compelling if your team is C#-heavy and wants to avoid a separate JavaScript frontend stack — though for many teams a conventional JS/TS frontend against a Minimal-API backend is still the pragmatic choice. (For the hypermedia alternative, see the upcoming server-driven-UI post in the frontend series.)

@* Counter.razor — a self-contained interactive component *@
<button @onclick="() => count++">Clicked @count times</button>
@code { private int count = 0; }

A pragmatic starting stack

Putting it together, a sensible default ASP.NET Core service in 2026 looks like:

  • .NET 10 (LTS), targeted via the dotnet CLI, built and shipped in a container.
  • Minimal APIs for the HTTP surface, graduating to controllers only where structure pays off.
  • EF Core against SQL Server or PostgreSQL, with migrations checked into source control.
  • The options pattern for typed config, secrets via environment variables / a vault.
  • JWT bearer auth validating tokens from an external identity provider, with policy-based authorization.
  • OpenAPI (built in, 3.1 with YAML output in .NET 10) for documenting and generating clients.
  • Kestrel behind a reverse proxy or cloud load balancer for TLS.

None of that requires Windows, Visual Studio, or IIS. The framework is fast, the tooling is command-line-native, the patterns (middleware, DI, options, an ORM with migrations) map directly onto what you already know from other backends, and .NET 10’s performance and memory wins make it a genuinely strong choice for new services — not a legacy obligation. If you can build a backend somewhere else, you can be productive here in an afternoon; the rest is learning where .NET puts the things you already understand.


Sources:

Comments