Learnixo

.NET & C# Development · Lesson 54 of 229

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.