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();
}
}
[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() { ... }
}
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)