3 min read
API Design Patterns That Stood the Test of Time in 2025
As we approach 2026, some API design patterns have proven their worth while others faded. Here are the patterns that consistently delivered maintainable, scalable APIs this year.
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.