RESTful API Design

Master REST API design principles including resource modeling, HTTP methods, versioning strategies, HATEOAS, and best practices for building scalable web APIs with ASP.NET Core

API Design
REST
Web APIs
HTTP
Architecture
Best Practices
25 min read

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

  1. What makes an API RESTful?

    • Resource-based URLs, HTTP methods for operations, stateless communication, standard status codes, uniform interface
  2. When would you use PUT vs PATCH?

    • PUT: Replace entire resource (send all fields)
    • PATCH: Update specific fields (send only changed fields)
  3. 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.
  4. 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)
  5. How do you implement pagination?

    • Query parameters (page, pageSize), return metadata (totalCount, totalPages), include pagination links in headers or response body
  6. What is HATEOAS?

    • Hypermedia As The Engine Of Application State. API responses include links to related resources, making API self-discoverable.
  7. How do you handle long-running operations?

    • Return 202 Accepted with location header, client polls status endpoint, or use webhooks for notification