Lesson 11 of 45 intermediate

RESTful API Design

Nouns in the URL, verbs in HTTP, and a status code that tells the truth

Guess first

Pick an answer. You will see if you were right at the end of the lesson.

A client sends the same DELETE /products/5 twice. What does idempotent mean here?

Open interactive version (quiz + challenge)

Real-world analogy

Think of a library. The shelf is the resource, and you do not invent new desks for each action. You ask the one desk to fetch, add, replace or remove a book. The librarian answers with a slip: found, created, not found, not allowed. Here the desk is the URL, the four requests are HTTP verbs, and the slip is the status code.

What is it?

REST is a style for HTTP APIs where each URL names a resource and standard verbs act on it. The server stays stateless: each request carries what it needs. Status codes and headers carry the outcome, so clients and tools can react without reading your docs. Good design means predictable URLs, correct codes, and a response shape that you control separately from your database model.

Real-world relevance

The mistakes seen in code review are small but costly. An endpoint returns 200 with an error message in the body, so monitoring shows green. A list endpoint has no paging and times out when the table grows. A controller returns the database entity, and a new column such as an internal note quietly becomes public. Good habits here save hours of support later.

Key points

Quick check

Answer from memory — scrolling up first makes it stick less.

Which response is best after POST creates a new product?

Why return a ProductDto instead of the Product entity?

Code example

[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
    private readonly AppDbContext _db;
    public ProductsController(AppDbContext db) => _db = db;
    [HttpGet]   // GET api/products?page=2&pageSize=20
    public async Task<ActionResult<List<ProductDto>>> List(
        int page = 1, int pageSize = 20, CancellationToken ct = default)
    {
        page = Math.Max(page, 1); pageSize = Math.Clamp(pageSize, 1, 100);
        var items = await _db.Products.OrderBy(p => p.Id)
            .Skip((page - 1) * pageSize).Take(pageSize)
            .Select(p => new ProductDto(p.Id, p.Name, p.Price))
            .ToListAsync(ct);
        return Ok(items);
    }
    [HttpGet("{id:int}")]
    public async Task<ActionResult<ProductDto>> Get(int id, CancellationToken ct)
    {
        var p = await _db.Products.FindAsync(new object[] { id }, ct);
        return p is null ? NotFound() : new ProductDto(p.Id, p.Name, p.Price);
    }
    [HttpPost]
    public async Task<ActionResult<ProductDto>> Create(CreateProductRequest req, CancellationToken ct)
    {
        var p = new Product { Name = req.Name, Price = req.Price };
        _db.Products.Add(p);
        await _db.SaveChangesAsync(ct);
        return CreatedAtAction(nameof(Get), new { id = p.Id }, new ProductDto(p.Id, p.Name, p.Price));
    }
}
public record ProductDto(int Id, string Name, decimal Price);
public record CreateProductRequest(string Name, decimal Price);

Line-by-line walkthrough

  1. 1. [ApiController] and [Route] make the class an API controller at api/products. The constructor receives the DbContext from DI.
  2. 2. List reads page and pageSize from the query string. Math.Max blocks page zero, and Math.Clamp stops callers asking for more than 100 rows.
  3. 3. The query orders, skips, takes and projects straight into ProductDto, so only three columns are read. ToListAsync runs it.
  4. 4. Get looks up one row. If it is missing the action returns NotFound, which is a 404.
  5. 5. Create takes a request DTO, saves a new Product and calls SaveChangesAsync.
  6. 6. CreatedAtAction returns 201 and a Location header that points to the Get route for the new id.

Spot the bug

public class User { public int Id { get; set; } public string Email { get; set; } = ""; public string PasswordHash { get; set; } = ""; public List<Order> Orders { get; set; } = new(); }
public class Order { public int Id { get; set; } public User User { get; set; } = null!; }

[HttpGet("{id:int}")]
public async Task<ActionResult<User>> Get(int id)
{
    var user = await _db.Users.Include(u => u.Orders).FirstOrDefaultAsync(u => u.Id == id);
    if (user is null) return NotFound();
    return user;
}
Need a hint?
Look at what type leaves the controller and which fields it carries.
Show answer
The action returns the EF entity itself. It exposes PasswordHash to every caller, and the User and Orders navigation properties point at each other, which can cause a JSON cycle error. Fix: return a UserDto record with only Id and Email, built with Select or a mapping.

Explain like I'm 5

A website is like a toy shop with one counter per kind of toy. You can ask the counter to show a toy, add a toy, swap a toy or remove one. The shopkeeper answers with a clear word: here it is, made it, cannot find it.

Fun fact

The 418 status code "I am a teapot" started as an April Fools joke in 1998. It is real in the RFC, but no serious API should use it.

Hands-on challenge

Say it out loud in 30 seconds: "How do you design a clean REST API for a Products resource?" Then check: did you mention nouns in URLs, verbs and status codes like 201 and 404, DTOs instead of entities, and paging?

More resources

Open interactive version (quiz + challenge) Download the whole course as PDF / Word

Lock it in

← Back to course: .NET + React Playbook