Overview
Designing robust RESTful APIs is critical for modern web applications. Understanding REST principles, HTTP semantics, and API design patterns is essential for mid-level to senior roles. API design frequently comes up in system design and architecture interviews.
Core Concepts
Resource Modeling
Resources should represent business entities, not database tables.
// GOOD - Resource-oriented URLs
GET /api/orders // Get all orders
GET /api/orders/123 // Get specific order
POST /api/orders // Create new order
PUT /api/orders/123 // Replace order
PATCH /api/orders/123 // Update order partially
DELETE /api/orders/123 // Delete order
GET /api/orders/123/items // Get order items
POST /api/orders/123/items // Add item to order
DELETE /api/orders/123/items/456 // Remove item from order
GET /api/customers/1/orders // Get customer's orders
GET /api/products/5/reviews // Get product reviews
// BAD - RPC-style URLs
POST /api/createOrder
POST /api/updateOrder
POST /api/deleteOrder
GET /api/getOrderById?id=123
HTTP Method Semantics
Each HTTP method has specific semantics and guarantees.
// GET - Safe and idempotent (no side effects)
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(int id)
{
var order = await _repository.GetByIdAsync(id);
return order == null ? NotFound() : Ok(order);
}
// POST - Not idempotent (creates new resource each time)
[HttpPost]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest request)
{
var order = await _service.CreateOrderAsync(request);
return CreatedAtAction(
nameof(GetOrder),
new { id = order.Id },
order);
}
// PUT - Idempotent (multiple identical requests = same result)
[HttpPut("{id}")]
public async Task<IActionResult> ReplaceOrder(int id, [FromBody] OrderDto orderDto)
{
// PUT replaces entire resource
var exists = await _repository.ExistsAsync(id);
if (!exists)
return NotFound();
var order = await _service.ReplaceOrderAsync(id, orderDto);
return Ok(order);
}
// PATCH - Partial update (not necessarily idempotent)
[HttpPatch("{id}")]
public async Task<IActionResult> UpdateOrder(int id, [FromBody] JsonPatchDocument<OrderDto> patchDoc)
{
var order = await _repository.GetByIdAsync(id);
if (order == null)
return NotFound();
var orderDto = _mapper.Map<OrderDto>(order);
patchDoc.ApplyTo(orderDto, ModelState);
if (!ModelState.IsValid)
return BadRequest(ModelState);
await _service.UpdateOrderAsync(id, orderDto);
return Ok(orderDto);
}
// DELETE - Idempotent
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteOrder(int id)
{
var result = await _service.DeleteOrderAsync(id);
if (!result)
return NotFound();
return NoContent(); // 204
}
Content Negotiation
// Configure multiple formatters
builder.Services.AddControllers(options =>
{
options.RespectBrowserAcceptHeader = true;
options.ReturnHttpNotAcceptable = true; // Return 406 if can't satisfy Accept header
})
.AddXmlSerializerFormatters()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.JsonSerializerOptions.WriteIndented = true;
});
// Client requests JSON
// GET /api/products/1
// Accept: application/json
// Client requests XML
// GET /api/products/1
// Accept: application/xml
// API responds based on Accept header
[HttpGet("{id}")]
[Produces("application/json", "application/xml")]
public async Task<IActionResult> GetProduct(int id)
{
var product = await _service.GetProductAsync(id);
return Ok(product); // Formatted based on Accept header
}
Versioning Strategies
1. URL Versioning
// v1
[ApiController]
[Route("api/v1/[controller]")]
public class ProductsV1Controller : ControllerBase
{
[HttpGet("{id}")]
public async Task<ProductV1Dto> GetProduct(int id)
{
// Return v1 format
return new ProductV1Dto
{
Id = id,
Name = "Product",
Price = 99.99m
};
}
}
// v2 - breaking changes
[ApiController]
[Route("api/v2/[controller]")]
public class ProductsV2Controller : ControllerBase
{
[HttpGet("{id}")]
public async Task<ProductV2Dto> GetProduct(int id)
{
// Return v2 format with additional fields
return new ProductV2Dto
{
Id = id,
Name = "Product",
Price = new Money { Amount = 99.99m, Currency = "USD" }, // Breaking change
Category = "Electronics"
};
}
}
// Configure in Program.cs
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
});
2. Header Versioning
[ApiController]
[Route("api/[controller]")]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
public class ProductsController : ControllerBase
{
[HttpGet("{id}")]
[MapToApiVersion("1.0")]
public async Task<ProductV1Dto> GetProductV1(int id)
{
// Version 1 implementation
}
[HttpGet("{id}")]
[MapToApiVersion("2.0")]
public async Task<ProductV2Dto> GetProductV2(int id)
{
// Version 2 implementation
}
}
// Client specifies version in header:
// GET /api/products/1
// api-version: 2.0
3. Media Type Versioning
[HttpGet("{id}")]
[Produces("application/vnd.company.product.v1+json")]
public async Task<ProductV1Dto> GetProductV1(int id) { }
[HttpGet("{id}")]
[Produces("application/vnd.company.product.v2+json")]
public async Task<ProductV2Dto> GetProductV2(int id) { }
// Client request:
// GET /api/products/1
// Accept: application/vnd.company.product.v2+json
Pagination
public class PaginatedRequest
{
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 20;
}
public class PaginatedResponse<T>
{
public List<T> Items { get; set; }
public int Page { get; set; }
public int PageSize { get; set; }
public int TotalCount { get; set; }
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
public bool HasPrevious => Page > 1;
public bool HasNext => Page < TotalPages;
}
[HttpGet]
public async Task<IActionResult> GetProducts([FromQuery] PaginatedRequest request)
{
var (products, totalCount) = await _service.GetProductsAsync(request.Page, request.PageSize);
var response = new PaginatedResponse<ProductDto>
{
Items = products,
Page = request.Page,
PageSize = request.PageSize,
TotalCount = totalCount
};
// Add pagination metadata to headers
Response.Headers.Add("X-Pagination", JsonSerializer.Serialize(new
{
response.TotalCount,
response.TotalPages,
response.HasPrevious,
response.HasNext
}));
return Ok(response);
}
// Alternative: Link header with next/prev URLs
Response.Headers.Add("Link",
$"<{baseUrl}?page={page+1}&pageSize={pageSize}>; rel=\"next\", " +
$"<{baseUrl}?page={page-1}&pageSize={pageSize}>; rel=\"prev\"");
Filtering and Searching
public class ProductQueryParameters
{
public string Search { get; set; }
public decimal? MinPrice { get; set; }
public decimal? MaxPrice { get; set; }
public string Category { get; set; }
public string SortBy { get; set; } = "name";
public string SortOrder { get; set; } = "asc";
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 20;
}
[HttpGet]
public async Task<IActionResult> GetProducts([FromQuery] ProductQueryParameters parameters)
{
var query = _context.Products.AsQueryable();
// Filtering
if (!string.IsNullOrEmpty(parameters.Search))
{
query = query.Where(p =>
p.Name.Contains(parameters.Search) ||
p.Description.Contains(parameters.Search));
}
if (parameters.MinPrice.HasValue)
query = query.Where(p => p.Price >= parameters.MinPrice.Value);
if (parameters.MaxPrice.HasValue)
query = query.Where(p => p.Price <= parameters.MaxPrice.Value);
if (!string.IsNullOrEmpty(parameters.Category))
query = query.Where(p => p.Category == parameters.Category);
// Sorting
query = parameters.SortBy.ToLower() switch
{
"price" => parameters.SortOrder == "desc"
? query.OrderByDescending(p => p.Price)
: query.OrderBy(p => p.Price),
"name" => parameters.SortOrder == "desc"
? query.OrderByDescending(p => p.Name)
: query.OrderBy(p => p.Name),
_ => query.OrderBy(p => p.Id)
};
// Pagination
var totalCount = await query.CountAsync();
var products = await query
.Skip((parameters.Page - 1) * parameters.PageSize)
.Take(parameters.PageSize)
.ToListAsync();
return Ok(new PaginatedResponse<Product>
{
Items = products,
Page = parameters.Page,
PageSize = parameters.PageSize,
TotalCount = totalCount
});
}
// Example request:
// GET /api/products?search=laptop&minPrice=500&maxPrice=2000&category=electronics&sortBy=price&sortOrder=desc&page=1&pageSize=20
Error Handling
// Use ProblemDetails (RFC 7807)
[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(int id)
{
var product = await _service.GetProductAsync(id);
if (product == null)
{
return NotFound(new ProblemDetails
{
Status = 404,
Title = "Product not found",
Detail = $"Product with ID {id} does not exist",
Instance = HttpContext.Request.Path
});
}
return Ok(product);
}
// Global exception handler
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Instance = context.HttpContext.Request.Path;
context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
};
});
app.UseExceptionHandler(appBuilder =>
{
appBuilder.Run(async context =>
{
var exceptionHandlerFeature = context.Features.Get<IExceptionHandlerFeature>();
var exception = exceptionHandlerFeature?.Error;
var problemDetails = new ProblemDetails
{
Status = 500,
Title = "An error occurred",
Detail = exception?.Message,
Instance = context.Request.Path
};
context.Response.StatusCode = 500;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(problemDetails);
});
});
// Validation errors
[HttpPost]
public async Task<IActionResult> CreateProduct([FromBody] CreateProductRequest request)
{
if (!ModelState.IsValid)
{
return BadRequest(new ValidationProblemDetails(ModelState)
{
Status = 400,
Title = "Validation errors occurred",
Instance = HttpContext.Request.Path
});
}
var product = await _service.CreateProductAsync(request);
return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product);
}
Interview Tips
Tip 1: Understand the difference between PUT and PATCH. PUT replaces the entire resource, PATCH applies partial updates. This is a common interview question.
Tip 2: Know when to use which HTTP status code. 200 vs 201 vs 204, 400 vs 401 vs 403 vs 404 - each has specific semantics.
Tip 3: Be ready to discuss API versioning strategies and their trade-offs. URL versioning is simple but pollutes URLs; header versioning is cleaner but less discoverable.
Common Interview Questions
-
What makes an API RESTful?
- Resource-based URLs, HTTP methods for operations, stateless communication, standard status codes, uniform interface
-
When would you use PUT vs PATCH?
- PUT: Replace entire resource (send all fields)
- PATCH: Update specific fields (send only changed fields)
-
How do you handle API versioning?
- URL versioning (
/api/v1/products), header versioning, media type versioning. Each has trade-offs in discoverability vs URL cleanliness.
- URL versioning (
-
What's the difference between 401 and 403?
- 401 Unauthorized: Authentication missing or invalid (not logged in)
- 403 Forbidden: Authenticated but lacks permissions (logged in but can't access)
-
How do you implement pagination?
- Query parameters (page, pageSize), return metadata (totalCount, totalPages), include pagination links in headers or response body
-
What is HATEOAS?
- Hypermedia As The Engine Of Application State. API responses include links to related resources, making API self-discoverable.
-
How do you handle long-running operations?
- Return 202 Accepted with location header, client polls status endpoint, or use webhooks for notification