REST API Engineering · Lesson 5 of 19
REST Rules Senior Devs Follow (That Juniors Don't)
Junior devs make REST APIs that work. Senior devs make REST APIs that don't need to be rewritten. The difference is a handful of rules — most of which aren't in the official docs.
This lesson covers the rules that matter most, with ASP.NET Core examples throughout.
Rule 1: Routes Are Nouns, Not Verbs
The most common junior mistake: putting actions in the URL.
❌ GET /api/getOrders
❌ POST /api/createOrder
❌ POST /api/deleteOrder/5
❌ GET /api/order/fetchById?id=5
✅ GET /api/orders
✅ POST /api/orders
✅ DELETE /api/orders/5
✅ GET /api/orders/5The HTTP method is the verb. The URL is the resource. Mixing them produces URLs like /api/doTheThing that nobody can read at 2am.
In ASP.NET Core:
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
[HttpGet] // GET /api/orders
[HttpGet("{id}")] // GET /api/orders/5
[HttpPost] // POST /api/orders
[HttpPut("{id}")] // PUT /api/orders/5
[HttpDelete("{id}")] // DELETE /api/orders/5
}Nested resources — use nesting only one level deep:
✅ GET /api/orders/5/items → items belonging to order 5
✅ GET /api/orders/5/items/12 → item 12 of order 5
❌ GET /api/orders/5/items/12/tags/7/categories → too deep, just use query paramsRule 2: Use the Right HTTP Method
| Method | When to use | Idempotent? |
|--------|------------|-------------|
| GET | Read resource(s) — never changes state | Yes |
| POST | Create a new resource | No |
| PUT | Replace entire resource | Yes |
| PATCH | Partial update | No (usually) |
| DELETE | Remove resource | Yes |
PUT vs PATCH is where most juniors go wrong:
// PUT — replace the whole order (client sends all fields)
[HttpPut("{id}")]
public async Task<IActionResult> Replace(int id, ReplaceOrderDto dto, CancellationToken ct)
{
// dto must contain every field — missing fields = null/default
}
// PATCH — update only what the client sends
[HttpPatch("{id}")]
public async Task<IActionResult> Update(int id, JsonPatchDocument<UpdateOrderDto> patch, CancellationToken ct)
{
var order = await _db.Orders.FindAsync(id, ct) ?? return NotFound();
var dto = mapper.Map<UpdateOrderDto>(order);
patch.ApplyTo(dto, ModelState);
if (!ModelState.IsValid) return ValidationProblem(ModelState);
mapper.Map(dto, order);
await _db.SaveChangesAsync(ct);
return NoContent();
}If you're building an API where clients always send the full object — use PUT. If clients send only changed fields — use PATCH.
Rule 3: Stop Returning 200 for Everything
// ❌ Junior: 200 for everything
[HttpPost]
public IActionResult Create(CreateOrderDto dto)
{
var order = service.Create(dto);
return Ok(order); // 200 — wrong for creation
}
// ✅ Senior: correct status codes
[HttpPost]
public async Task<IActionResult> Create(CreateOrderDto dto, CancellationToken ct)
{
var order = await _orderService.CreateAsync(dto, ct);
return CreatedAtAction(nameof(GetById), new { id = order.Id }, order); // 201 + Location header
}The status codes you actually need:
| Code | Meaning | When |
|------|---------|------|
| 200 OK | Success with body | GET, PUT, PATCH |
| 201 Created | Resource created | POST (return the new resource + Location header) |
| 204 No Content | Success, no body | DELETE, PUT when not returning resource |
| 400 Bad Request | Invalid input | Validation failures |
| 401 Unauthorized | Not authenticated | Missing/invalid token |
| 403 Forbidden | Authenticated but not allowed | Wrong role/permission |
| 404 Not Found | Resource doesn't exist | — |
| 409 Conflict | State conflict | Duplicate, concurrency violation |
| 422 Unprocessable Entity | Business rule violation | Valid input but invalid state |
| 500 Internal Server Error | Unexpected server fault | — |
The most important distinction: 400 = input is malformed. 422 = input is valid but the business rule says no (e.g., "you can't cancel an order that's already shipped").
Rule 4: Use ProblemDetails for Every Error
Don't invent your own error format. RFC 9457 (ProblemDetails) is the standard — and ASP.NET Core supports it natively.
// Program.cs
builder.Services.AddProblemDetails();
// Global exception handler
app.UseExceptionHandler();
// Return ProblemDetails-shaped validation errors automatically
builder.Services.Configure<ApiBehaviorOptions>(options =>
options.InvalidModelStateResponseFactory = ctx =>
{
var problem = ctx.HttpContext.RequestServices
.GetRequiredService<ProblemDetailsFactory>()
.CreateValidationProblemDetails(ctx.HttpContext, ctx.ModelState, 400);
return new BadRequestObjectResult(problem);
});What a client sees instead of a raw exception dump:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
"title": "Not Found",
"status": 404,
"detail": "Order 42 was not found.",
"instance": "/api/orders/42",
"traceId": "00-abc123..."
}Consistent. Parseable. Debuggable. All without any custom error wrapper.
Rule 5: Version From Day One
Adding versioning after you have clients is painful. Adding it before costs nothing.
dotnet add package Asp.Versioning.Mvc
dotnet add package Asp.Versioning.Mvc.ApiExplorer// Program.cs
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true; // adds api-supported-versions header
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(), // /api/v1/orders
new HeaderApiVersionReader("X-Api-Version") // header fallback
);
});[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/orders")]
public class OrdersV1Controller : ControllerBase { }
[ApiController]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/orders")]
public class OrdersV2Controller : ControllerBase { }The rule: never remove or rename fields on an existing version. Add a new version. Old clients keep working.
Rule 6: Paginate Everything That Can Return More Than One Row
Never return unbounded lists. A table that has 50 rows today will have 50,000 rows in a year.
// Standard query params: ?page=1&pageSize=20&sortBy=createdAt&sortDir=desc&search=foo
public record PagedQuery(
int Page = 1,
int PageSize = 20,
string? SortBy = null,
string SortDir = "asc",
string? Search = null
);
public record PagedResult<T>(
IReadOnlyList<T> Items,
int Page,
int PageSize,
int TotalCount,
int TotalPages
);[HttpGet]
public async Task<ActionResult<PagedResult<OrderSummaryDto>>> GetAll(
[FromQuery] PagedQuery query, CancellationToken ct)
{
var q = _db.Orders.AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Search))
q = q.Where(o => o.Reference.Contains(query.Search));
var total = await q.CountAsync(ct);
var items = await q
.OrderBy(query.SortBy ?? "createdAt", query.SortDir)
.Skip((query.Page - 1) * query.PageSize)
.Take(Math.Min(query.PageSize, 100)) // hard cap at 100
.Select(o => new OrderSummaryDto(o.Id, o.Reference, o.Status, o.TotalAmount))
.ToListAsync(ct);
return Ok(new PagedResult<OrderSummaryDto>(
items, query.Page, query.PageSize, total,
(int)Math.Ceiling((double)total / query.PageSize)
));
}The hard cap on pageSize is important. Never let a client request 10,000 rows in a single call.
Rule 7: Never Expose Auto-Increment IDs in Public Routes
❌ GET /api/orders/1 → tells competitors you've had exactly 1 order
❌ GET /api/users/42 → tells bots exactly how many users you have
✅ GET /api/orders/b3f1a2c4-... (GUID)
✅ GET /api/orders/ORD-2026-X7K2 (opaque reference)Use GUIDs or opaque references for all public-facing IDs. Keep auto-increment PKs internal to the database.
public class Order
{
public int Id { get; set; } // internal PK — never exposed
public Guid PublicId { get; set; } = Guid.NewGuid(); // exposed in API
public string Reference { get; set; } = default!; // human-readable
}
// Route uses PublicId, not Id
[HttpGet("{publicId:guid}")]
public async Task<ActionResult<OrderDetailDto>> GetById(Guid publicId, CancellationToken ct)
{
var order = await _db.Orders
.AsNoTracking()
.FirstOrDefaultAsync(o => o.PublicId == publicId, ct)
?? return NotFound();
// ...
}Rule 8: Be Strict on Input, Permissive on Output
Validate everything coming in. Never validate outgoing data.
// Use FluentValidation — not Data Annotations in a serious API
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(x => x.CustomerId).GreaterThan(0);
RuleFor(x => x.DeliveryAddress).NotEmpty().MaximumLength(500);
RuleFor(x => x.Items).NotEmpty().WithMessage("At least one item required.");
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(i => i.ProductId).GreaterThan(0);
item.RuleFor(i => i.Quantity).InclusiveBetween(1, 1000);
item.RuleFor(i => i.UnitPrice).GreaterThan(0);
});
}
}On output, strip internal fields from DTOs but don't add extra validation. Trust your own system.
Rule 9: Always Return the Resource After Creation
// ❌ Forces the client to make a second request to see what was created
[HttpPost]
public async Task<IActionResult> Create(CreateOrderDto dto)
{
var id = await _service.CreateAsync(dto);
return CreatedAtAction(nameof(GetById), new { id }, null); // no body
}
// ✅ Client gets the resource immediately — includes server-assigned fields
[HttpPost]
public async Task<IActionResult> Create(CreateOrderDto dto, CancellationToken ct)
{
var order = await _service.CreateAsync(dto, ct);
return CreatedAtAction(
nameof(GetById),
new { id = order.PublicId },
order // return the full created resource
);
}The CreatedAtAction sets the Location header pointing to the new resource. Clients that need to follow up can use it; clients that just want the data already have it.
Rule 10: Design for Backward Compatibility
The one rule that affects every other rule:
✅ You can ADD new fields to a response — clients ignore what they don't know
✅ You can ADD new optional request fields — existing clients omit them
✅ You can ADD new endpoints
❌ Never REMOVE or RENAME a response field on an existing version
❌ Never make an optional request field REQUIRED on an existing version
❌ Never change the meaning of an existing fieldIf you need to break any of these rules — increment the API version. Everything else is just a backward-compatible change that doesn't require a new version.
Quick Reference
GET /api/orders → list (paginated)
GET /api/orders/{id} → single
POST /api/orders → create → 201 + Location
PUT /api/orders/{id} → full replace → 200 or 204
PATCH /api/orders/{id} → partial update → 200 or 204
DELETE /api/orders/{id} → remove → 204
Errors → ProblemDetails (RFC 9457), never raw exceptions
IDs → GUIDs or opaque refs in URLs, never auto-increment PKs
Pages → ?page=1&pageSize=20, hard cap on pageSize
Versions → /api/v1/... from day oneThese rules won't make your API perfect. They'll make it one that your team — and your clients — can work with for years.