Practical rules for designing an API people enjoy using, covering resource naming, nesting, actions that are not CRUD, idempotency, dates, nulls and consistency.

Bishal Biswas
Self Learner
Name resources, not actions
The URL identifies a thing. The HTTP method says what you are doing to it.
Bad Good
GET /api/getAllBooks GET /api/books
POST /api/createBook POST /api/books
POST /api/updateBook/5 PUT /api/books/5
POST /api/deleteBook/5 DELETE /api/books/5
GET /api/getBooksByAuthor/3 GET /api/books?authorId=3
The good column has four URLs for four operations, and you could have guessed all of them.
Plural nouns, lowercase, hyphens
/api/books plural, even for a single item below it
/api/books/5
/api/purchase-orders hyphens, not camelCase or underscores
Three conventions worth following simply because they are conventions:
Plural. /api/books/5 reads as “book 5 in the books collection”. Mixing /api/book/5 and /api/books means clients have to remember which is which.
Lowercase. Paths are case sensitive in the spec. Lowercase removes the question.
Hyphens. /api/purchase-orders is more readable than /api/purchaseorders, and search engines treat hyphens as word separators.
Nest only one level
Nesting shows ownership:
GET /api/books/5/reviews reviews of book 5
POST /api/books/5/reviews add a review to book 5
Beyond one level it gets unusable:
GET /api/authors/3/books/5/reviews/9/comments/2 too much
Once you have the review id, you do not need the path to it. Address it directly:
GET /api/reviews/9/comments/2
Rule of thumb: nest to show a relationship on the collection, then let each resource have its own top level URL.
Filter with the query string, not the path
Bad Good
/api/books/year/2008 /api/books?year=2008
/api/books/author/3/year/2008 /api/books?authorId=3&year=2008
/api/books/sorted-by-price /api/books?sort=price
The path identifies a resource. The query string narrows a collection. Keeping that split means one endpoint handles every combination of filters instead of one endpoint per combination.
Actions that are not CRUD
Not everything is create, read, update or delete. Publishing an article, cancelling an order, resetting a password.
Three legitimate approaches.
Model the state as a field and PATCH it. The most RESTful:
PATCH /api/articles/5
{ "status": "published" }
Treat the action as a sub resource and POST to it. The most practical, and very common:
POST /api/articles/5/publish
POST /api/orders/9/cancel
POST /api/users/3/password-reset
Create a resource representing the action, when the action itself has a lifecycle:
POST /api/refunds
{ "orderId": 9, "amount": 25.00, "reason": "damaged" }
The second is what most APIs do, and purists dislike it. It is fine. Consistency across your API matters more than winning the argument.
Be consistent above all
One inconsistency costs more than any single design flaw, because it forces clients to special case.
Pick one answer to each of these and never deviate:
- Are collections wrapped in an envelope or returned as a bare array?
- Is paging in the body or in headers?
- Do you use
404or403for a record the caller may not see? - Are dates always UTC ISO 8601?
- Is it
createdAtorcreated_atorCreatedAt? - Do errors always use problem details?
- Do you return the created object on
POST, or just aLocationheader?
Write the answers down. A short conventions document is worth more than a long style guide.
Dates and times
Always UTC, always ISO 8601:
{ "createdAt": "2026-09-07T16:40:00Z" }
The Z matters. Without it, a client cannot know the timezone and will guess wrong.
Never send a local time and never send a format like 07/09/2026, which means two different days depending on where the reader lives.
Use DateOnly when there is no time component:
{ "publishedOn": "2008-08-01" }
Accept UTC on input too, and convert to local time only for display, in the client.
Nulls and empty collections
Return an empty array, not null, for a collection with no items:
{ "tags": [] } // good, the client can loop
{ "tags": null } // bad, the client must null check first
Be consistent about omitted fields. Either always send a null field or never send it. Switching between the two means clients cannot rely on either:
options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
An empty search result is 200 with [], not 404. The collection exists and contains nothing matching.
Status codes that tell the truth
Covered in detail earlier, and worth repeating as design advice:
200for a successful read or an update that returns the object201plus aLocationheader for a create204for a delete or a silent update400for malformed input,422for input that parses but breaks a rule401for no or bad credentials,403for valid credentials without permission404for a resource that does not exist409for a duplicate or a state conflict429for a rate limit, withRetry-After
Never return 200 with {"success": false}. Clients then have to parse the body to know whether it worked, and every HTTP tool, cache and monitor is misled.
Idempotency for creates
PUT and DELETE are naturally idempotent. POST is not, and a flaky network means a client will retry one.
Let the client supply a key:
POST /api/orders
Idempotency-Key: 8f14e45f-ceea-467a-9c1e-4e2f6b2d0c3a
{ "items": [ ... ] }
Store the key with the created resource. If the same key arrives again, return the original result rather than creating a second order:
Order? existing = await db.Orders
.FirstOrDefaultAsync(o => o.IdempotencyKey == key, ct);
if (existing is not null)
return Ok(ToResponse(existing)); // same answer, no duplicate
Any endpoint that costs money should have this.
Return the created resource
After a POST, send back the object with its server assigned fields filled in:
HTTP/1.1 201 Created
Location: /api/books/7
{ "id": 7, "title": "Refactoring", "createdAt": "2026-09-07T16:40:00Z" }
The client needs the id, and it saves an immediate GET.
Support partial responses when it helps
For a large resource with mobile clients, letting them ask for less is a real win:
GET /api/books/5?fields=id,title,price
Do not build this speculatively. Add it when a client actually needs it, and validate the field list against an allow list.
Document the contract, not the implementation
Your documentation should describe what a client can rely on. Not the database, not the framework.
Say: “Returns up to 100 books. Use page and pageSize. Sortable by title, year or price.”
Do not say: “Queries the Books table using EF Core with AsNoTracking.”
Design for the client
The most useful test. Before building an endpoint, write the client code that would call it:
const books = await api.books.search({ author: 'martin', year: 2008, page: 1 });
If that reads naturally, the design is good. If you find yourself writing three calls and stitching results together, the API is shaped for your database rather than for the people using it.
A related habit: when a screen always needs a book plus its author plus its review count, consider returning them together rather than making the client fetch three times. Purity is worth less than a fast screen.
Quick recap
- URLs name resources. HTTP methods are the verbs.
- Use plural, lowercase, hyphenated nouns.
- Nest one level to show ownership, then give each resource a top level URL.
- Filters go in the query string, never in the path.
- For actions that are not CRUD,
PATCHa state field orPOSTto a sub resource. Be consistent. - Consistency beats every individual design choice. Write your conventions down.
- Dates are UTC ISO 8601 with a
Z. UseDateOnlywhen there is no time. - Return
[]rather than null, and200with[]rather than404for an empty search. - Never return
200with a success flag of false. - Support an
Idempotency-Keyon anyPOSTthat costs money. - Return the created object with its
Locationheader. - Write the client call first. If it reads awkwardly, redesign the endpoint.
// A resource designed by these rules, with the reasoning kept in the comments.
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers().AddJsonOptions(o =>
{
o.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
// Pick one and never change it: either always send nulls or never send them
o.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
var app = builder.Build();
app.MapControllers();
app.Run();
// /api/purchase-orders : plural, lowercase, hyphenated
[ApiController]
[Route("api/purchase-orders")]
[Produces("application/json")]
public class PurchaseOrdersController : ControllerBase
{
private readonly AppDbContext db;
public PurchaseOrdersController(AppDbContext db) => this.db = db;
// Filters live in the QUERY STRING, so one endpoint serves every combination.
// Not /api/purchase-orders/status/pending/supplier/3
[HttpGet]
public async Task<ActionResult<PagedResult<OrderSummary>>> Search(
[FromQuery] OrderStatus? status, [FromQuery] int? supplierId,
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default)
{
pageSize = Math.Clamp(pageSize, 1, 100);
IQueryable<PurchaseOrder> query = db.PurchaseOrders.AsNoTracking();
if (status is not null) query = query.Where(o => o.Status == status);
if (supplierId is not null) query = query.Where(o => o.SupplierId == supplierId);
int total = await query.CountAsync(ct);
List<OrderSummary> items = await query
.OrderByDescending(o => o.PlacedAt).ThenBy(o => o.Id)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(o => new OrderSummary(o.Id, o.Reference, o.Status, o.Total, o.PlacedAt))
.ToListAsync(ct);
// No matches is 200 with an empty array. The collection exists; it is just empty.
return new PagedResult<OrderSummary>(items, page, pageSize, total);
}
[HttpGet("{id:int}", Name = nameof(GetById))]
public async Task<ActionResult<OrderDetail>> GetById(int id, CancellationToken ct)
{
OrderDetail? order = await db.PurchaseOrders
.Where(o => o.Id == id)
.Select(o => new OrderDetail(
o.Id, o.Reference, o.Status, o.Total, o.PlacedAt,
// An empty array, never null. The client can loop without a null check.
o.Lines.Select(l => new LineItem(l.Sku, l.Quantity, l.UnitPrice)).ToList()))
.FirstOrDefaultAsync(ct);
return order is null ? NotFound() : order;
}
// One level of nesting shows ownership. Deeper than this and you should
// address the child directly at /api/order-lines/{id}.
[HttpGet("{id:int}/lines")]
public async Task<ActionResult<List<LineItem>>> GetLines(int id, CancellationToken ct) =>
await db.OrderLines.Where(l => l.PurchaseOrderId == id)
.Select(l => new LineItem(l.Sku, l.Quantity, l.UnitPrice))
.ToListAsync(ct);
// POST with an Idempotency-Key. A retried request after a dropped connection
// returns the ORIGINAL order instead of creating a second one.
// Any endpoint that costs money should do this.
[HttpPost]
public async Task<ActionResult<OrderDetail>> Create(
[FromBody] CreateOrderRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
{
if (!string.IsNullOrWhiteSpace(idempotencyKey))
{
PurchaseOrder? existing = await db.PurchaseOrders
.FirstOrDefaultAsync(o => o.IdempotencyKey == idempotencyKey, ct);
if (existing is not null)
return Ok(ToDetail(existing)); // same answer, no duplicate
}
var order = new PurchaseOrder
{
Reference = $"PO-{DateTime.UtcNow:yyyyMMdd}-{Random.Shared.Next(1000, 9999)}",
SupplierId = request.SupplierId,
Status = OrderStatus.Draft,
PlacedAt = DateTime.UtcNow, // UTC, always
IdempotencyKey = idempotencyKey,
Lines = request.Lines
.Select(l => new OrderLine { Sku = l.Sku, Quantity = l.Quantity, UnitPrice = l.UnitPrice })
.ToList()
};
order.Total = order.Lines.Sum(l => l.Quantity * l.UnitPrice);
db.PurchaseOrders.Add(order);
await db.SaveChangesAsync(ct);
// 201, a Location header, and the created object so the client has the id
return CreatedAtRoute(nameof(GetById), new { id = order.Id }, ToDetail(order));
}
// An action that is not CRUD, option A: PATCH the state field. The most RESTful.
[HttpPatch("{id:int}")]
public async Task<IActionResult> Patch(int id, [FromBody] PatchOrderRequest patch, CancellationToken ct)
{
PurchaseOrder? order = await db.PurchaseOrders.FindAsync(new object[] { id }, ct);
if (order is null) return NotFound();
if (patch.Status is not null) order.Status = patch.Status.Value; // null means leave alone
await db.SaveChangesAsync(ct);
return NoContent();
}
// Option B: POST to a sub resource. Less pure, more practical, very common.
// Pick one style and use it everywhere.
[HttpPost("{id:int}/cancel")]
public async Task<IActionResult> Cancel(int id, [FromBody] CancelRequest request, CancellationToken ct)
{
PurchaseOrder? order = await db.PurchaseOrders.FindAsync(new object[] { id }, ct);
if (order is null) return NotFound();
if (order.Status == OrderStatus.Shipped)
// 409, not 400: the request is valid, the current STATE forbids it
return Conflict(new ProblemDetails
{
Type = "https://api.example.com/errors/order-already-shipped",
Title = "Order already shipped",
Status = StatusCodes.Status409Conflict,
Detail = $"Order {order.Reference} shipped on {order.ShippedOn:yyyy-MM-dd} and cannot be cancelled."
});
order.Status = OrderStatus.Cancelled;
await db.SaveChangesAsync(ct);
return NoContent();
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id, CancellationToken ct)
{
PurchaseOrder? order = await db.PurchaseOrders.FindAsync(new object[] { id }, ct);
if (order is null) return NotFound();
db.PurchaseOrders.Remove(order);
await db.SaveChangesAsync(ct);
return NoContent();
}
private static OrderDetail ToDetail(PurchaseOrder o) => new(
o.Id, o.Reference, o.Status, o.Total, o.PlacedAt,
o.Lines.Select(l => new LineItem(l.Sku, l.Quantity, l.UnitPrice)).ToList());
}
public enum OrderStatus { Draft, Submitted, Shipped, Cancelled }
// Dates are UTC DateTime and serialize as "2026-09-07T16:40:00Z".
// A date with no time uses DateOnly and serializes as "2026-09-07".
public record OrderSummary(int Id, string Reference, OrderStatus Status, decimal Total, DateTime PlacedAt);
public record OrderDetail(int Id, string Reference, OrderStatus Status, decimal Total,
DateTime PlacedAt, List<LineItem> Lines);
public record LineItem(string Sku, int Quantity, decimal UnitPrice);
public record CreateOrderRequest(int SupplierId, List<LineItem> Lines);
public record PatchOrderRequest(OrderStatus? Status);
public record CancelRequest(string? Reason);
public record PagedResult<T>(IReadOnlyList<T> Items, int Page, int PageSize, int TotalCount);
public class PurchaseOrder
{
public int Id { get; set; }
public string Reference { get; set; } = "";
public int SupplierId { get; set; }
public OrderStatus Status { get; set; }
public decimal Total { get; set; }
public DateTime PlacedAt { get; set; }
public DateOnly? ShippedOn { get; set; }
public string? IdempotencyKey { get; set; }
public List<OrderLine> Lines { get; set; } = new();
}
public class OrderLine
{
public int Id { get; set; }
public int PurchaseOrderId { get; set; }
public string Sku { get; set; } = "";
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
}
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> o) : base(o) { }
public DbSet<PurchaseOrder> PurchaseOrders => Set<PurchaseOrder>();
public DbSet<OrderLine> OrderLines => Set<OrderLine>();
protected override void OnModelCreating(ModelBuilder b) =>
b.Entity<PurchaseOrder>().HasIndex(o => o.IdempotencyKey).IsUnique();
}
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready