DEV Community

kirandeepjassal-crypto
kirandeepjassal-crypto

Posted on

ASP.NET Web API — A Complete Guide from Basics to Advanced

REST principles, architecture, real .NET code with controllers and minimal APIs, JWT auth, versioning, and where Web API fits in 2026.
tags: dotnet, webapi, csharp, beginners
canonical_url: https://prepstack.co.in/blog/aspnet-web-api-complete-guide-basics-to-advanced

cover_image: https://prepstack.co.in/opengraph-image

Originally published on PrepStack — full deep-dive with all code examples + architecture diagrams below ↓
ASP.NET Web API is the framework you use when your client is not a browser asking for HTML — it is a mobile app, a single-page application, a partner integration, or another microservice asking for JSON. It is the same routing + DI + middleware as MVC, but the result is data, not a rendered page.

This guide is the complete picture: REST principles, architecture, minimal vs controller-based APIs, auth, versioning, performance, and where Web API fits in 2026.

Why Web API exists

Web pages were the entire web 15 years ago. Now most software is a backend talking to many clients: an iOS app, an Android app, a React SPA, partner B2B calls, internal cron jobs, third-party webhooks. They all need a stable, language-agnostic way to call the server.

The de-facto standard is HTTP + JSON with REST conventions. Web API is the toolkit for building exactly that on .NET, with everything ASP.NET already does well: routing, model binding, DI, filters, validation, identity.

The architecture in one diagram

Mobile app SPA (React, Vue) Partner system
│ │ │
└──────────┬────────────┴──────────┬────────────┘
│ │
▼ ▼
┌──────────────────────────────────┐
│ Reverse Proxy / Gateway │ TLS, rate limit, WAF
│ (NGINX / YARP / Azure Front) │
└──────────────────┬───────────────┘


┌────────────────────────────────────────────────┐
│ ASP.NET Web API Host │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ Middleware │ │
│ │ Auth → CORS → RateLimit → Exception → │ │
│ └──────────────────┬───────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ Routing + Model Binding │ │
│ └──────────────────┬───────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ Controller / Endpoint │ │
│ │ public async Task GetAsync() │ │
│ └──────────────────┬───────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ Services + DbContext + Cache + HTTP │ │
│ └──────────────────┬───────────────────────┘ │
│ │ │
│ ▼ │
│ JSON serializer → HTTP response │
└────────────────────────────────────────────────┘


┌──────────────────┐
│ PostgreSQL │
│ Redis │
│ Other APIs │
└──────────────────┘

Notice three things absent from this picture: there is no view, no Razor, no HTML. Everything is JSON in / JSON out.

REST in 60 seconds

REST is a set of conventions, not a strict standard:

HTTP verb

Means

Returns

GET

Read, idempotent, safe

200 OK, 404 Not Found

POST

Create

201 Created + Location header

PUT

Replace whole resource

200 / 204 No Content

PATCH

Modify part of a resource

200 / 204

DELETE

Remove

204 No Content / 404

URLs name resources, not actions:

GET /orders list orders
GET /orders/123 get one order
POST /orders create
PUT /orders/123 replace
PATCH /orders/123 partial update
DELETE /orders/123 remove

GET /orders/123/items nested resource

Status codes tell the client what happened without parsing the body:

200 OK success
201 Created new resource at Location: header
204 No Content success, no body
400 Bad Request client-side validation error
401 Unauthorized missing / invalid auth
403 Forbidden authenticated but not allowed
404 Not Found resource missing
409 Conflict version mismatch / duplicate
422 Unprocessable validation error on otherwise-valid request
500 Server Error bug, log + alert

A complete minimal Web API

Project bootstrap

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext(o => o.UseNpgsql(builder.Configuration.GetConnectionString("Db")!));
builder.Services.AddScoped();
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi(); // .NET 9+
var app = builder.Build();

app.UseExceptionHandler("/error");
app.MapControllers();
app.MapOpenApi();
app.Run();

Controller-based API

[ApiController]
[Route("orders")]
public class OrdersController : ControllerBase
{
private readonly OrderService _service;
public OrdersController(OrderService service) => _service = service;

[HttpGet]
public Task<IEnumerable<OrderDto>> List([FromQuery] int page = 1) =>
    _service.ListAsync(page);

[HttpGet("{id:guid}")]
public async Task<ActionResult<OrderDto>> Get(Guid id)
{
    var order = await _service.GetAsync(id);
    return order is null ? NotFound() : Ok(order);
}

[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<OrderDto>> Create(CreateOrderRequest req)
{
    var created = await _service.CreateAsync(req);
    return CreatedAtAction(nameof(Get), new { id = created.Id }, created);
}

[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id)
{
    await _service.DeleteAsync(id);
    return NoContent();
}
Enter fullscreen mode Exit fullscreen mode

}

[ApiController] gives you:

Automatic 400 on invalid model state

Binding source inference (route, query, body)

Standardized error responses

Minimal API — same thing, fewer lines

var orders = app.MapGroup("/orders").WithTags("Orders");

orders.MapGet("/", (OrderService svc, int page = 1) => svc.ListAsync(page));

orders.MapGet("/{id:guid}", async (Guid id, OrderService svc) =>
await svc.GetAsync(id) is { } o ? Results.Ok(o) : Results.NotFound());

orders.MapPost("/", async (CreateOrderRequest req, OrderService svc) =>
{
var created = await svc.CreateAsync(req);
return Results.CreatedAtRoute("GetOrder", new { id = created.Id }, created);
}).Accepts("application/json");

orders.MapDelete("/{id:guid}", async (Guid id, OrderService svc) =>
{
await svc.DeleteAsync(id);
return Results.NoContent();
});

Minimal APIs are shorter for simple endpoints. Controllers are clearer for big projects with cross-cutting filters.

Request and response DTOs

Never expose your DB entities directly. Use DTOs (data transfer objects) — input and output shapes you control.

public record CreateOrderRequest(
[Required] string CustomerEmail,
[Required, MinLength(1)] List Items);

public record LineItem([Required] Guid ProductId, [Range(1, 999)] int Quantity);

public record OrderDto(Guid Id, string Status, decimal Total, DateTimeOffset CreatedAt);

Why DTOs:

API surface is stable even if the DB schema evolves

Avoids leaking sensitive fields (password_hash, internal_notes)

Lets validation rules live next to the input shape

Intermediate — versioning, error handling, OpenAPI

Versioning

builder.Services.AddApiVersioning(o =>
{
o.DefaultApiVersion = new ApiVersion(1, 0);
o.AssumeDefaultVersionWhenUnspecified = true;
o.ReportApiVersions = true;
o.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(),
new HeaderApiVersionReader("X-Api-Version"));
});

[ApiController]
[Route("v{version:apiVersion}/orders")]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
public class OrdersController : ControllerBase
{
[HttpGet, MapToApiVersion("1.0")]
public IEnumerable ListV1() { ... }

[HttpGet, MapToApiVersion("2.0")]
public IEnumerable<OrderDtoV2> ListV2() { ... }
Enter fullscreen mode Exit fullscreen mode

}

URL versioning (/v1/orders) is the most discoverable; header versioning (X-Api-Version: 2.0) keeps URLs stable.

Centralized error handling — RFC 7807

app.UseExceptionHandler(handler =>
{
handler.Run(async ctx =>
{
var ex = ctx.Features.Get()?.Error;
var problem = ex switch
{
ValidationException v => new ProblemDetails {
Status = 400, Title = "Validation failed", Detail = v.Message
},
NotFoundException => new ProblemDetails { Status = 404, Title = "Not found" },
_ => new ProblemDetails { Status = 500, Title = "Server error" },
};
ctx.Response.StatusCode = problem.Status ?? 500;
await ctx.Response.WriteAsJsonAsync(problem);
});
});

Clients now receive a consistent JSON error envelope they can parse and display.

OpenAPI / Swagger

AddOpenApi() (.NET 9+) auto-generates a machine-readable spec at /openapi/v1.json. From it you get:

Swagger UI for humans to try the API

Auto-generated client SDKs (NSwag, OpenAPI Generator)

Postman collection import

This is non-optional in a real product — every Web API should ship its OpenAPI spec.

Advanced — auth, rate limiting, output caching

Authentication with JWT

builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(opts =>
{
opts.TokenValidationParameters = new()
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = cfg["Jwt:Issuer"],
ValidAudience = cfg["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(cfg["Jwt:Key"]!)),
};
});

builder.Services.AddAuthorization();

Then:

[Authorize]
[HttpGet("me")]
public Task Me() => _users.MeAsync(User);

[Authorize(Roles = "admin")]
[HttpDelete("orders/{id:guid}")]
public Task Delete(Guid id) => _orders.DeleteAsync(id);

Rate limiting (.NET 7+)

builder.Services.AddRateLimiter(o =>
{
o.AddFixedWindowLimiter("api", opt =>
{
opt.PermitLimit = 100;
opt.Window = TimeSpan.FromMinutes(1);
});
});

app.UseRateLimiter();

[EnableRateLimiting("api")]
public class PublicController : ControllerBase { ... }

Output caching (.NET 7+)

builder.Services.AddOutputCache(o => o.AddPolicy("Products", b => b.Expire(TimeSpan.FromMinutes(5))));
app.UseOutputCache();

[OutputCache(PolicyName = "Products")]
[HttpGet("products")]
public Task> List() => _service.ListAsync();

CORS — the SPA gotcha

builder.Services.AddCors(o => o.AddPolicy("spa", p =>
p.WithOrigins("https://app.example.com")
.AllowAnyHeader().AllowAnyMethod().AllowCredentials()));
app.UseCors("spa");

A React SPA on a different origin cannot call your API without this. Pinning origin is critical — AllowAnyOrigin() is fine for public read-only APIs, dangerous for anything else.

Advantages of ASP.NET Web API

Native to .NET — same DI, same logging, same testing tools you already use

Async throughput — Kestrel + async controllers handle thousands of concurrent requests per core

OpenAPI built-in — clients in any language can be generated

Strong typing — DTOs catch breaking changes at compile time, not 3 AM in prod

Minimal API option for small services keeps the file count low

First-class JWT, OAuth, identity, rate-limiting, output cache — no third-party glue needed

Cross-platform — Linux containers, Windows servers, macOS dev machines, all identical

Disadvantages — when Web API is the wrong tool

JSON overhead — text-based, larger than binary protocols. For internal service-to-service, gRPC saves bandwidth and parsing cost.

No SSR / SEO — search engines see nothing useful in JSON. You need MVC, Blazor, or a SPA with SSR for content sites.

REST is conventions, not enforced — teams disagree about PUT vs PATCH, nesting, error shapes. Add an API style guide.

Stateful protocols (chat, telemetry) — SignalR / WebSockets are better than polling REST.

Versioning is real work — every breaking change forces a v2; old clients linger for years.

REST vs gRPC vs GraphQL — quick guide

Use REST when

Use gRPC when

Use GraphQL when

Public API for any HTTP client

Internal service-to-service traffic

UI fetches deeply nested data with many shapes

Browsers and curl call it directly

Strong typing + small payloads matter

Front-end teams iterate fast, back-end ships slowly

Wide partner ecosystem

Streaming bidirectionally

Multiple clients (mobile, web, partner) want different fields

Caching via HTTP semantics

Polyglot internal stack

Aggregator over microservices

You can have all three in one organization. Most Indian product teams I have worked with pick: REST for the public API, gRPC for service-to-service, GraphQL only when the UI variability justifies the operational cost.

Where Web API fits in 2026

React / Vue / Angular SPAs talking to a .NET backend

iOS and Android apps calling a single REST service

Partner integrations (webhooks, B2B data sync)

Internal admin tools whose UI lives in React

Backend-for-frontend (BFF) services in front of microservices

Background workers + admin API on the same host

Where Web API does NOT fit

High-frequency internal traffic — gRPC wins on bandwidth + latency

Browser-rendered HTML — use MVC or Blazor Server

Real-time push (typing indicators, live cursors) — SignalR / WebSockets

Production checklist

Health checks — MapHealthChecks("/health") separately for liveness and readiness

Structured logging — Serilog with correlation IDs, never Console.WriteLine

Retries on transient errors — Polly or IHttpClientFactory.AddStandardResilienceHandler()

Idempotency-Key header on POST endpoints clients might retry

OpenAPI shipped to /openapi/v1.json even on prod (or behind auth) so clients can regenerate

Pagination by cursor on large list endpoints, not offset

Filter validation — never accept arbitrary ?orderBy= strings → SQL injection risk

Don't return entire DB rows — always DTO

Async all the way — no .Result, no .Wait()

HTTPS only — app.UseHttpsRedirection()

Migration paths

From WCF / SOAP → REST: pick one resource model, expose JSON shapes, deprecate SOAP over 12 months while clients migrate.

From REST → gRPC: keep REST as the public face; introduce gRPC for service-to-service. Most organizations end up with both.

From .NET Framework Web API → ASP.NET Core: API controllers map almost one-to-one. The biggest changes are DI registration, configuration, and HttpContext.

Summary

ASP.NET Web API is the right choice when clients need data, not pages. It is the .NET answer to "give me a fast, strongly-typed, cross-platform JSON API with everything I need to ship a real product — auth, validation, caching, rate-limiting, OpenAPI."

Start with [ApiController] and controller classes. Add DTOs, validation, JWT auth, versioning, error handling, output cache as the surface grows. Reach for minimal APIs when a service is tiny. Reach for gRPC when service-to-service throughput justifies the schema discipline. Layer a gateway (YARP, Azure Front Door) in front when you have more than one service.

Top comments (0)