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?
Real-world analogy
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
- Resources are nouns — Use /products/42, not /getProduct?id=42. The URL names the thing, and the HTTP verb says what to do with it.
- Verbs and idempotency — GET, PUT and DELETE are idempotent: repeating them gives the same end state. POST is not, so two identical posts can create two rows.
- PUT versus PATCH — PUT replaces the whole resource. PATCH changes only the fields you send. Many teams use PUT for simple updates and skip PATCH.
- Status codes that matter — 200 OK, 201 Created with a Location header, and 204 No Content are the success codes. 400 bad input, 401 not signed in, 403 not allowed, 404 missing, 409 conflict and 422 invalid are client errors. 500 is our bug.
- DTOs, not entities — Send small purpose-built classes over the wire. Returning an EF entity can leak fields like a password hash and can break on circular references.
- Paging, filtering, sorting — Put them in the query string: ?page=2&pageSize=20&sort=price. Cap pageSize on the server, or one caller can ask for a million rows.
- Versioning — Plan for change early. Put the version in the URL (/v2/products) or a header, so old clients keep working while new ones move on.
- ApiController helps you — [ApiController] binds JSON bodies, returns 400 automatically when validation fails, and gives ProblemDetails error bodies. Minimal APIs map routes straight to lambdas, with no controller class and fewer built-in conventions.
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. [ApiController] and [Route] make the class an API controller at api/products. The constructor receives the DbContext from DI.
- 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. The query orders, skips, takes and projects straight into ProductDto, so only three columns are read. ToListAsync runs it.
- 4. Get looks up one row. If it is missing the action returns NotFound, which is a 404.
- 5. Create takes a request DTO, saves a new Product and calls SaveChangesAsync.
- 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?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Create web APIs with ASP.NET Core (Microsoft Learn)
- Tutorial: Create a controller-based web API (Microsoft Learn)
- HTTP response status codes (MDN)
Lock it in