Free .NET Web API Course

RESTful API Best Practices for .NET Developers – The Complete Guide to Building Production-Ready APIs

Master REST principles and build bulletproof .NET Web APIs. Learn resource naming, HTTP methods, status codes, versioning, pagination, error handling, and real-world patterns that separate amateur APIs from production-grade ones.

dotnet webapi-course

rest webapi api-design dotnet-webapi-zero-to-hero-course

22 min read

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:

AdvantageWhy It Matters
SimpleUses HTTP verbs you already know (GET, POST, PUT, DELETE)
ScalableStateless design enables horizontal scaling
FlexibleSupports multiple formats (JSON, XML, etc.)
CacheableBuilt-in HTTP caching reduces server load
DecoupledClient 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 session
app.MapPost("/api/orders", (HttpContext context) =>
{
var userId = context.Session.GetInt32("UserId"); // Session state!
// Create order for this user
});
// GOOD: All context in the request
app.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 minutes

The response includes headers like:

Cache-Control: public, max-age=600
ETag: "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 42

4.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",
"email": "[email protected]",
"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.1
Host: api.example.com
Content-Type: application/json ← How to parse the body
Accept: application/json ← What format I want back
Authorization: Bearer eyJhb... ← How to authenticate me
{"name": "John Doe", "email": "[email protected]"}

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 API

6. 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/getUsersGET /api/users
POST /api/createUserPOST /api/users
PUT /api/updateUser/42PUT /api/users/42
DELETE /api/deleteUser/42DELETE /api/users/42
GET /api/getUserOrders/42GET /api/users/42/orders

The HTTP method already tells us the action:

// The method IS the verb
app.MapGet("/api/users", GetUsers); // GET = retrieve
app.MapPost("/api/users", CreateUser); // POST = create
app.MapPut("/api/users/{id}", UpdateUser); // PUT = replace
app.MapDelete("/api/users/{id}", DeleteUser);// DELETE = remove

Use 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 users
GET /api/users/42 → Returns single user
POST /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 users
GET /api/users/42/orders → All orders for user 42
GET /api/users/42/orders/7 → Order 7 for user 42
POST /api/users/42/orders → Create order for user 42
# Items belong to orders
GET /api/orders/7/items → All items in order 7
POST /api/orders/7/items → Add item to order 7

But don’t go too deep. Three levels is usually the maximum before URLs become unwieldy:

# Too deep - hard to read and use
GET /api/users/42/orders/7/items/3/options/1
# Better - flatten when entities can stand alone
GET /api/order-items/3/options/1

Rule 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/orders

Path Parameters vs. Query Parameters

Path parameters identify a specific resource:

GET /api/users/42 → User with ID 42
GET /api/products/abc-123 → Product with slug abc-123

Query parameters filter, sort, paginate, or modify the response:

GET /api/users?role=admin&sort=name → Filter and sort
GET /api/products?category=electronics → Filter by category
GET /api/orders?page=2&pageSize=20 → Pagination
Use Path Parameters WhenUse Query Parameters When
Identifying a resourceFiltering a collection
Required for the requestOptional refinements
Part of the resource hierarchySorting, pagination, search

Never do this:

# BAD: Path for filtering
GET /api/users/role/admin/sort/name/page/2
# GOOD: Query for filtering
GET /api/users?role=admin&sort=name&page=2

HTTP 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, not 200 OK
  • Include Location header 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 Content on success (or 200 OK with 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

AspectPUTPATCH
PayloadComplete resourceOnly changed fields
Missing fieldsReset to null/defaultLeft unchanged
IdempotentYesDepends on implementation
Use whenReplacing the whole resourceUpdating a few fields

Example:

// Current user state
{
"id": 42,
"name": "John Doe",
"email": "[email protected]",
"phone": "555-1234"
}
// PUT request - replaces everything
PUT /api/users/42
{
"name": "John Smith",
"email": "[email protected]"
}
// Result: phone is now NULL (not provided)
// PATCH request - updates only provided fields
PATCH /api/users/42
{
"name": "John Smith"
}
// Result: phone and email unchanged

Less 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)

CodeNameWhen to Use
200 OKSuccessGET that returns data, PUT/PATCH that returns updated resource
201 CreatedResource createdPOST that creates a new resource
204 No ContentSuccess, no bodyDELETE, PUT/PATCH when not returning the resource
// 200 OK - returning data
return Results.Ok(users);
// 201 Created - new resource with location
return Results.Created($"/api/users/{user.Id}", user);
// 204 No Content - success but nothing to return
return Results.NoContent();

Client Error Codes (4xx)

CodeNameWhen to Use
400 Bad RequestInvalid inputMalformed JSON, validation errors
401 UnauthorizedNot authenticatedMissing or invalid credentials
403 ForbiddenNot authorizedValid credentials but no permission
404 Not FoundResource doesn’t existID not found, bad route
409 ConflictState conflictDuplicate email, version mismatch
422 Unprocessable EntitySemantic errorBusiness rule violation
429 Too Many RequestsRate limitedClient exceeded rate limit
// 400 Bad Request - validation failed
return Results.BadRequest(new { errors = validationErrors });
// 401 Unauthorized - no/invalid credentials
return Results.Unauthorized();
// 403 Forbidden - authenticated but not allowed
return Results.Forbid();
// 404 Not Found - resource doesn't exist
return Results.NotFound();
// 409 Conflict - can't complete due to state
return Results.Conflict(new { message = "Email already registered" });

Server Error Codes (5xx)

CodeNameWhen to Use
500 Internal Server ErrorServer bugUnhandled exception
502 Bad GatewayUpstream errorProxy/gateway received bad response
503 Service UnavailableTemporarily downMaintenance, overload
504 Gateway TimeoutUpstream timeoutProxy/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 body
return Results.Ok(new { success = false, error = "User not found" });
// GOOD: Appropriate error status
return Results.NotFound(new { error = "User not found" });

Using 500 for client errors:

// BAD: Server error for bad input
throw new Exception("Invalid email format"); // Results in 500
// GOOD: Client error for bad input
return 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 issue

Error 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,
"detail": "A user with email [email protected] already exists.",
"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.

Version in the URL path. Most explicit and cache-friendly.

GET /api/v1/users
GET /api/v2/users
var 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.0
GET /api/users?api-version=2.0

Header Versioning

Version in a custom header.

GET /api/users
X-API-Version: 2.0

Which to Choose?

StrategyProsCons
URI PathExplicit, cacheable, easy to testDuplicates routes, “ugly” URLs
Query StringSimple, optionalLess discoverable, harder to cache
HeaderClean URLsHidden, 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:

  1. Add Sunset header with deprecation date
  2. Communicate to clients via email/docs
  3. Keep deprecated version running for a transition period
  4. 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=20

Response 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=20
public 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=true
public 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=asc
GET /api/users?sort=-createdAt # Prefix with - for descending
app.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=20

This 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 hour
Cache-Control: private, max-age=300 # User-specific, cache 5 min
Cache-Control: no-cache # Validate before using cache
Cache-Control: no-store # Never cache (sensitive data)
ETag: "abc123" # Version identifier
Last-Modified: Wed, 01 Jan 2026 10:00:00 GMT

ETag 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.Extensions

Prevent 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.json

Scalar for Interactive Documentation

using Scalar.AspNetCore;
app.MapScalarApiReference(); // Available at /scalar/v1

Enhancing 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.cs

Models

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);
// Services
builder.Services.AddSingleton<IProductService, ProductService>();
builder.Services.AddOpenApi();
builder.Services.AddProblemDetails();
var app = builder.Build();
// Middleware
app.UseExceptionHandler();
app.MapOpenApi();
app.MapScalarApiReference();
// API Routes
var 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:

Terminal window
# Get all products (paginated)
curl http://localhost:5000/api/v1/products?page=1&pageSize=10
# Get a specific product
curl http://localhost:5000/api/v1/products/1
# Create a new product
curl -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 update
curl -X PATCH http://localhost:5000/api/v1/products/1 \
-H "Content-Type: application/json" \
-d '{"price":1299.99}'
# Delete a product
curl -X DELETE http://localhost:5000/api/v1/products/1

Quick Reference Cheat Sheet

PrincipleRule
URIsUse nouns, plural form, no verbs
GETRetrieve resources (safe, idempotent)
POSTCreate resources (returns 201 + Location)
PUTReplace resources (idempotent)
PATCHPartial update (not necessarily idempotent)
DELETERemove resources (idempotent)
404Resource not found
400Bad request / validation error
401Authentication required
403Permission denied
409Conflict (duplicate, version mismatch)
PaginationAlways paginate collections
VersioningUse URI path (/api/v1/)
CachingUse 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:

  1. Consistent resource naming – Clients can guess your endpoints
  2. Proper HTTP methods – GET for reads, POST for creates, PUT/PATCH for updates, DELETE for removals
  3. Meaningful status codes – Don’t return 200 for everything
  4. Structured error responses – Problem Details format (RFC 7807)
  5. Versioning strategy – Plan for evolution from day one
  6. Pagination by default – Never return unbounded lists
  7. 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.

What's your Feedback?
Do let me know your thoughts around this article.

Level Up Your .NET Skills

Join 8,000+ developers. Get one practical tip each week with best practices and real-world examples.

Weekly tips
Code examples
100% free
No spam, unsubscribe anytime