ASP.NET Web API — A Complete Guide from Basics to Advanced (with real code)
Deep-dive into ASP.NET Web API: REST principles, architecture diagram, real controller and minimal-API code, versioning, auth, advantages and disadvantages. Basic to advanced.
- Author
- Randhir Jassal
- Published
- Reading time
- 14 min read
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<Result> 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<AppDb>(o => o.UseNpgsql(builder.Configuration.GetConnectionString("Db")!));
builder.Services.AddScoped<OrderService>();
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();
}
}
[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<CreateOrderRequest>("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<LineItem> 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<OrderDtoV1> ListV1() { ... }
[HttpGet, MapToApiVersion("2.0")]
public IEnumerable<OrderDtoV2> ListV2() { ... }
}
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<IExceptionHandlerFeature>()?.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<UserDto> 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<ProductDto>> 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
POSTendpoints 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.
Get the next issue
A short, curated email with the newest posts and questions.