Welcome to the first article in the .NET Web API Zero to Hero FREE course! If you’re building APIs in .NET—whether you’re just starting out or have been doing it for years—this guide will give you a rock-solid foundation in REST principles and API design best practices.
Here’s the thing: I’ve reviewed hundreds of .NET codebases over the years, and the number one issue I see isn’t performance or architecture—it’s inconsistent, poorly designed APIs. Endpoints named /api/getUserById, query parameters used for resource identification, 200 OK returned for errors, and zero versioning strategy. These APIs work, but they’re a nightmare to maintain and integrate with.
By the end of this article, you’ll understand why REST principles matter and how to apply them in your .NET projects. You won’t just know the rules—you’ll understand when to break them and why.
The complete sample code lives at github.com/codewithmukesh/dotnet-webapi-zero-to-hero-course—clone it and follow along.
What is REST (And Why Should You Care)?
REST (Representational State Transfer) is an architectural style for building networked applications. It was defined by Roy Fielding in his 2000 doctoral dissertation and has become the de facto standard for web APIs.
But here’s what most tutorials get wrong: REST is not a protocol or specification—it’s a set of architectural constraints. An API that uses HTTP and JSON isn’t automatically RESTful. You can build a perfectly valid HTTP API that violates every REST principle.
REST vs. HTTP: Not the Same Thing
HTTP is a protocol. REST is an architectural style that typically uses HTTP. You can build non-RESTful APIs over HTTP (like SOAP or RPC-style APIs), and theoretically, you could build RESTful systems over other protocols.
When we talk about building “REST APIs” in .NET, we really mean HTTP APIs that follow REST constraints—using resources, standard methods, proper status codes, and stateless communication.
Why REST Won
Before REST dominated, we had:
- SOAP: XML-based, envelope-wrapped, WSDL contracts. Heavy, complex, and painful to debug.
- XML-RPC: Procedure calls over HTTP. Simple but not resource-oriented.
- Custom protocols: Every team invented their own conventions.
REST won because it’s:
| Advantage | Why It Matters |
|---|---|
| Simple | Uses HTTP verbs you already know (GET, POST, PUT, DELETE) |
| Scalable | Stateless design enables horizontal scaling |
| Flexible | Supports multiple formats (JSON, XML, etc.) |
| Cacheable | Built-in HTTP caching reduces server load |
| Decoupled | Client and server evolve independently |
For .NET developers, REST means you can use ASP.NET Core’s minimal APIs or controllers to build APIs that any client—mobile apps, SPAs, microservices—can consume without specialized tooling.
The Six REST Constraints (Most Developers Only Know Three)
Roy Fielding defined six constraints that make an architecture “RESTful.” Most developers know about statelessness and uniform interface, but the others matter too.
1. Client-Server Separation
The client (consumer) and server (API) must be independent. The client doesn’t know how the server stores data. The server doesn’t care what UI framework the client uses.
Why it matters: You can rewrite your entire .NET backend without changing mobile apps, or build a new React frontend without touching the API. True separation of concerns.
2. Statelessness
Each request from client to server must contain all information needed to understand and process the request. The server doesn’t store client session state between requests.
What this means practically:
// BAD: Relying on server-side sessionapp.MapPost("/api/orders", (HttpContext context) =>{ var userId = context.Session.GetInt32("UserId"); // Session state! // Create order for this user});
// GOOD: All context in the requestapp.MapPost("/api/orders", (CreateOrderRequest request, ClaimsPrincipal user) =>{ var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; // From JWT // Create order for this user});Benefits:
- Scalability: Any server instance can handle any request—no sticky sessions needed.
- Reliability: Server crashes don’t lose client state.
- Simplicity: No session synchronization across server instances.
3. Cacheability
Responses must define themselves as cacheable or non-cacheable. If cacheable, clients (and intermediaries like CDNs) can reuse responses for equivalent requests.
app.MapGet("/api/products/{id}", async (int id, ProductService service) =>{ var product = await service.GetByIdAsync(id); return product is null ? Results.NotFound() : Results.Ok(product);}).CacheOutput(policy => policy.Expire(TimeSpan.FromMinutes(10))); // Cacheable for 10 minutesThe response includes headers like:
Cache-Control: public, max-age=600ETag: "abc123"4. Uniform Interface
This is the most important REST constraint. It defines four sub-constraints:
4.1 Resource Identification
Resources are identified by URIs. Everything is a resource—users, orders, products, even abstract concepts like search results.
/api/users → Collection of users/api/users/42 → Specific user/api/users/42/orders → Orders belonging to user 424.2 Resource Manipulation Through Representations
Clients interact with resources through representations (usually JSON). The representation isn’t the resource itself—it’s a snapshot of its state.
{ "id": 42, "name": "John Doe", "createdAt": "2026-01-02T10:30:00Z"}The actual User object in your database has more fields, relationships, and internal state. The JSON is a representation suitable for the client.
4.3 Self-Descriptive Messages
Each message includes enough information to describe how to process it. HTTP headers play a key role:
POST /api/users HTTP/1.1Host: api.example.comContent-Type: application/json ← How to parse the bodyAccept: application/json ← What format I want backAuthorization: Bearer eyJhb... ← How to authenticate me
4.4 HATEOAS (Hypermedia as the Engine of Application State)
Responses should include links to related actions and resources. This is the most controversial and least-implemented constraint.
{ "id": 42, "name": "John Doe", "_links": { "self": { "href": "/api/users/42" }, "orders": { "href": "/api/users/42/orders" }, "update": { "href": "/api/users/42", "method": "PUT" }, "delete": { "href": "/api/users/42", "method": "DELETE" } }}Practical reality: Full HATEOAS is rarely implemented in practice. Most “REST APIs” skip this constraint. That’s okay—pragmatism beats purity. But understanding it helps you design more discoverable APIs.
5. Layered System
The client doesn’t know (or care) whether it’s communicating directly with the end server or through intermediaries like load balancers, API gateways, or CDNs.
┌────────┐ ┌───────────┐ ┌──────────────┐ ┌────────────┐│ Client │ ─► │ Cloudflare│ ─► │ API Gateway │ ─► │ .NET API ││ │ │ CDN │ │ (Auth, Rate │ │ (Business ││ │ │ │ │ Limiting) │ │ Logic) │└────────┘ └───────────┘ └──────────────┘ └────────────┘ ▲ │ Client thinks it's talking directly to API6. Code on Demand (Optional)
Servers can extend client functionality by transferring executable code (like JavaScript). This is optional and rarely used for APIs—it’s more relevant for web browsers.
RESTful API Design Best Practices
Now let’s get into the practical patterns that separate well-designed APIs from the chaos I see in production codebases.
Use Nouns, Not Verbs
URIs identify resources. Use nouns. The HTTP method provides the verb.
| ❌ Bad (Verbs in URI) | ✅ Good (Resource Nouns) |
|---|---|
GET /api/getUsers | GET /api/users |
POST /api/createUser | POST /api/users |
PUT /api/updateUser/42 | PUT /api/users/42 |
DELETE /api/deleteUser/42 | DELETE /api/users/42 |
GET /api/getUserOrders/42 | GET /api/users/42/orders |
The HTTP method already tells us the action:
// The method IS the verbapp.MapGet("/api/users", GetUsers); // GET = retrieveapp.MapPost("/api/users", CreateUser); // POST = createapp.MapPut("/api/users/{id}", UpdateUser); // PUT = replaceapp.MapDelete("/api/users/{id}", DeleteUser);// DELETE = removeUse Plural Nouns for Collections
Always use plural nouns for resource collections. It’s consistent and reads naturally.
| ❌ Inconsistent | ✅ Consistent (Plural) |
|---|---|
/api/user | /api/users |
/api/user/42 | /api/users/42 |
/api/product | /api/products |
/api/order | /api/orders |
This stays consistent whether you’re accessing the collection or a single item:
GET /api/users → Returns list of usersGET /api/users/42 → Returns single userPOST /api/users → Creates a user (in the users collection)Use Nesting to Show Relationships
When resources have parent-child relationships, use nested routes to express them.
# Orders belong to usersGET /api/users/42/orders → All orders for user 42GET /api/users/42/orders/7 → Order 7 for user 42POST /api/users/42/orders → Create order for user 42
# Items belong to ordersGET /api/orders/7/items → All items in order 7POST /api/orders/7/items → Add item to order 7But don’t go too deep. Three levels is usually the maximum before URLs become unwieldy:
# Too deep - hard to read and useGET /api/users/42/orders/7/items/3/options/1
# Better - flatten when entities can stand aloneGET /api/order-items/3/options/1Rule of thumb: If the child resource can exist independently (like an order that could belong to a guest), consider a flat structure with query parameters instead:
GET /api/orders?userId=42 → Alternative to /users/42/ordersPath Parameters vs. Query Parameters
Path parameters identify a specific resource:
GET /api/users/42 → User with ID 42GET /api/products/abc-123 → Product with slug abc-123Query parameters filter, sort, paginate, or modify the response:
GET /api/users?role=admin&sort=name → Filter and sortGET /api/products?category=electronics → Filter by categoryGET /api/orders?page=2&pageSize=20 → Pagination| Use Path Parameters When | Use Query Parameters When |
|---|---|
| Identifying a resource | Filtering a collection |
| Required for the request | Optional refinements |
| Part of the resource hierarchy | Sorting, pagination, search |
Never do this:
# BAD: Path for filteringGET /api/users/role/admin/sort/name/page/2
# GOOD: Query for filteringGET /api/users?role=admin&sort=name&page=2HTTP Methods Deep Dive
Getting HTTP methods right is fundamental. Here’s the complete picture.
GET – Retrieve Resources
Retrieve a resource without modifying it. Must be safe and idempotent.
app.MapGet("/api/users", async (UserService service) =>{ var users = await service.GetAllAsync(); return Results.Ok(users);});
app.MapGet("/api/users/{id:int}", async (int id, UserService service) =>{ var user = await service.GetByIdAsync(id); return user is null ? Results.NotFound() : Results.Ok(user);});Response: 200 OK with resource body, or 404 Not Found if it doesn’t exist.
POST – Create Resources
Create a new resource. Not idempotent—calling it twice creates two resources.
app.MapPost("/api/users", async (CreateUserRequest request, UserService service) =>{ var user = await service.CreateAsync(request); return Results.Created($"/api/users/{user.Id}", user);});Key points:
- Return
201 Created, not200 OK - Include
Locationheader pointing to the new resource - Return the created resource in the body
PUT – Replace Resources
Replace an entire resource. Must be idempotent—calling it multiple times produces the same result.
app.MapPut("/api/users/{id:int}", async (int id, UpdateUserRequest request, UserService service) =>{ var user = await service.GetByIdAsync(id); if (user is null) return Results.NotFound();
await service.UpdateAsync(id, request); return Results.NoContent();});Key points:
- Client sends the complete resource representation
- Missing fields are set to null/default (not ignored)
- Return
204 No Contenton success (or200 OKwith updated resource)
PATCH – Partial Updates
Update specific fields of a resource. Use when you only want to modify some properties.
app.MapPatch("/api/users/{id:int}", async (int id, JsonPatchDocument<User> patchDoc, UserService service) =>{ var user = await service.GetByIdAsync(id); if (user is null) return Results.NotFound();
patchDoc.ApplyTo(user); await service.UpdateAsync(id, user); return Results.NoContent();});Or with a simpler DTO approach:
public record PatchUserRequest(string? Name, string? Email);
app.MapPatch("/api/users/{id:int}", async (int id, PatchUserRequest request, UserService service) =>{ var user = await service.GetByIdAsync(id); if (user is null) return Results.NotFound();
// Only update provided fields if (request.Name is not null) user.Name = request.Name; if (request.Email is not null) user.Email = request.Email;
await service.UpdateAsync(id, user); return Results.NoContent();});DELETE – Remove Resources
Delete a resource. Should be idempotent—deleting something twice shouldn’t error (the second request can return 404).
app.MapDelete("/api/users/{id:int}", async (int id, UserService service) =>{ var user = await service.GetByIdAsync(id); if (user is null) return Results.NotFound();
await service.DeleteAsync(id); return Results.NoContent();});Response: 204 No Content on success.
PUT vs. PATCH – When to Use Each
| Aspect | PUT | PATCH |
|---|---|---|
| Payload | Complete resource | Only changed fields |
| Missing fields | Reset to null/default | Left unchanged |
| Idempotent | Yes | Depends on implementation |
| Use when | Replacing the whole resource | Updating a few fields |
Example:
// Current user state{ "id": 42, "name": "John Doe", "phone": "555-1234"}
// PUT request - replaces everythingPUT /api/users/42{ "name": "John Smith",}// Result: phone is now NULL (not provided)
// PATCH request - updates only provided fieldsPATCH /api/users/42{ "name": "John Smith"}// Result: phone and email unchangedLess Common Methods
- HEAD: Like GET but returns only headers (no body). Useful for checking if a resource exists.
- OPTIONS: Returns allowed methods for a resource. Used by browsers for CORS preflight.
HTTP Status Codes Done Right
Status codes tell the client what happened. Using them correctly makes your API predictable and debuggable.
Success Codes (2xx)
| Code | Name | When to Use |
|---|---|---|
200 OK | Success | GET that returns data, PUT/PATCH that returns updated resource |
201 Created | Resource created | POST that creates a new resource |
204 No Content | Success, no body | DELETE, PUT/PATCH when not returning the resource |
// 200 OK - returning datareturn Results.Ok(users);
// 201 Created - new resource with locationreturn Results.Created($"/api/users/{user.Id}", user);
// 204 No Content - success but nothing to returnreturn Results.NoContent();Client Error Codes (4xx)
| Code | Name | When to Use |
|---|---|---|
400 Bad Request | Invalid input | Malformed JSON, validation errors |
401 Unauthorized | Not authenticated | Missing or invalid credentials |
403 Forbidden | Not authorized | Valid credentials but no permission |
404 Not Found | Resource doesn’t exist | ID not found, bad route |
409 Conflict | State conflict | Duplicate email, version mismatch |
422 Unprocessable Entity | Semantic error | Business rule violation |
429 Too Many Requests | Rate limited | Client exceeded rate limit |
// 400 Bad Request - validation failedreturn Results.BadRequest(new { errors = validationErrors });
// 401 Unauthorized - no/invalid credentialsreturn Results.Unauthorized();
// 403 Forbidden - authenticated but not allowedreturn Results.Forbid();
// 404 Not Found - resource doesn't existreturn Results.NotFound();
// 409 Conflict - can't complete due to statereturn Results.Conflict(new { message = "Email already registered" });Server Error Codes (5xx)
| Code | Name | When to Use |
|---|---|---|
500 Internal Server Error | Server bug | Unhandled exception |
502 Bad Gateway | Upstream error | Proxy/gateway received bad response |
503 Service Unavailable | Temporarily down | Maintenance, overload |
504 Gateway Timeout | Upstream timeout | Proxy/gateway didn’t get response in time |
Never expose stack traces in production 5xx responses. Log them server-side and return a generic error message to clients.
Common Mistakes
❌ Returning 200 for errors:
// BAD: Success status with error bodyreturn Results.Ok(new { success = false, error = "User not found" });
// GOOD: Appropriate error statusreturn Results.NotFound(new { error = "User not found" });❌ Using 500 for client errors:
// BAD: Server error for bad inputthrow new Exception("Invalid email format"); // Results in 500
// GOOD: Client error for bad inputreturn Results.BadRequest(new { error = "Invalid email format" });❌ 401 vs. 403 confusion:
// 401: "Who are you?" - Authentication issue// 403: "I know who you are, but you can't do this" - Authorization issueError Response Design
Consistent error responses make your API easier to consume. Here’s a pattern that works:
Standard Error Format
public record ApiError( string Code, string Message, string? Target = null, IEnumerable<ApiError>? Details = null);
public record ProblemDetails( string Type, string Title, int Status, string? Detail = null, string? Instance = null, IDictionary<string, object?>? Extensions = null);Example Responses
Validation Error (400):
{ "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1", "title": "One or more validation errors occurred.", "status": 400, "errors": { "email": ["The email field is required."], "name": ["Name must be between 2 and 100 characters."] }}Not Found (404):
{ "type": "https://tools.ietf.org/html/rfc7231#section-6.5.4", "title": "Not Found", "status": 404, "detail": "User with ID 42 was not found.", "instance": "/api/users/42"}Conflict (409):
{ "type": "https://example.com/problems/duplicate-email", "title": "Conflict", "status": 409, "conflictingResource": "/api/users/17"}Implementing in .NET
ASP.NET Core has built-in support for RFC 7807 Problem Details:
builder.Services.AddProblemDetails(options =>{ options.CustomizeProblemDetails = context => { context.ProblemDetails.Instance = context.HttpContext.Request.Path; context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier; };});
app.UseExceptionHandler();app.UseStatusCodePages();Custom exception handling:
app.UseExceptionHandler(exceptionApp =>{ exceptionApp.Run(async context => { var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
var problemDetails = exception switch { NotFoundException ex => new ProblemDetails { Status = 404, Title = "Not Found", Detail = ex.Message }, ValidationException ex => new ProblemDetails { Status = 400, Title = "Validation Error", Detail = ex.Message, Extensions = { ["errors"] = ex.Errors } }, _ => new ProblemDetails { Status = 500, Title = "Internal Server Error", Detail = "An unexpected error occurred." } };
context.Response.StatusCode = problemDetails.Status ?? 500; context.Response.ContentType = "application/problem+json"; await context.Response.WriteAsJsonAsync(problemDetails); });});API Versioning Strategies
Your API will evolve. Breaking changes are inevitable. Versioning prevents you from breaking existing clients.
URI Path Versioning (Recommended)
Version in the URL path. Most explicit and cache-friendly.
GET /api/v1/usersGET /api/v2/usersvar v1 = app.MapGroup("/api/v1");var v2 = app.MapGroup("/api/v2");
v1.MapGet("/users", GetUsersV1);v2.MapGet("/users", GetUsersV2);Query String Versioning
Version as a query parameter.
GET /api/users?api-version=1.0GET /api/users?api-version=2.0Header Versioning
Version in a custom header.
GET /api/usersX-API-Version: 2.0Which to Choose?
| Strategy | Pros | Cons |
|---|---|---|
| URI Path | Explicit, cacheable, easy to test | Duplicates routes, “ugly” URLs |
| Query String | Simple, optional | Less discoverable, harder to cache |
| Header | Clean URLs | Hidden, requires documentation |
My recommendation: URI Path versioning. It’s the most discoverable and debuggable. When a client reports an issue, you immediately know which version they’re using.
Implementing with Asp.Versioning
builder.Services.AddApiVersioning(options =>{ options.DefaultApiVersion = new ApiVersion(1, 0); options.AssumeDefaultVersionWhenUnspecified = true; options.ReportApiVersions = true; options.ApiVersionReader = new UrlSegmentApiVersionReader();}).AddApiExplorer(options =>{ options.GroupNameFormat = "'v'VVV"; options.SubstituteApiVersionInUrl = true;});Deprecation Strategy
When deprecating a version:
- Add
Sunsetheader with deprecation date - Communicate to clients via email/docs
- Keep deprecated version running for a transition period
- Monitor usage and remove when safe
app.MapGet("/api/v1/users", GetUsersV1) .WithMetadata(new ObsoleteAttribute("Use /api/v2/users instead. Sunset: 2026-06-01"));Pagination, Filtering, and Sorting
Any endpoint returning a collection needs pagination. Nobody wants to fetch 10,000 records when they need 20.
Pagination
Offset-based (most common):
GET /api/users?page=2&pageSize=20Response includes metadata:
{ "data": [...], "pagination": { "page": 2, "pageSize": 20, "totalPages": 15, "totalCount": 287, "hasNext": true, "hasPrevious": true }}Cursor-based (better for large datasets):
GET /api/users?cursor=eyJpZCI6NDJ9&limit=20public record PagedResponse<T>( IEnumerable<T> Data, PaginationMeta Pagination);
public record PaginationMeta( int Page, int PageSize, int TotalPages, int TotalCount, bool HasNext, bool HasPrevious);
app.MapGet("/api/users", async ( int page = 1, int pageSize = 20, UserService service) =>{ pageSize = Math.Clamp(pageSize, 1, 100); // Enforce max page size
var (users, totalCount) = await service.GetPagedAsync(page, pageSize); var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
return Results.Ok(new PagedResponse<User>(users, new PaginationMeta( page, pageSize, totalPages, totalCount, HasNext: page < totalPages, HasPrevious: page > 1 )));});Filtering
Use query parameters for filtering:
GET /api/products?category=electronics&minPrice=100&maxPrice=500&inStock=truepublic record ProductFilter( string? Category, decimal? MinPrice, decimal? MaxPrice, bool? InStock, string? Search);
app.MapGet("/api/products", async ([AsParameters] ProductFilter filter, ProductService service) =>{ var products = await service.GetFilteredAsync(filter); return Results.Ok(products);});Sorting
GET /api/users?sort=name&order=ascGET /api/users?sort=-createdAt # Prefix with - for descendingapp.MapGet("/api/users", async ( string? sort = "id", string? order = "asc", UserService service) =>{ var allowedSortFields = new[] { "id", "name", "email", "createdAt" }; if (!allowedSortFields.Contains(sort, StringComparer.OrdinalIgnoreCase)) sort = "id";
var users = await service.GetSortedAsync(sort, order == "desc"); return Results.Ok(users);});Combined Example
GET /api/products?category=electronics&minPrice=100&sort=-price&page=1&pageSize=20This endpoint:
- Filters by category and minimum price
- Sorts by price descending
- Returns page 1 with 20 items per page
Caching for Performance
Proper caching can reduce your server load by 90%+ and dramatically improve response times.
Response Headers
Cache-Control: public, max-age=3600 # Cache for 1 hourCache-Control: private, max-age=300 # User-specific, cache 5 minCache-Control: no-cache # Validate before using cacheCache-Control: no-store # Never cache (sensitive data)
ETag: "abc123" # Version identifierLast-Modified: Wed, 01 Jan 2026 10:00:00 GMTETag Validation
ETags enable conditional requests—the server only sends the full response if the resource has changed.
app.MapGet("/api/products/{id}", async (int id, ProductService service, HttpContext context) =>{ var product = await service.GetByIdAsync(id); if (product is null) return Results.NotFound();
var etag = $"\"{product.Version}\"";
// Check if client has current version if (context.Request.Headers.IfNoneMatch == etag) return Results.StatusCode(304); // Not Modified
context.Response.Headers.ETag = etag; context.Response.Headers.CacheControl = "private, max-age=60";
return Results.Ok(product);});Output Caching in .NET
builder.Services.AddOutputCache(options =>{ options.AddBasePolicy(builder => builder.Expire(TimeSpan.FromMinutes(10)));
options.AddPolicy("Products", builder => builder.Expire(TimeSpan.FromMinutes(30)) .Tag("products"));
options.AddPolicy("UserSpecific", builder => builder.Expire(TimeSpan.FromMinutes(5)) .SetVaryByQuery("userId"));});
app.UseOutputCache();
app.MapGet("/api/products", GetProducts) .CacheOutput("Products");
app.MapGet("/api/users/{id}/profile", GetUserProfile) .CacheOutput("UserSpecific");Cache Invalidation
app.MapPost("/api/products", async ( CreateProductRequest request, ProductService service, IOutputCacheStore cache) =>{ var product = await service.CreateAsync(request);
// Invalidate product list cache await cache.EvictByTagAsync("products", CancellationToken.None);
return Results.Created($"/api/products/{product.Id}", product);});Security Best Practices
Always Use HTTPS
In production, HTTPS is non-negotiable. Configure HSTS:
if (!app.Environment.IsDevelopment()){ app.UseHsts();}app.UseHttpsRedirection();Authentication with JWT
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = builder.Configuration["Jwt:Issuer"], ValidAudience = builder.Configuration["Jwt:Audience"], IssuerSigningKey = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!)) }; });
app.UseAuthentication();app.UseAuthorization();Rate Limiting
Prevent abuse with rate limiting:
builder.Services.AddRateLimiter(options =>{ options.AddFixedWindowLimiter("fixed", config => { config.Window = TimeSpan.FromMinutes(1); config.PermitLimit = 100; config.QueueLimit = 10; });
options.AddSlidingWindowLimiter("sliding", config => { config.Window = TimeSpan.FromMinutes(1); config.SegmentsPerWindow = 6; config.PermitLimit = 100; });
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;});
app.UseRateLimiter();
app.MapGet("/api/users", GetUsers) .RequireRateLimiting("fixed");Input Validation
Never trust client input:
public record CreateUserRequest( [Required, StringLength(100, MinimumLength = 2)] string Name, [Required, EmailAddress] string Email, [Required, MinLength(8)] string Password);
app.MapPost("/api/users", async (CreateUserRequest request, UserService service) =>{ // ASP.NET Core validates automatically when using [Required], etc. var user = await service.CreateAsync(request); return Results.Created($"/api/users/{user.Id}", user);}).WithParameterValidation(); // Requires MinimalApis.ExtensionsPrevent Common Vulnerabilities
- SQL Injection: Always use parameterized queries or EF Core (never string concatenation)
- XSS: ASP.NET Core encodes output by default
- CSRF: Use anti-forgery tokens for form submissions
- Mass Assignment: Use DTOs, never bind directly to entities
API Documentation with OpenAPI
Good documentation is the difference between an API people love and one they hate.
Built-in OpenAPI in .NET
builder.Services.AddOpenApi();
var app = builder.Build();
app.MapOpenApi(); // Available at /openapi/v1.jsonScalar for Interactive Documentation
using Scalar.AspNetCore;
app.MapScalarApiReference(); // Available at /scalar/v1Enhancing Documentation
app.MapGet("/api/users/{id}", async (int id, UserService service) =>{ var user = await service.GetByIdAsync(id); return user is null ? Results.NotFound() : Results.Ok(user);}).WithName("GetUserById").WithSummary("Get a user by ID").WithDescription("Returns a single user based on their unique identifier. Returns 404 if the user doesn't exist.").Produces<User>(StatusCodes.Status200OK).Produces(StatusCodes.Status404NotFound).WithTags("Users");Building a Complete RESTful API
Let’s put it all together with a real-world example—a product management API.
Project Structure
RestfulApiBestPractices.Api/├── Program.cs├── Models/│ ├── Product.cs│ └── User.cs├── DTOs/│ ├── CreateProductRequest.cs│ ├── UpdateProductRequest.cs│ └── ProductResponse.cs├── Services/│ ├── IProductService.cs│ └── ProductService.cs└── Extensions/ └── EndpointExtensions.csModels
public record Product{ public int Id { get; init; } public required string Name { get; set; } public required string Description { get; set; } public decimal Price { get; set; } public int Stock { get; set; } public string? Category { get; set; } public DateTime CreatedAt { get; init; } = DateTime.UtcNow; public DateTime? UpdatedAt { get; set; }}DTOs
public record CreateProductRequest( [property: Required, StringLength(200)] string Name, [property: Required] string Description, [property: Range(0.01, 1000000)] decimal Price, [property: Range(0, int.MaxValue)] int Stock, string? Category);
public record UpdateProductRequest( [property: Required, StringLength(200)] string Name, [property: Required] string Description, [property: Range(0.01, 1000000)] decimal Price, [property: Range(0, int.MaxValue)] int Stock, string? Category);
public record PatchProductRequest( string? Name, string? Description, decimal? Price, int? Stock, string? Category);
public record ProductResponse( int Id, string Name, string Description, decimal Price, int Stock, string? Category, DateTime CreatedAt, DateTime? UpdatedAt);Service Layer
public interface IProductService{ Task<IEnumerable<Product>> GetAllAsync(); Task<(IEnumerable<Product> Items, int TotalCount)> GetPagedAsync(int page, int pageSize); Task<Product?> GetByIdAsync(int id); Task<Product> CreateAsync(CreateProductRequest request); Task<Product?> UpdateAsync(int id, UpdateProductRequest request); Task<Product?> PatchAsync(int id, PatchProductRequest request); Task<bool> DeleteAsync(int id);}
public class ProductService : IProductService{ private static readonly List<Product> _products = new() { new Product { Id = 1, Name = "Laptop", Description = "High-performance laptop", Price = 999.99m, Stock = 50, Category = "Electronics" }, new Product { Id = 2, Name = "Mouse", Description = "Wireless mouse", Price = 29.99m, Stock = 200, Category = "Electronics" }, new Product { Id = 3, Name = "Keyboard", Description = "Mechanical keyboard", Price = 79.99m, Stock = 100, Category = "Electronics" } }; private static int _nextId = 4;
public Task<IEnumerable<Product>> GetAllAsync() => Task.FromResult<IEnumerable<Product>>(_products);
public Task<(IEnumerable<Product> Items, int TotalCount)> GetPagedAsync(int page, int pageSize) { var items = _products.Skip((page - 1) * pageSize).Take(pageSize); return Task.FromResult((items, _products.Count)); }
public Task<Product?> GetByIdAsync(int id) => Task.FromResult(_products.FirstOrDefault(p => p.Id == id));
public Task<Product> CreateAsync(CreateProductRequest request) { var product = new Product { Id = _nextId++, Name = request.Name, Description = request.Description, Price = request.Price, Stock = request.Stock, Category = request.Category }; _products.Add(product); return Task.FromResult(product); }
public Task<Product?> UpdateAsync(int id, UpdateProductRequest request) { var product = _products.FirstOrDefault(p => p.Id == id); if (product is null) return Task.FromResult<Product?>(null);
product.Name = request.Name; product.Description = request.Description; product.Price = request.Price; product.Stock = request.Stock; product.Category = request.Category; product.UpdatedAt = DateTime.UtcNow;
return Task.FromResult<Product?>(product); }
public Task<Product?> PatchAsync(int id, PatchProductRequest request) { var product = _products.FirstOrDefault(p => p.Id == id); if (product is null) return Task.FromResult<Product?>(null);
if (request.Name is not null) product.Name = request.Name; if (request.Description is not null) product.Description = request.Description; if (request.Price.HasValue) product.Price = request.Price.Value; if (request.Stock.HasValue) product.Stock = request.Stock.Value; if (request.Category is not null) product.Category = request.Category; product.UpdatedAt = DateTime.UtcNow;
return Task.FromResult<Product?>(product); }
public Task<bool> DeleteAsync(int id) { var product = _products.FirstOrDefault(p => p.Id == id); if (product is null) return Task.FromResult(false);
_products.Remove(product); return Task.FromResult(true); }}Endpoint Configuration
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// Servicesbuilder.Services.AddSingleton<IProductService, ProductService>();builder.Services.AddOpenApi();builder.Services.AddProblemDetails();
var app = builder.Build();
// Middlewareapp.UseExceptionHandler();app.MapOpenApi();app.MapScalarApiReference();
// API Routesvar api = app.MapGroup("/api/v1");
api.MapGet("/products", async ( int page = 1, int pageSize = 20, IProductService service) =>{ pageSize = Math.Clamp(pageSize, 1, 100); var (products, totalCount) = await service.GetPagedAsync(page, pageSize); var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
return Results.Ok(new { data = products.Select(p => new ProductResponse( p.Id, p.Name, p.Description, p.Price, p.Stock, p.Category, p.CreatedAt, p.UpdatedAt)), pagination = new { page, pageSize, totalPages, totalCount, hasNext = page < totalPages, hasPrevious = page > 1 } });}).WithName("GetProducts").WithSummary("Get all products with pagination").WithTags("Products");
api.MapGet("/products/{id:int}", async (int id, IProductService service) =>{ var product = await service.GetByIdAsync(id); return product is null ? Results.NotFound(new { error = $"Product with ID {id} not found" }) : Results.Ok(new ProductResponse( product.Id, product.Name, product.Description, product.Price, product.Stock, product.Category, product.CreatedAt, product.UpdatedAt));}).WithName("GetProductById").WithSummary("Get a product by ID").Produces<ProductResponse>(StatusCodes.Status200OK).Produces(StatusCodes.Status404NotFound).WithTags("Products");
api.MapPost("/products", async (CreateProductRequest request, IProductService service) =>{ var product = await service.CreateAsync(request); var response = new ProductResponse( product.Id, product.Name, product.Description, product.Price, product.Stock, product.Category, product.CreatedAt, product.UpdatedAt);
return Results.Created($"/api/v1/products/{product.Id}", response);}).WithName("CreateProduct").WithSummary("Create a new product").Produces<ProductResponse>(StatusCodes.Status201Created).Produces(StatusCodes.Status400BadRequest).WithTags("Products");
api.MapPut("/products/{id:int}", async (int id, UpdateProductRequest request, IProductService service) =>{ var product = await service.UpdateAsync(id, request); return product is null ? Results.NotFound(new { error = $"Product with ID {id} not found" }) : Results.NoContent();}).WithName("UpdateProduct").WithSummary("Update an existing product").Produces(StatusCodes.Status204NoContent).Produces(StatusCodes.Status404NotFound).WithTags("Products");
api.MapPatch("/products/{id:int}", async (int id, PatchProductRequest request, IProductService service) =>{ var product = await service.PatchAsync(id, request); return product is null ? Results.NotFound(new { error = $"Product with ID {id} not found" }) : Results.NoContent();}).WithName("PatchProduct").WithSummary("Partially update a product").Produces(StatusCodes.Status204NoContent).Produces(StatusCodes.Status404NotFound).WithTags("Products");
api.MapDelete("/products/{id:int}", async (int id, IProductService service) =>{ var deleted = await service.DeleteAsync(id); return deleted ? Results.NoContent() : Results.NotFound(new { error = $"Product with ID {id} not found" });}).WithName("DeleteProduct").WithSummary("Delete a product").Produces(StatusCodes.Status204NoContent).Produces(StatusCodes.Status404NotFound).WithTags("Products");
app.Run();Testing the API
Run the application and navigate to /scalar/v1 to see the interactive documentation. Here are some example requests:
# Get all products (paginated)curl http://localhost:5000/api/v1/products?page=1&pageSize=10
# Get a specific productcurl http://localhost:5000/api/v1/products/1
# Create a new productcurl -X POST http://localhost:5000/api/v1/products \ -H "Content-Type: application/json" \ -d '{"name":"Headphones","description":"Noise-cancelling headphones","price":199.99,"stock":75,"category":"Electronics"}'
# Update a product (full replacement)curl -X PUT http://localhost:5000/api/v1/products/1 \ -H "Content-Type: application/json" \ -d '{"name":"Gaming Laptop","description":"High-end gaming laptop","price":1499.99,"stock":30,"category":"Electronics"}'
# Partial updatecurl -X PATCH http://localhost:5000/api/v1/products/1 \ -H "Content-Type: application/json" \ -d '{"price":1299.99}'
# Delete a productcurl -X DELETE http://localhost:5000/api/v1/products/1Quick Reference Cheat Sheet
| Principle | Rule |
|---|---|
| URIs | Use nouns, plural form, no verbs |
| GET | Retrieve resources (safe, idempotent) |
| POST | Create resources (returns 201 + Location) |
| PUT | Replace resources (idempotent) |
| PATCH | Partial update (not necessarily idempotent) |
| DELETE | Remove resources (idempotent) |
| 404 | Resource not found |
| 400 | Bad request / validation error |
| 401 | Authentication required |
| 403 | Permission denied |
| 409 | Conflict (duplicate, version mismatch) |
| Pagination | Always paginate collections |
| Versioning | Use URI path (/api/v1/) |
| Caching | Use ETags and Cache-Control |
Wrap-Up
Building truly RESTful APIs isn’t about blindly following rules—it’s about understanding why those rules exist and applying them to make your APIs predictable, maintainable, and delightful to use.
Here’s what separates good APIs from great ones:
- Consistent resource naming – Clients can guess your endpoints
- Proper HTTP methods – GET for reads, POST for creates, PUT/PATCH for updates, DELETE for removals
- Meaningful status codes – Don’t return 200 for everything
- Structured error responses – Problem Details format (RFC 7807)
- Versioning strategy – Plan for evolution from day one
- Pagination by default – Never return unbounded lists
- Documentation – OpenAPI + Scalar for interactive docs
This article is Chapter 1 of our FREE .NET Web API Zero to Hero course. In the next article, we’ll dive deep into Middlewares and the Request Pipeline in ASP.NET Core—understanding how requests flow through your application and how to intercept them.
Grab the complete source code from github.com/codewithmukesh/dotnet-webapi-zero-to-hero-course and start building better APIs today.
Have questions or want to share how you’ve applied these principles? Drop a comment below—I’d love to hear from you.


