Skip to content
Back to Blog
1 min read

API Design Patterns That Stood the Test of Time in 2025

I wrote “API Design Patterns That Stood the Test of Time in 2025” to share practical, production-minded guidance on this topic.

1. Resource-Based URLs with Proper Nesting

# Good - Clear resource hierarchy
GET /customers/{customerId}/orders
GET /customers/{customerId}/orders/{orderId}
POST /customers/{customerId}/orders

# Bad - Action-based URLs
GET /getCustomerOrders?customerId=123
POST /createOrder

2. Consistent Error Responses

public class ApiErrorResponse
{
    public string Type { get; init; }      // Error category URI
    public string Title { get; init; }     // Human-readable summary
    public int Status { get; init; }       // HTTP status code
    public string Detail { get; init; }    // Specific error description
    public string Instance { get; init; }  // Request identifier
    public Dictionary<string, string[]> Errors { get; init; }  // Validation errors
}

// Example response
{
    "type": "https://api.example.com/errors/validation",
    "title": "Validation Failed",
    "status": 400,
    "detail": "One or more fields failed validation",
    "instance": "/orders/123",
    "errors": {
        "email": ["Invalid email format"],
        "quantity": ["Must be greater than 0"]
    }
}

3. Pagination with Cursor-Based Approach

public class PagedResponse<T>
{
    public List<T> Data { get; init; }
    public string NextCursor { get; init; }
    public string PreviousCursor { get; init; }
    public int TotalCount { get; init; }
    public bool HasMore { get; init; }
}

// Implementation
[HttpGet("orders")]
public async Task<PagedResponse<Order>> GetOrders(
    [FromQuery] string cursor = null,
    [FromQuery] int pageSize = 20)
{
    var decodedCursor = cursor != null
        ? DecodeCursor(cursor)
        : new Cursor { LastId = Guid.Empty, LastDate = DateTime.MaxValue };

    var orders = await _db.Orders
        .Where(o => o.CreatedAt < decodedCursor.LastDate
                 || (o.CreatedAt == decodedCursor.LastDate && o.Id < decodedCursor.LastId))
        .OrderByDescending(o => o.CreatedAt)
        .ThenByDescending(o => o.Id)
        .Take(pageSize + 1)
        .ToListAsync();

    var hasMore = orders.Count > pageSize;
    var resultOrders = orders.Take(pageSize).ToList();

    return new PagedResponse<Order>
    {
        Data = resultOrders,
        HasMore = hasMore,
        NextCursor = hasMore ? EncodeCursor(resultOrders.Last()) : null
    };
}

4. Versioning via URL Path

// Clearest approach for consumers
[ApiController]
[Route("api/v1/[controller]")]
public class OrdersV1Controller : ControllerBase { }

[ApiController]
[Route("api/v2/[controller]")]
public class OrdersV2Controller : ControllerBase { }

5. HATEOAS for Discoverability

{
    "id": "order-123",
    "status": "pending",
    "total": 99.99,
    "_links": {
        "self": { "href": "/api/v1/orders/order-123" },
        "cancel": { "href": "/api/v1/orders/order-123/cancel", "method": "POST" },
        "customer": { "href": "/api/v1/customers/cust-456" },
        "items": { "href": "/api/v1/orders/order-123/items" }
    }
}

6. Idempotency Keys for Safe Retries

[HttpPost("payments")]
public async Task<IActionResult> CreatePayment(
    [FromHeader(Name = "Idempotency-Key")] string idempotencyKey,
    [FromBody] PaymentRequest request)
{
    // Check if we've seen this key before
    var existingResult = await _cache.GetAsync($"idempotency:{idempotencyKey}");
    if (existingResult != null)
    {
        return Ok(existingResult);
    }

    var payment = await _paymentService.ProcessAsync(request);

    // Store result with TTL
    await _cache.SetAsync($"idempotency:{idempotencyKey}", payment, TimeSpan.FromHours(24));

    return CreatedAtAction(nameof(GetPayment), new { id = payment.Id }, payment);
}

7. Rate Limiting with Clear Headers

HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 999
X-RateLimit-Reset: 1703894400

Key Principles

  • Predictability - Consistent patterns across all endpoints
  • Self-documentation - Clear naming, standard responses
  • Backward compatibility - Version and deprecate carefully
  • Performance transparency - Pagination, rate limits visible

Good API design is timeless. These patterns work because they prioritize developer experience and operational reliability.\n\n## Takeaways\n\nAdd a concise, personal takeaway and recommended next steps here.\n

Michael John Peña

Michael John Peña

Senior Data Engineer based in Sydney. Writing about data, cloud, and technology.