Web APIs & REST: Building HTTP-Based Services the Right Way
A practical guide to REST principles and building well-designed HTTP APIs — covering resource modeling, HTTP methods and status codes, versioning, pagination, error handling, HATEOAS, and common anti-patterns, with ASP.NET Core examples.
Table of Contents
- Introduction
- What REST Actually Means
- Resources and URIs
- HTTP Methods
- HTTP Status Codes
- Request and Response Design
- Filtering, Sorting, and Pagination
- Versioning
- Error Handling
- HATEOAS and the Richardson Maturity Model
- Idempotency and Caching
- Security Basics
- Common Anti-Patterns
- Quick Reference Table
- Conclusion
Introduction
REST (Representational State Transfer) isn’t a protocol or a library — it’s an architectural style, defined by Roy Fielding in his 2000 doctoral dissertation, for building distributed systems that communicate over HTTP. In practice, “REST API” has become shorthand for “an HTTP API that models data as resources and uses HTTP methods and status codes meaningfully” — which is a looser, more pragmatic interpretation than Fielding’s original constraints, but it’s the one that dominates real-world API design.
This guide covers both the pragmatic conventions most teams follow day-to-day and the stricter theory behind them, so you understand not just what to do but why.
1. What REST Actually Means
Fielding’s dissertation defines REST via a set of architectural constraints:
- Client-server — separation of concerns between UI/client and data storage/server.
- Statelessness — each request contains all the information needed to process it; the server holds no client session state between requests.
- Cacheability — responses must define themselves as cacheable or not, so clients (and intermediaries) can reuse them safely.
- Uniform interface — the core REST constraint, broken into four sub-constraints: resource identification (URIs), manipulation through representations (JSON/XML bodies), self-descriptive messages (methods, status codes, media types), and HATEOAS (hypermedia as the engine of application state — see Section 9).
- Layered system — a client can’t tell (and shouldn’t need to know) whether it’s talking directly to the server or through intermediaries like load balancers or gateways.
- Code on demand (optional) — servers can extend client functionality by transferring executable logic (rarely used in practice for APIs).
Most “REST APIs” in the wild satisfy constraints 1–3 and 5 fully, and part of 4 (resources + HTTP methods) — but skip HATEOAS almost entirely. That’s fine; it’s a spectrum, not a certification (see the Richardson Maturity Model in Section 9).
2. Resources and URIs
The foundational idea of REST API design: everything is a resource, identified by a URI, and you interact with it through a small, fixed set of HTTP methods rather than inventing a new “verb” endpoint for every action.
Nouns, not verbs
❌ GET /getAllProducts
❌ POST /createNewOrder
❌ POST /updateProductPrice
✅ GET /products
✅ POST /orders
✅ PATCH /products/{id}
Enter fullscreen mode Exit fullscreen mode
The HTTP method already carries the verb — putting another verb in the URL is redundant and usually a sign the API is modeled around actions (RPC-style) rather than resources.
Collections and individual resources
GET /products → list of products (collection)
POST /products → create a new product
GET /products/{id} → a single product
PUT /products/{id} → replace a single product
PATCH /products/{id} → partially update a single product
DELETE /products/{id} → delete a single product
Enter fullscreen mode Exit fullscreen mode
Nested resources for relationships
GET /customers/{id}/orders → orders belonging to a customer
GET /orders/{id}/line-items → line items belonging to an order
POST /customers/{id}/orders → create an order for a specific customer
Enter fullscreen mode Exit fullscreen mode
Nesting should reflect genuine ownership/containment, not just “these are related.” A rule of thumb: if the child resource can’t sensibly exist without the parent (an order line item without an order), nest it; if it can (a product exists independent of any one customer), avoid deep nesting like /customers/{id}/products.
Naming conventions
- Use plural nouns for collections:
/products, not/product. - Use lowercase, hyphen-separated paths:
/order-items, not/orderItemsor/OrderItems. - Avoid trailing slashes and file extensions:
/products/42, not/products/42.json.
3. HTTP Methods
Each HTTP method carries specific semantics that clients, proxies, and caches all rely on — using them correctly is what makes an API predictable.
Method Purpose Safe? Idempotent? Has a body?GET
Retrieve a resource or collection
Yes
Yes
No
POST
Create a resource, or trigger a non-idempotent action
No
No
Yes
PUT
Replace a resource entirely
No
Yes
Yes
PATCH
Partially update a resource
No
Not guaranteed
Yes
DELETE
Remove a resource
No
Yes
Usually no
HEAD
Like GET, but headers only, no body
Yes
Yes
No
OPTIONS
Discover allowed methods/CORS preflight
Yes
Yes
No
-
Safe means the method doesn’t change server state — a
GETshould never have side effects a client wouldn’t expect. -
Idempotent means calling it once has the same effect as calling it many times —
DELETE /products/5called twice still ends with the product gone (the second call may 404, which is fine — the end state is identical).
PUT vs. PATCH
// PUT: full replacement — every field must be provided
app.MapPut("/products/{id}", (int id, Product fullProduct) =>
{
// fullProduct completely replaces the existing resource
});
// PATCH: partial update — only supplied fields change
app.MapPatch("/products/{id}", (int id, JsonPatchDocument<Product> patch) =>
{
// apply only the specified operations
});
Enter fullscreen mode Exit fullscreen mode
A common PATCH body format is JSON Patch (RFC 6902):
[
{ "op": "replace", "path": "/price", "value": 29.99 },
{ "op": "add", "path": "/tags/-", "value": "sale" }
]
Enter fullscreen mode Exit fullscreen mode
A simpler, widely-used alternative is a “merge patch” style — a partial JSON object where only present fields are updated (RFC 7386). Either is fine; pick one and document it consistently.
POST for actions that don’t fit CRUD
Not everything maps neatly to create/read/update/delete. For actions like “publish this article” or “refund this order,” a POST to an action-like sub-resource is an accepted, pragmatic pattern:
POST /orders/{id}/refund
POST /articles/{id}/publish
Enter fullscreen mode Exit fullscreen mode
This is a small, deliberate exception to “nouns not verbs” — it’s better than misusing PATCH to smuggle in behavior that isn’t really a field update.
4. HTTP Status Codes
Status codes are part of the API’s contract — clients should be able to branch on them without parsing the response body.
2xx — Success
Code Meaning When to use200 OK
Success
GET, PUT, PATCH success with a body
201 Created
Resource created
POST that creates a resource — include a Location header
202 Accepted
Accepted for async processing
Long-running operations queued for later
204 No Content
Success, no body
DELETE, or PUT/PATCH with nothing to return
4xx — Client errors
Code Meaning When to use400 Bad Request
Malformed request
Invalid JSON, missing required fields
401 Unauthorized
Missing/invalid credentials
No valid auth token provided
403 Forbidden
Authenticated but not allowed
Valid token, insufficient permissions
404 Not Found
Resource doesn’t exist
Unknown ID, or intentionally hiding existence for security
405 Method Not Allowed
Wrong HTTP method for this route
DELETE on a read-only resource
409 Conflict
State conflict
Duplicate unique key, version mismatch
422 Unprocessable Entity
Semantically invalid
Well-formed JSON that fails business validation
429 Too Many Requests
Rate limit exceeded
Client is being throttled
5xx — Server errors
Code Meaning When to use500 Internal Server Error
Unhandled exception
Generic catch-all — avoid leaking stack traces
502 Bad Gateway
Upstream failure
A dependency/proxy returned an invalid response
503 Service Unavailable
Temporarily down
Maintenance, overload, circuit breaker open
504 Gateway Timeout
Upstream timeout
A dependency took too long to respond
201 Created in practice
app.MapPost("/products", (CreateProductRequest request, IProductRepository repo) =>
{
var product = repo.Add(request);
return Results.Created($"/products/{product.Id}", product);
});
Enter fullscreen mode Exit fullscreen mode
The Location header (/products/{id}) tells the client exactly where to find the resource it just created — a small detail that’s easy to skip but genuinely useful to clients.
5. Request and Response Design
Consistent envelope (or the lack of one)
For a single resource, return the resource directly:
{
"id": 42,
"name": "Wireless Mouse",
"price": 29.99
}
Enter fullscreen mode Exit fullscreen mode
For a collection, avoid returning a bare array at the top level — it makes adding pagination metadata later a breaking change. Wrap it:
{
"data": [
{ "id": 42, "name": "Wireless Mouse", "price": 29.99 },
{ "id": 43, "name": "Mechanical Keyboard", "price": 89.99 }
],
"page": 1,
"pageSize": 20,
"totalCount": 137
}
Enter fullscreen mode Exit fullscreen mode
Naming: camelCase vs. snake_case
Pick one casing convention and apply it consistently across the whole API — mixing productId and product_id in the same payload is a common source of client-side bugs. JSON APIs most commonly use camelCase (matching JavaScript conventions), which is also System.Text.Json‘s default in ASP.NET Core.
Dates and times
Always use ISO 8601 (2026-07-05T14:30:00Z), always in UTC unless there’s a specific reason not to, and always explicit about the timezone offset (Z or +00:00 — never a bare timestamp with no offset).
6. Filtering, Sorting, and Pagination
Filtering via query parameters
GET /products?category=electronics&minPrice=10&maxPrice=100
Enter fullscreen mode Exit fullscreen mode
app.MapGet("/products", (string? category, decimal? minPrice, decimal? maxPrice, IProductRepository repo) =>
{
var query = repo.Query();
if (category is not null) query = query.Where(p => p.Category == category);
if (minPrice is not null) query = query.Where(p => p.Price >= minPrice);
if (maxPrice is not null) query = query.Where(p => p.Price <= maxPrice);
return query.ToList();
});
Enter fullscreen mode Exit fullscreen mode
Sorting
GET /products?sort=price → ascending by price
GET /products?sort=-price → descending by price (leading `-`)
GET /products?sort=category,-price → sort by category, then price descending
Enter fullscreen mode Exit fullscreen mode
Pagination: offset vs. cursor
Offset-based (simple, but can skip/duplicate rows under concurrent writes):
GET /products?page=2&pageSize=20
Enter fullscreen mode Exit fullscreen mode
Cursor-based (stable under concurrent writes, better for large or real-time datasets):
GET /products?after=eyJpZCI6NDJ9&limit=20
Enter fullscreen mode Exit fullscreen mode
{
"data": [ /* ... */ ],
"nextCursor": "eyJpZCI6NjJ9",
"hasMore": true
}
Enter fullscreen mode Exit fullscreen mode
Offset pagination is fine for most admin dashboards and small datasets; cursor pagination is worth the extra complexity for large, frequently-changing, or infinite-scroll-style collections.
7. Versioning
APIs change over time, and clients need a way to keep working against an older contract while you evolve the newer one.
URI versioning (most common, most visible)
GET /v1/products
GET /v2/products
Enter fullscreen mode Exit fullscreen mode
Simple, cache-friendly, and immediately visible in logs — the most widely adopted approach despite being technically “impure” (the URI is supposed to identify a resource, not a version).
Header versioning
GET /products
Accept: application/vnd.myapi.v2+json
Enter fullscreen mode Exit fullscreen mode
Keeps URIs clean and stable, but is harder to test manually (can’t just click a link) and less visible in server logs.
Query parameter versioning
GET /products?api-version=2.0
Enter fullscreen mode Exit fullscreen mode
ASP.NET Core supports all three via the Asp.Versioning package:
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(),
new HeaderApiVersionReader("X-Api-Version"));
});
Enter fullscreen mode Exit fullscreen mode
Practical guidance
- Version from day one, even at
v1— retrofitting versioning onto an unversioned API is painful. - Prefer additive, backward-compatible changes (new optional fields, new endpoints) over bumping the version — reserve version bumps for breaking changes.
- Support old versions for a clearly communicated deprecation window, and surface deprecation via response headers (
Sunset,Deprecation) rather than silently removing them.
8. Error Handling
The Problem Details standard (RFC 9457)
ASP.NET Core has built-in support for ProblemDetails, a standardized JSON error format that gives clients a consistent shape to parse regardless of which endpoint failed:
{
"type": "https://example.com/errors/insufficient-stock",
"title": "Insufficient stock",
"status": 409,
"detail": "Only 3 units of 'Wireless Mouse' are available, but 5 were requested.",
"instance": "/orders",
"traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00"
}
Enter fullscreen mode Exit fullscreen mode
app.MapPost("/orders", (CreateOrderRequest request, IInventoryService inventory) =>
{
if (!inventory.HasStock(request.ProductId, request.Quantity))
{
return Results.Problem(
title: "Insufficient stock",
detail: $"Only {inventory.GetStock(request.ProductId)} units available.",
statusCode: StatusCodes.Status409Conflict);
}
// ... create order
});
Enter fullscreen mode Exit fullscreen mode
Validation errors specifically
{
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Name": ["Name is required."],
"Price": ["Price must be greater than zero."]
}
}
Enter fullscreen mode Exit fullscreen mode
ASP.NET Core produces this shape automatically for [ApiController]-based validation failures, and Results.ValidationProblem(...) produces the same shape from minimal API endpoints.
What NOT to put in error responses
- Stack traces or internal exception messages in production (log them server-side; return a generic message and a
traceIdclients can hand to support). - Different error shapes per endpoint — pick one format (ProblemDetails is a sound default) and use it everywhere.
-
200 OKwith an"error": truefield in the body — this breaks HTTP semantics and forces every client to parse the body just to know if the call succeeded.
9. HATEOAS and the Richardson Maturity Model
Leonard Richardson’s maturity model describes REST adoption as a set of levels:
Level Characteristic Example 0 Single endpoint, POST everything (RPC-over-HTTP)POST /api with an action name in the body
1
Multiple resource URIs, but one HTTP method (usually POST)
POST /getProduct, POST /createOrder
2
Resources + proper HTTP methods and status codes
GET /products/{id}, 201 Created, 404 Not Found
3
+ HATEOAS: responses include links to related actions/resources
See below
Most production “REST APIs” stop at level 2 — and that’s a reasonable, pragmatic choice for the vast majority of use cases. Level 3 (true HATEOAS) means the client discovers available actions from the response itself, rather than hardcoding URL templates:
{
"id": 42,
"status": "pending",
"total": 59.98,
"_links": {
"self": { "href": "/orders/42" },
"cancel": { "href": "/orders/42/cancel", "method": "POST" },
"customer": { "href": "/customers/17" }
}
}
Enter fullscreen mode Exit fullscreen mode
HATEOAS shines in large, evolving public APIs where clients genuinely benefit from discoverability (state transitions differ per resource state — a shipped order has no cancel link, for instance). For most internal or moderately-sized APIs, the added complexity on both client and server outweighs the benefit, which is why level 2 remains the practical industry default.
10. Idempotency and Caching
Idempotency keys for non-idempotent operations
POST isn’t idempotent by HTTP definition, but retries (network blips, client timeouts) are a fact of life. An idempotency key lets a client safely retry a POST without risking a duplicate side effect (e.g., double-charging a payment):
POST /payments
Idempotency-Key: 7c9f8a3e-1b2d-4e5f-9a1b-2c3d4e5f6a7b
Enter fullscreen mode Exit fullscreen mode
app.MapPost("/payments", async (PaymentRequest request, HttpContext ctx, IIdempotencyStore store) =>
{
var key = ctx.Request.Headers["Idempotency-Key"].ToString();
if (await store.TryGetCachedResponse(key) is { } cached)
return Results.Content(cached, "application/json");
var result = await ProcessPayment(request);
await store.SaveResponse(key, result);
return Results.Ok(result);
});
Enter fullscreen mode Exit fullscreen mode
Caching headers
app.MapGet("/products/{id}", (int id, IProductRepository repo) =>
{
var product = repo.GetById(id);
return Results.Ok(product);
})
.CacheOutput(policy => policy.Expire(TimeSpan.FromMinutes(5)));
Enter fullscreen mode Exit fullscreen mode
-
ETag— a hash/version identifier for a resource; clients send it back viaIf-None-Matchto get a cheap304 Not Modifiedif nothing changed. -
Cache-Control—max-age,no-store,private/publiccontrol how long and where a response can be cached. -
Last-Modified/If-Modified-Since— a simpler, time-based alternative to ETags.
11. Security Basics
- Always use HTTPS — never accept credentials or tokens over plain HTTP.
- Authenticate with bearer tokens (OAuth2/JWT), not API keys in query strings (query strings end up in logs, browser history, and referrer headers).
- Authorize per-resource, not just per-endpoint — verify the authenticated user actually owns/can access the specific resource ID in the URL, not just that they’re logged in (a classic IDOR — insecure direct object reference — vulnerability is checking auth at the endpoint level but not the record level).
- Rate limit — protect against abuse and accidental retry storms (see the ASP.NET Core rate limiting middleware).
- Validate and sanitize all input — never trust query parameters, headers, or body content, even from authenticated clients.
-
Don’t leak information via status codes — for genuinely private resources, consider
404instead of403when you don’t want to confirm a resource’s existence to unauthorized callers.
12. Common Anti-Patterns
Anti-pattern Why it’s a problem Better approach Verbs in URLs (/getProducts)
Redundant with HTTP method, RPC-flavored
GET /products
Returning 200 for errors
Breaks HTTP semantics, forces body parsing
Use proper 4xx/5xx status codes
Deeply nested URLs (/a/{id}/b/{id}/c/{id}/d/{id})
Hard to read, brittle, awkward for partial access
Flatten where the child can stand alone; use query filters instead
Bare arrays as top-level response
Breaks when you need to add pagination metadata
Wrap in an object: { "data": [...] }
Inconsistent casing/naming
Increases client integration bugs
Pick one convention (usually camelCase) and enforce it
No versioning strategy
Any change risks breaking existing clients
Version from day one, prefer additive changes
Chatty APIs (many round-trips for one screen)
Latency, especially on mobile
Support field selection (?fields=) or a light aggregation/BFF layer
Leaking stack traces in errors
Security risk, unhelpful to clients
Use ProblemDetails with a generic message + trace ID
Quick Reference Table
Concept Guidance URI design Plural nouns, no verbs, lowercase-hyphenatedPOST
Create resources; 201 Created + Location header
PUT
Full replace; idempotent
PATCH
Partial update; JSON Patch or merge-patch body
GET/DELETE
Safe/idempotent; no unexpected side effects
Collections
Wrap in an object with pagination metadata, never a bare array
Errors
Use ProblemDetails (RFC 9457) consistently across all endpoints
Versioning
Version from day one; URI versioning is the most common default
Pagination
Offset for small/simple data; cursor for large/real-time data
Idempotency
Use idempotency keys for retry-safe POST operations
HATEOAS
Optional; valuable for large public APIs, often skipped for internal ones
Conclusion
Good REST API design isn’t about chasing Fielding’s dissertation to the letter — it’s about making an HTTP API predictable: resources are nouns, HTTP methods carry the verbs, status codes tell the truth about what happened, and errors follow one consistent shape. Get those fundamentals right — plus sane pagination, a versioning strategy from day one, and proper use of caching/idempotency — and you’ll have covered the vast majority of what makes an API pleasant to integrate against, regardless of whether you ever reach HATEOAS-level “true” REST.
The best test of an API’s design is usually a simple one: could a new developer, given just the URL list and a bit of curiosity, guess correctly how most of it behaves? If yes, the fundamentals in this guide have probably been followed.
Found this useful? Feel free to star the repo, open an issue with corrections, or share the REST design decision you keep having to defend to your team.
답글 남기기