REST API Design
Before you start
You need: model binding and validation (Article 07).
Time: about 50 minutes, plus the practice.
Learning objective
Design an API whose URLs, methods and status codes a client can predict without reading documentation.
Topics
- Resources and URL design
- HTTP methods and their guarantees
- Status codes per outcome
- Request and response DTOs
- Paging, filtering and sorting
- Partial updates
- Versioning
- Consistency
Resources
A REST API exposes resources, named by nouns, acted on by HTTP methods.
GET /api/students list
POST /api/students create
GET /api/students/{publicId} read one
PUT /api/students/{publicId} replace
PATCH /api/students/{publicId} partially update
DELETE /api/students/{publicId} remove
GET /api/students/{publicId}/results a sub-resource
POST /api/exams/{examId}/results create within a parent
# Not REST — the verb is in the URL
POST /api/getStudents
POST /api/createStudent
POST /api/deleteStudentById
Plural nouns, no verbs. The method is the verb. Consistency matters more than any individual choice — an API mixing /api/student and /api/teachers costs every client a lookup.
Nest a sub-resource only one level deep. /api/schools/1/classes/10/sections/A/students/42 is a URL nobody can construct correctly; use a filter instead:
GET /api/students?className=10th§ion=A
Identify resources by PublicId, not the integer key. Sequential ids let anyone enumerate records by incrementing. A GUID removes the guessability, and the tenant filter in the query provides the actual protection.
HTTP methods
| Method | Purpose | Safe | Idempotent | Body |
|---|---|---|---|---|
GET | Read | Yes | Yes | No |
POST | Create, or an action | No | No | Yes |
PUT | Replace | No | Yes | Yes |
PATCH | Partially update | No | No | Yes |
DELETE | Remove | No | Yes | Rarely |
Safe means it changes nothing. Idempotent means repeating it has the same effect as doing it once.
Both properties are relied on by infrastructure you do not control: browsers prefetch GET links, proxies cache them, and clients retry idempotent requests automatically after a timeout.
// Wrong — a GET that changes data
[HttpGet("{publicId:guid}/deactivate")]
public async Task<IActionResult> Deactivate(Guid publicId) { }
A crawler, a link prefetch, a corporate proxy warming its cache, or an email client scanning URLs will all issue that GET. Records vanish with nobody having clicked anything — and this has genuinely happened to production systems.
// Correct
[HttpDelete("{publicId:guid}")]
public async Task<IActionResult> Deactivate(Guid publicId, CancellationToken ct) { }
DELETE is idempotent: deleting an already-deleted student is still 204. Returning 404 on the second call is also defensible; pick one and apply it everywhere.
POST is not idempotent, which is why a double-clicked Save creates two students unless the client disables the button or the server accepts an idempotency key.
Actions that are not CRUD
POST /api/students/{publicId}/promote
POST /api/fee-accounts/{publicId}/payments
POST /api/exams/{examId}/publish
Model the action as a sub-resource or a POST to a named endpoint. Do not contort it into a PUT on the parent.
Status codes
| Code | Meaning | When |
|---|---|---|
| 200 | OK | GET, or an update returning the result |
| 201 | Created | POST succeeded — include Location |
| 202 | Accepted | Queued for later processing |
| 204 | No Content | Successful PUT/DELETE with no body |
| 304 | Not Modified | Conditional GET and the ETag matched |
| 400 | Bad Request | Validation failed |
| 401 | Unauthorized | Not authenticated |
| 403 | Forbidden | Authenticated, not permitted |
| 404 | Not Found | No such resource |
| 405 | Method Not Allowed | Right URL, wrong verb |
| 409 | Conflict | Duplicate, or a concurrency conflict |
| 415 | Unsupported Media Type | Missing or wrong Content-Type |
| 422 | Unprocessable Entity | Valid JSON, invalid data |
| 429 | Too Many Requests | Rate limited |
| 500 | Internal Server Error | Unhandled exception |
| 503 | Service Unavailable | Down or overloaded |
4xx means the request was wrong. 5xx means the server failed. A client seeing 400 should fix the request; one seeing 500 should retry or report.
401 versus 403 is the distinction interviewers ask about, and it is not academic: 401 tells the client to authenticate, 403 tells it not to bother.
Return 404, not 403, for another tenant's record. A 403 confirms the record exists; 404 reveals nothing.
[HttpPost]
[ProducesResponseType(typeof(StudentDto), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<StudentDto>> Create(
StudentCreateRequest request, CancellationToken ct)
{
var created = await _studentService.CreateAsync(User.GetSchoolId(), request, ct);
return CreatedAtAction(
nameof(GetById),
new { publicId = created.PublicId },
created);
}
CreatedAtAction sets the Location header — the URL of the new resource, which is what 201 is defined to carry.
204 for a PUT or DELETE with nothing to return, not 200 with an empty body. A client calling .json() on a 200 with no body gets a parse error.
DTOs
// Request — only what the caller may set
public class StudentCreateRequest
{
[Required, StringLength(100)]
public string Name { get; set; } = string.Empty;
[Required]
[RegularExpression(@"^NCA-\d{4}-\d{4}$")]
public string RollNumber { get; set; } = string.Empty;
[Required, StringLength(10)]
public string ClassName { get; set; } = string.Empty;
[Required]
public DateOnly? DateOfBirth { get; set; }
}
// Response — only what the caller should see
public class StudentDto
{
public Guid PublicId { get; init; }
public string Name { get; init; } = string.Empty;
public string RollNumber { get; init; } = string.Empty;
public string ClassName { get; init; } = string.Empty;
public string Section { get; init; } = string.Empty;
public string ParentName { get; init; } = string.Empty;
public string ParentPhone { get; init; } = string.Empty;
}
Never expose an entity directly. Three reasons, and the second is the one that bites:
- Over-posting on the request side — a client sets
SchoolIdand moves a record between tenants. - Accidental disclosure on the response side — adding a
PasswordHashcolumn toUserpublishes it to every client on the next deployment, with no code change. - Coupling — a database rename becomes a breaking API change.
The DTO is your contract. It changes when you decide it changes.
private static StudentDto MapToDto(Student student) => new()
{
PublicId = student.PublicId,
Name = student.Name,
RollNumber = student.RollNumber,
ClassName = student.ClassName,
Section = student.Section,
ParentName = student.ParentName,
ParentPhone = student.ParentPhone
};
Hand-written mapping is explicit and fast. A mapping library saves typing and hides exactly this kind of accidental exposure — if you use one, configure it to fail on unmapped members.
JSON conventions
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.JsonSerializerOptions.DefaultIgnoreCondition =
JsonIgnoreCondition.WhenWritingNull;
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
camelCase is the JSON convention and is what every JavaScript client expects. JsonStringEnumConverter serialises StudentStatus.Active as "Active" rather than 0 — far more readable, and it survives an enum member being renumbered.
Paging, filtering and sorting
public class StudentQueryParameters
{
public string? Term { get; set; }
public string? ClassName { get; set; }
public string? SortBy { get; set; }
public bool Descending { get; set; }
[Range(1, int.MaxValue)]
public int Page { get; set; } = 1;
[Range(1, 100)]
public int PageSize { get; set; } = 20;
}
public class PagedResult<T>
{
public IReadOnlyList<T> Items { get; init; } = Array.Empty<T>();
public int TotalCount { get; init; }
public int Page { get; init; }
public int PageSize { get; init; }
public int TotalPages => PageSize <= 0 ? 0 : (int)Math.Ceiling((double)TotalCount / PageSize);
public bool HasNext => Page < TotalPages;
public bool HasPrevious => Page > 1;
}
GET /api/students?term=Ravi&className=10th&page=2&pageSize=20&sortBy=name
Always page a collection endpoint, from the first version. An unpaged endpoint works with 400 students and times out at 40,000 — and adding paging later is a breaking change for every client.
[Range(1, 100)] on PageSize is a real control. Without it a client requests pageSize=1000000 and takes the API down.
Sorting must use a whitelist, never the raw value:
private static string ResolveSortColumn(string? sortBy) => sortBy switch
{
"name" => "Name",
"rollNumber" => "RollNumber",
"className" => "ClassName, Section",
_ => "Name"
};
Passing sortBy into SQL is a direct injection route, and a parameter cannot carry a column name.
Partial updates
[HttpPut("{publicId:guid}")]
public async Task<IActionResult> Update(
Guid publicId, StudentUpdateRequest request, CancellationToken ct)
PUT replaces the resource: every editable field must be present, and an omitted one is set to its default. Clients that send only what changed are misusing it.
[HttpPatch("{publicId:guid}")]
public async Task<IActionResult> Patch(
Guid publicId, JsonPatchDocument<StudentUpdateRequest> patch, CancellationToken ct)
{
var existing = await _studentService.GetForUpdateAsync(User.GetSchoolId(), publicId, ct);
if (existing is null)
{
return NotFound();
}
patch.ApplyTo(existing, ModelState);
if (!TryValidateModel(existing))
{
return ValidationProblem(ModelState);
}
await _studentService.UpdateAsync(User.GetSchoolId(), publicId, existing, ct);
return NoContent();
}
ApplyTo(existing, ModelState) records a malformed patch as a model error rather than throwing. TryValidateModel re-runs validation on the result, because a patch can produce an invalid state from two individually valid operations.
JSON Patch is expressive and awkward for clients. A simpler alternative is a request model of nullable properties, where null means "unchanged" — at the cost of being unable to set a value to null.
Versioning
Right-click the project → Manage NuGet Packages → Browse, search for the package, and click Install.
The Package Manager Console (Tools → NuGet Package Manager → Package Manager Console) does the same thing typed:
Install-Package Asp.Versioning.Mvc
Install-Package Asp.Versioning.Mvc.ApiExplorer
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(),
new HeaderApiVersionReader("X-Api-Version"));
})
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
[ApiController]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/students")]
public class StudentsController : ControllerBase
{
[HttpGet, MapToApiVersion("1.0")]
public async Task<IActionResult> SearchV1() { }
[HttpGet, MapToApiVersion("2.0")]
public async Task<IActionResult> SearchV2() { }
}
Version from the first release. Adding versioning after clients exist means either breaking them or maintaining an unversioned route forever.
A change is breaking if it removes a field, renames one, changes a type, adds a required request field, or changes a status code. Adding an optional field is not breaking, which is why response models should be extended rather than reshaped.
Consistency
An API is predictable when the same decision is made everywhere.
| Decide once | Apply everywhere |
|---|---|
| Plural or singular nouns | Every resource |
| camelCase JSON | Every DTO |
PublicId in URLs | Every resource |
| Paged envelope shape | Every collection |
ProblemDetails for errors | Every failure |
| Date format (ISO 8601, UTC) | Every timestamp |
| 204 or 200 for updates | Every write |
Idempotent DELETE returns 204 or 404 | Every delete |
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"traceId": "00-8a3c...-01",
"errors": {
"rollNumber": ["Format: NCA-2024-0012"]
}
}
One error shape means a client writes one error handler. Three shapes mean three, and the third is always the one they miss.
Dates as ISO 8601 in UTC, always:
{ "paidOn": "2024-06-15T10:30:00Z", "dateOfBirth": "2009-05-14" }
A local-time timestamp with no offset is ambiguous the moment the server moves region.
Errors you will hit
| What you see | Cause | Fix |
|---|---|---|
A POST repeated creates two records | No idempotency guard | Guard, or use a client-supplied key |
| Client cannot tell 404 from 403 | Same status used for both | Use them correctly |
| Returning 200 for a creation | Should be 201 with a Location header | CreatedAtAction |
| Internal ids exposed in URLs | Using the identity key | Use PublicId |
| The response leaks fields the caller should not see | Returned the entity, not a DTO | Project into a DTO |
| Breaking change ships silently | No versioning | Version the API |
Returning the entity is the commonest data-exposure bug in a .NET API. Teacher carries Salary; Student carries ParentPhone.
Common mistakes
- Verbs in URLs
- A
GETthat changes data - Exposing entities instead of DTOs
- Sequential integer ids in public URLs
- 200 with an empty body where 204 belongs
- 403 for another tenant's record
- Confusing 401 and 403
- No paging on a collection endpoint
- No maximum
pageSize - A raw sort column passed to SQL
- Inconsistent naming or error shapes across endpoints
- Local-time timestamps
- No versioning until it is too late
PUTused for partial updates, wiping omitted fields
Practice
The course exercises are create CRUD endpoints and return correct status codes.
- Design and build the full student resource: list, read, create, update, delete, plus the results sub-resource.
- Return 201 with a
Locationheader fromPOST. Confirm the header in the Network tab and follow it. - Return 200 with an empty body from
DELETE. Call it from JavaScript with.json()and record the error. Change to 204. - Implement
GET /api/students/{publicId}/deactivate. Then reason through what a link prefetcher would do, and change it toDELETE. - Call
DELETEtwice on the same student. Decide 204 or 404 for the second, and apply it consistently. - Return
Forbid()for another school's student, thenNotFound(). Explain which leaks information. - Expose the
Studententity as the response. Add aPasswordHashproperty and confirm it appears in the JSON. - Switch to
StudentDtoand confirm it does not. - Add paging with
[Range(1, 100)]onPageSize. RequestpageSize=100000and confirm the 400. - Remove the range and repeat. Record the response time.
- Add sorting with a whitelist. Pass
sortBy=Name; DROP TABLE Student--and confirm it falls through to the default. - Configure camelCase and
JsonStringEnumConverter. Compare the JSON before and after. - Add API versioning with a URL segment. Expose v1 and v2 of the list endpoint.
- Add a required field to the v1 request model and explain why that is a breaking change.
Exercises 4 and 7 are the two that cause real incidents — silent data loss and accidental disclosure.
You can now
- Design URLs, methods and status codes a client can rely on
- Return 201 with a
Locationfor creation - Return DTOs, never entities
- Use
PublicIdrather than the identity key in URLs - Guard a
POSTagainst duplicate submission
Review questions
- Why must a
GETnever change data? - Why return 404 rather than 403 for another tenant's resource?
- What three problems does exposing an entity as a DTO cause?
- Why must a sort column come from a whitelist?
Next: Data access with Dapper