API Design Best Practices: REST, gRPC, and GraphQL for Modern Backend Teams
An API is a contract between your team and the world. A well-designed API is intuitive, consistent, and evolvable — it empowers consumers to build confidently without reading your source code. A poorly designed one becomes a maintenance liability that cascades breaking changes through every client. This guide covers the principles and practical patterns that distinguish exceptional APIs from forgettable ones.
The Importance of API Design in Modern Software Systems
APIs have become the fundamental unit of software integration. Modern applications are composed of dozens of APIs: payment processors, identity providers, data platforms, internal microservices, and third-party SaaS integrations. The quality of your API design directly determines developer productivity, system reliability, and the long-term maintainability of your codebase.
A great API design investment pays dividends for years. When an API is intuitive and consistent, developers can learn one endpoint and correctly predict the behavior of ten others. When error responses are structured and informative, debugging is fast. When versioning is handled thoughtfully, you can evolve your API without breaking existing integrations. Conversely, a poorly designed API creates ongoing friction: unclear naming requires constant documentation lookups, inconsistent error formats complicate client error handling, and absent versioning strategies force painful big-bang migrations.
The three dominant API paradigms — REST, gRPC, and GraphQL — each have distinct strengths and appropriate use cases. Understanding when to use each, and how to apply best practices within each, is an essential competency for any senior backend engineer.
REST API Design Principles
REST (Representational State Transfer) remains the most widely used API style for public and external-facing APIs. Its success comes from its alignment with HTTP semantics, human-readable JSON payloads, and near-universal tooling support. But "using HTTP" is not the same as "designing a good REST API." Here are the principles that separate well-crafted REST APIs from accidental ones.
Resource Naming: Nouns, Not Verbs
REST resources should be named as nouns representing entities, not verbs representing actions. The HTTP method provides the verb.
- Good:
GET /users/{id},POST /orders,DELETE /products/{id} - Bad:
GET /getUser/{id},POST /createOrder,DELETE /deleteProduct/{id}
Use plural nouns for collections (/users, not /user). Nest sub-resources to express ownership relationships: GET /users/{userId}/orders retrieves all orders for a specific user. Avoid nesting deeper than two levels — it makes URLs brittle and hard to parse. If you need deeper relationships, use query parameters or standalone endpoints with filtering.
HTTP Semantics: Use Methods and Status Codes Correctly
HTTP provides a rich vocabulary of methods and status codes. Use them precisely:
- GET: Read-only, idempotent, safe. Must not modify state.
- POST: Create a new resource. Not idempotent — calling twice creates two resources.
- PUT: Replace a resource entirely. Idempotent.
- PATCH: Partially update a resource. Use for sparse updates rather than full replacements.
- DELETE: Remove a resource. Idempotent — deleting a non-existent resource should return 404 or 204.
Status code discipline: 201 Created with a Location header for successful resource creation; 204 No Content for successful deletion; 400 Bad Request for client errors with a descriptive body; 401 Unauthorized for missing/invalid authentication; 403 Forbidden for authenticated but unauthorized access; 404 Not Found for missing resources; 409 Conflict for business rule violations (duplicate email); 422 Unprocessable Entity for validation errors; 429 Too Many Requests for rate limiting; 500 Internal Server Error for unexpected server failures.
API Versioning Strategies
Every API evolves, and every breaking change requires a versioning strategy. The three main approaches each have distinct trade-offs:
URI versioning (/v1/users, /v2/users) is the most explicit and widely understood approach. The version is visible in URLs, logs, and browser history, making debugging straightforward. The downside is that routes proliferate as versions accumulate, and clients must update all their URLs when migrating. This approach works well for public APIs where transparency is paramount.
Header versioning (Accept: application/vnd.myapi.v2+json or a custom API-Version: 2 header) keeps URLs clean and version-agnostic. It aligns with REST's content negotiation model and is preferred by API design purists. However, it is less discoverable, harder to test in a browser, and requires client developers to set custom headers — raising the barrier for quick integrations.
Query parameter versioning (/users?version=2) is easy to implement and test but pollutes query strings and can conflict with pagination and filtering parameters. It is generally the least preferred approach for production APIs.
The pragmatic recommendation: use URI versioning (/v1/, /v2/) for all external and public APIs. Maintain a clear deprecation policy — announce deprecated versions at least 12 months before sunset, include Deprecation and Sunset response headers, and provide migration guides. For internal microservice APIs, prefer evolution over versioning: add optional fields, never remove or rename fields, and use feature flags to gate new behavior.
Pagination, Filtering, and Sorting
Collection endpoints that can return large datasets must support pagination. There are two primary approaches, each suited to different use cases.
Offset-based pagination (?page=3&size=20) is intuitive and allows random access to any page. It is simple to implement and works well for UI components that need page numbers. However, it suffers from "page drift" — if items are inserted or deleted between requests, the pages shift, causing duplicates or gaps in the results. It also requires a full COUNT(*) query for total page count, which is expensive on large datasets.
Cursor-based pagination (?cursor=eyJpZCI6MTAwfQ&&limit=20) uses an opaque cursor (typically a base64-encoded last-seen record ID or timestamp) to mark the caller's position in the dataset. It is stable — insertions and deletions do not shift the cursor position — and is the correct choice for feeds, infinite scroll, and real-time data. It does not support random access to arbitrary pages, which is acceptable for most streaming or feed use cases.
For filtering and sorting, use consistent query parameter conventions: ?status=ACTIVE&createdAfter=2025-01-01&sortBy=createdAt&sortDir=desc. Document all supported filter fields and their operators explicitly in your OpenAPI spec. Reject unsupported filter combinations with a 400 response and a clear error message rather than silently ignoring them.
Error Handling and Problem Details
Consistent, informative error responses are one of the highest-value investments you can make in an API. RFC 9457 (formerly RFC 7807) defines the application/problem+json media type as the standard format for HTTP API error responses. It is supported natively by Spring Boot 3.x via ProblemDetail.
A well-structured problem detail response looks like this:
{
"type": "https://api.example.com/errors/validation-failed",
"title": "Validation Failed",
"status": 422,
"detail": "The request body contains invalid fields.",
"instance": "/orders/checkout",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"timestamp": "2026-03-17T10:22:11Z",
"errors": [
{
"field": "items[0].quantity",
"code": "MUST_BE_POSITIVE",
"message": "Quantity must be greater than zero."
},
{
"field": "shippingAddress.postalCode",
"code": "INVALID_FORMAT",
"message": "Postal code must match pattern [A-Z0-9]{5,10}."
}
]
}
Key elements: type is a stable URI that uniquely identifies the error class (link to docs); title is a human-readable summary; detail is a specific, actionable description; instance is the request URI; traceId enables correlation with server logs. The errors array provides field-level validation details for form validation use cases. Never expose stack traces, SQL query details, or internal class names in error responses — these are security liabilities and implementation leaks.
OpenAPI 3.1: Contract-First Development
The most reliable way to design a high-quality API is to write the OpenAPI specification first, validate it with stakeholders, and then generate server stubs and client SDKs from the spec. This contract-first approach decouples API design from implementation and makes it impossible for the implementation to drift from the documented interface.
Here is a minimal but complete OpenAPI 3.1 definition for a user endpoint:
openapi: 3.1.0
info:
title: User Management API
version: 1.0.0
description: Manages user accounts and profiles.
paths:
/v1/users/{userId}:
get:
summary: Get a user by ID
operationId: getUserById
tags: [Users]
parameters:
- name: userId
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: User found
content:
application/json:
schema:
$ref: '#/components/schemas/UserResponse'
'404':
description: User not found
content:
application/problem+json:
schema:
$ref: '#/components/schemas/ProblemDetail'
components:
schemas:
UserResponse:
type: object
required: [id, email, createdAt]
properties:
id:
type: string
format: uuid
example: "123e4567-e89b-12d3-a456-426614174000"
email:
type: string
format: email
example: "user@example.com"
displayName:
type: string
example: "Jane Doe"
createdAt:
type: string
format: date-time
ProblemDetail:
type: object
properties:
type:
type: string
format: uri
title:
type: string
status:
type: integer
detail:
type: string
instance:
type: string
gRPC for High-Performance Internal APIs
gRPC is the dominant choice for high-throughput, low-latency internal service communication. It uses Protocol Buffers (protobuf) as the interface definition language and binary serialization format, yielding payloads that are typically 3–10x smaller than equivalent JSON and 2–5x faster to serialize and deserialize.
gRPC also provides first-class support for streaming: unary (single request/response), server streaming, client streaming, and bidirectional streaming. This makes it ideal for real-time data feeds, large file transfers, and long-running operations.
// product_service.proto
syntax = "proto3";
package com.example.product;
option java_package = "com.example.product.grpc";
service ProductService {
rpc GetProduct (GetProductRequest) returns (ProductResponse);
rpc ListProducts (ListProductsRequest) returns (stream ProductResponse);
rpc UpdateInventory (UpdateInventoryRequest) returns (UpdateInventoryResponse);
}
message GetProductRequest {
string product_id = 1;
}
message ProductResponse {
string id = 1;
string name = 2;
string description = 3;
double price = 4;
int32 stock = 5;
}
message ListProductsRequest {
string category = 1;
int32 page_size = 2;
string page_token = 3;
}
message UpdateInventoryRequest {
string product_id = 1;
int32 delta = 2; // Positive to add, negative to subtract
}
message UpdateInventoryResponse {
int32 new_stock = 1;
bool success = 2;
}
Compile the proto file with the protobuf compiler to generate Java stubs, then implement the service interface. Spring Boot integrates with gRPC via the grpc-spring-boot-starter library. In a Kubernetes cluster, gRPC over HTTP/2 enables multiplexed streams over a single TCP connection per pod, dramatically reducing connection overhead compared to HTTP/1.1.
GraphQL: When Flexibility Beats Simplicity
GraphQL is the right choice when your API must serve multiple clients (web, mobile, third parties) with significantly different data needs, and where over-fetching and under-fetching are real performance problems. A GraphQL schema defines a typed graph of your data; clients query exactly the fields they need in a single request, eliminating both over-fetching (receiving more data than needed) and under-fetching (requiring multiple requests to assemble a response).
The trade-offs are real: GraphQL introduces server-side complexity (resolvers, DataLoader for N+1 prevention, schema stitching for federation), makes HTTP caching more difficult (all requests are POST to a single endpoint), and requires client teams to invest in GraphQL client tooling (Apollo Client, Relay). The GraphQL sweet spot is product-facing APIs where the client shape is highly variable and mobile bandwidth efficiency matters. For internal microservice APIs, REST or gRPC is almost always simpler and sufficient.
"REST for public contracts, gRPC for internal performance-critical paths, GraphQL for product APIs with heterogeneous clients. Knowing which to reach for — and why — is what separates thoughtful API designers from framework evangelists."
API Security: Authentication, Authorization, and Rate Limiting
Security is not an afterthought in API design — it is a structural concern that must be baked into the design from the first endpoint.
Authentication with JWT and OAuth 2.0
Use OAuth 2.0 with JWT bearer tokens for stateless, scalable authentication. The Authorization Server issues signed JWTs; the API validates the token's signature and claims on every request without a database lookup. Use short-lived access tokens (15 minutes) paired with refresh tokens for user-facing APIs. For service-to-service authentication, use the OAuth 2.0 Client Credentials flow.
In Spring Boot, configure JWT authentication with Spring Security:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(sm ->
sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/v1/public/**").permitAll()
.requestMatchers(HttpMethod.GET, "/v1/products/**").hasRole("USER")
.requestMatchers("/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 ->
oauth2.jwt(jwt ->
jwt.jwtAuthenticationConverter(jwtAuthConverter())));
return http.build();
}
}
Rate Limiting in Spring Boot
Rate limiting protects your API from abuse, prevents denial-of-service scenarios, and ensures fair resource allocation across consumers. Implement rate limiting at the API gateway layer (AWS API Gateway, Kong, Nginx) for coarse-grained protection, and at the application layer with Bucket4j for fine-grained, business-rule-aware limiting:
@Component
public class RateLimitInterceptor implements HandlerInterceptor {
private final Map<String, Bucket> bucketCache = new ConcurrentHashMap<>();
private Bucket newBucket() {
Bandwidth limit = Bandwidth.classic(100,
Refill.greedy(100, Duration.ofMinutes(1)));
return Bucket.builder().addLimit(limit).build();
}
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
String apiKey = request.getHeader("X-API-Key");
if (apiKey == null) {
response.setStatus(401);
return false;
}
Bucket bucket = bucketCache.computeIfAbsent(apiKey, k -> newBucket());
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
if (probe.isConsumed()) {
response.setHeader("X-Rate-Limit-Remaining",
String.valueOf(probe.getRemainingTokens()));
return true;
} else {
response.setStatus(429);
response.setHeader("X-Rate-Limit-Retry-After-Seconds",
String.valueOf(probe.getNanosToWaitForRefill() / 1_000_000_000));
return false;
}
}
}
Always include rate limit headers in responses (X-Rate-Limit-Limit, X-Rate-Limit-Remaining, X-Rate-Limit-Reset) so clients can self-regulate. Return 429 Too Many Requests with a Retry-After header when the limit is exceeded. Tier your rate limits: unauthenticated users get the most restrictive limits, authenticated users get higher limits, and trusted partners get the highest limits via API key tiers.
HTTPS, CORS, and Input Validation
Enforce HTTPS for all API traffic — HTTP should redirect to HTTPS or be blocked entirely. Configure CORS (Cross-Origin Resource Sharing) to allow only known, trusted origins. Validate and sanitize all input at the API boundary; never trust client-supplied data. Use a schema validation library (Hibernate Validator, JSON Schema validation) to reject malformed requests before they reach business logic. Implement request size limits to prevent payload-based denial-of-service attacks.
Great API design is ultimately an act of empathy — understanding how developers will discover, learn, and integrate your API, and removing every unnecessary point of friction. Start with a clear contract (OpenAPI or protobuf), choose the right protocol for your use case (REST, gRPC, or GraphQL), design errors that help rather than confuse, and build security in from the first endpoint. The APIs you design today will be the foundation on which your colleagues, partners, and customers build for years to come. Make them excellent.
Related Articles
Discussion / Comments
Join the conversation — your comment goes directly to my inbox.