A comprehensive guide to designing RESTful APIs following industry best practices.
β
Good:
GET /users
GET /users/123
POST /users
PUT /users/123
DELETE /users/123
β Bad:
GET /getUsers
POST /createUser
PUT /updateUser/123
DELETE /deleteUser/123
β
Good:
GET /users
GET /orders
GET /products
β Bad:
GET /user
GET /order
GET /product
β
Good:
GET /user-accounts
GET /order-items
GET /shipping-addresses
β Bad:
GET /userAccounts
GET /user_accounts
| Method |
Description |
Idempotent |
Safe |
| GET |
Retrieve resource(s) |
Yes |
Yes |
| POST |
Create resource |
No |
No |
| PUT |
Replace entire resource |
Yes |
No |
| PATCH |
Partial update |
No |
No |
| DELETE |
Remove resource |
Yes |
No |
ASP.NET Core Implementation
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
[HttpGet]
public async Task<ActionResult<IEnumerable<UserDto>>> GetAll()
{
var users = await _userService.GetAllAsync();
return Ok(users);
}
[HttpGet("{id}")]
public async Task<ActionResult<UserDto>> GetById(int id)
{
var user = await _userService.GetByIdAsync(id);
if (user == null) return NotFound();
return Ok(user);
}
[HttpPost]
public async Task<ActionResult<UserDto>> Create(CreateUserDto dto)
{
var user = await _userService.CreateAsync(dto);
return CreatedAtAction(nameof(GetById), new { id = user.Id }, user);
}
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, UpdateUserDto dto)
{
if (id != dto.Id) return BadRequest();
await _userService.UpdateAsync(dto);
return NoContent();
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
await _userService.DeleteAsync(id);
return NoContent();
}
}
| Code |
Name |
Use Case |
| 200 |
OK |
Successful GET, PUT, PATCH |
| 201 |
Created |
Successful POST creating resource |
| 204 |
No Content |
Successful DELETE or PUT with no body |
| Code |
Name |
Use Case |
| 400 |
Bad Request |
Validation error, malformed request |
| 401 |
Unauthorized |
Missing or invalid authentication |
| 403 |
Forbidden |
Valid auth but insufficient permissions |
| 404 |
Not Found |
Resource doesnβt exist |
| 409 |
Conflict |
Resource state conflict |
| 422 |
Unprocessable Entity |
Semantic validation error |
| Code |
Name |
Use Case |
| 500 |
Internal Server Error |
Unexpected server error |
| 503 |
Service Unavailable |
Server temporarily unavailable |
GET /users?status=active&role=admin # Filtering
GET /users?sort=created_at&order=desc # Sorting
GET /users?page=2&limit=20 # Pagination
GET /users?fields=id,name,email # Field selection
public class QueryParameters
{
private const int MaxPageSize = 100;
private int _pageSize = 10;
public int Page { get; set; } = 1;
public int PageSize
{
get => _pageSize;
set => _pageSize = Math.Min(value, MaxPageSize);
}
public string? SortBy { get; set; }
public bool Descending { get; set; }
public string? Status { get; set; }
}
[HttpGet]
public async Task<ActionResult<PagedResult<UserDto>>> GetAll(
[FromQuery] QueryParameters parameters)
{
var result = await _userService.GetPagedAsync(parameters);
Response.Headers.Add("X-Total-Count", result.TotalCount.ToString());
Response.Headers.Add("X-Page", parameters.Page.ToString());
Response.Headers.Add("X-Page-Size", parameters.PageSize.ToString());
return Ok(result.Items);
}
GET /api/v1/users
GET /api/v2/users
GET /api/users
X-API-Version: 2
GET /api/users?api-version=2.0
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"),
new QueryStringApiVersionReader("api-version")
);
});
[ApiVersion("1.0")]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class UsersController : ControllerBase
{
[HttpGet]
[MapToApiVersion("1.0")]
public IActionResult GetV1() => Ok("Version 1.0");
[HttpGet]
[MapToApiVersion("2.0")]
public IActionResult GetV2() => Ok("Version 2.0");
}
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "Bad Request",
"status": 400,
"traceId": "00-abc123...",
"errors": {
"Email": ["The Email field is required."],
"Password": ["Password must be at least 8 characters."]
}
}
public class ApiError
{
public int StatusCode { get; set; }
public string Message { get; set; }
public string? Detail { get; set; }
public string? TraceId { get; set; }
public Dictionary<string, string[]>? Errors { get; set; }
}
return BadRequest(new ApiError
{
StatusCode = 400,
Message = "Validation failed",
Errors = new Dictionary<string, string[]>
{
["Email"] = new[] { "Invalid email format" }
}
});
{
"id": 123,
"name": "John Doe",
"_links": {
"self": { "href": "/api/users/123" },
"orders": { "href": "/api/users/123/orders" },
"edit": { "href": "/api/users/123", "method": "PUT" },
"delete": { "href": "/api/users/123", "method": "DELETE" }
}
}
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("fixed", config =>
{
config.PermitLimit = 100;
config.Window = TimeSpan.FromMinutes(1);
config.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
config.QueueLimit = 10;
});
});
app.UseRateLimiter();
[EnableRateLimiting("fixed")]
public class UsersController : ControllerBase { }
- Use consistent naming - kebab-case, plural nouns
- Return appropriate status codes - 201 for created, 204 for no content
- Version your API - Plan for breaking changes
- Implement pagination - Prevent large data transfers
- Use standard error format - Consistent error responses
- Document with OpenAPI/Swagger - Self-documenting APIs
- Implement rate limiting - Protect against abuse
- Use HTTPS only - Secure all communications