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/5

The 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:

C#
[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 params

Rule 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:

C#
// 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

C#
// ❌ 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.

C#
// 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:

JSON
{
  "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.

Bash
dotnet add package Asp.Versioning.Mvc
dotnet add package Asp.Versioning.Mvc.ApiExplorer
C#
// 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
    );
});
C#
[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.

C#
// 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
);
C#
[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.

C#
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.

C#
// 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

C#
// ❌ 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 field

If 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 one

These rules won't make your API perfect. They'll make it one that your team — and your clients — can work with for years.