1. API Versioning & Deprecation
The moment an API has external consumers, a "breaking change" isn't just a code change — it's a change that breaks someone else's running application, possibly one you can't see or contact directly. Versioning exists to let an API evolve without that happening: old clients keep working against the version they built against, while new capability ships under a new version.
Three common strategies, each with a real tradeoff:
- URI versioning (
/api/v1/tasks,/api/v2/tasks) — the most visible and cacheable option; the tradeoff is that it treats the entire resource as versioned even when only one field changed. - Header versioning (
Accept: application/vnd.acme.v2+json) — keeps URLs stable and is arguably more "correct" REST, at the cost of being far less discoverable — nobody can see the version by looking at a URL in a browser or a log line. - No versioning, additive-only changes — never remove or repurpose a field, only add new optional ones; simplest to reason about, but it doesn't scale to changes that are genuinely breaking by nature (renaming a field, changing a type).
@RestController
@RequestMapping("/api/v1/tasks")
class TaskControllerV1 {
@GetMapping("/{id}")
TaskResponseV1 getTask(@PathVariable Long id) { /* ... */ }
}
@RestController
@RequestMapping("/api/v2/tasks")
class TaskControllerV2 {
@GetMapping("/{id}")
TaskResponseV2 getTask(@PathVariable Long id) { /* ... */ } // new shape
}
Whichever strategy you pick, deprecation needs a signal, not a surprise
removal. A Deprecation and Sunset HTTP header
(RFC 8594) on the old version's responses gives clients a machine-readable warning and
a concrete date, well before the endpoint actually disappears:
@GetMapping("/{id}")
ResponseEntity<TaskResponseV1> getTask(@PathVariable Long id) {
return ResponseEntity.ok()
.header("Deprecation", "true")
.header("Sunset", "Wed, 01 Apr 2026 00:00:00 GMT")
.header("Link", "</api/v2/tasks>; rel=\"successor-version\"")
.body(toResponseV1(taskService.getTask(id)));
}
Adding a new optional field to a response is safe; renaming an existing field, changing its type, or making an optional field required are all breaking, even if the change looks small in a diff. Before shipping any API change, ask specifically whether an existing client's current deserialization code would still work unmodified — that's the real test, not whether the change feels minor to write.
2. Pagination, Filtering & Sorting
Spring Data's Pageable gives you offset-based pagination
(page=2&size=20) almost for free, and it's the right default for most
admin screens and small datasets. It has a real weakness at scale: a large
OFFSET forces the database to scan and discard every row before it, so
page=5000 against a large table gets progressively slower the deeper a
client paginates — and if rows are inserted or deleted between page requests, offsets
can shift, silently skipping or duplicating rows.
@GetMapping
Page<TaskResponse> listTasks(
@PageableDefault(size = 20, sort = "createdAt") Pageable pageable) {
return taskRepository.findAll(pageable).map(this::toResponse);
}
Cursor-based (keyset) pagination solves both problems by paginating from a stable reference point instead of a row count — "give me the 20 tasks created after this specific timestamp/ID" rather than "give me rows 100 through 120."
interface TaskRepository extends JpaRepository<Task, Long> {
@Query("""
SELECT t FROM Task t
WHERE t.createdAt < :cursor
ORDER BY t.createdAt DESC
""")
List<Task> findPageBefore(@Param("cursor") Instant cursor, Pageable limit);
}
// response includes a `nextCursor` derived from the last item's createdAt,
// which the client passes back as `cursor` on the next request
This is why most large-scale public APIs (Stripe, GitHub, Slack) use cursors rather than offsets — the query stays roughly constant-time regardless of how deep into the dataset the client has paginated, and rows inserted during pagination don't shift already-fetched results.
Filtering and sorting deserve the same consistency as pagination: standardize on one
convention — ?sort=createdAt,desc&status=OPEN — applied identically
across every list endpoint, rather than each endpoint inventing its own query
parameter shape. A consumer who's learned one endpoint's filtering syntax should be
able to guess every other endpoint's correctly.
3. OpenAPI Documentation & Rate Limiting
springdoc-openapi generates an OpenAPI 3 specification directly from your controllers, DTOs and validation annotations — documentation that can't silently drift out of sync with the actual code, because it's derived from that code on every build rather than hand-maintained separately.
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.5.0</version>
</dependency>
@Operation(summary = "Fetch a task by ID")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Task found"),
@ApiResponse(responseCode = "404", description = "No task with that ID")
})
@GetMapping("/{id}")
TaskResponse getTask(@PathVariable @Parameter(description = "The task's ID") Long id) {
return toResponse(taskService.getTask(id));
}
With the starter on the classpath, a browsable Swagger UI is available at
/swagger-ui.html with zero additional configuration, generated from
exactly the same annotations — Bean Validation constraints from Week 3 included —
that already describe the endpoint's real behavior.
Once an API has external, potentially untrusted consumers, rate limiting protects it from a single client — buggy or malicious — consuming a disproportionate share of capacity. Bucket4j implements the token bucket algorithm as a Spring filter:
@Component
class RateLimitFilter extends OncePerRequestFilter {
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws IOException, ServletException {
String apiKey = req.getHeader("X-API-Key");
Bucket bucket = buckets.computeIfAbsent(apiKey, k ->
Bucket.builder()
.addLimit(Bandwidth.simple(100, Duration.ofMinutes(1))) // 100 req/min
.build());
if (bucket.tryConsume(1)) {
chain.doFilter(req, res);
} else {
res.setStatus(429); // Too Many Requests
res.setHeader("Retry-After", "60");
}
}
}
The in-memory ConcurrentHashMap above works for a single instance but resets independently on every replica behind a load balancer — a client could get 100 requests/minute per replica instead of 100 total. A production deployment needs the bucket state in a shared store (Redis, per Week 11) so the limit is enforced consistently regardless of which instance handles a given request.
4. Hands-on Exercise
Version an endpoint, add cursor pagination, and document and rate-limit the API
Apply all three practices to the task service from earlier weeks.
Requirements:
- Take an existing endpoint and create a
v2under URI versioning that changes its response shape, while keepingv1working unmodified for existing callers; addDeprecation/Sunsetheaders to thev1response. - Replace offset pagination on your task list endpoint with cursor-based pagination, and confirm query performance stays flat when paginating deep into a table seeded with several thousand rows (compare against the offset version at a high page number).
- Add springdoc-openapi and confirm the generated Swagger UI accurately reflects your endpoints, including validation constraints and possible error responses.
- Add a per-API-key rate limit filter and confirm a client exceeding the limit receives a
429with aRetry-Afterheader.
Seed at least 10,000 test rows before comparing offset vs. cursor pagination performance — the difference is invisible on a table with 50 rows and becomes obvious once OFFSET actually has something expensive to skip past.
5. Knowledge Check
Three quick questions. Expand each to check your answer.
Q1
Why is renaming a JSON field a breaking change even if the value's meaning stays the same?
Why is renaming a JSON field a breaking change even if the value's meaning stays the same?
An existing client's deserialization code references the field by its exact name; renaming it means that code either silently gets a null/missing value or fails to parse the response at all, even though the underlying data is unchanged. Whether a change is "breaking" is defined by whether existing consumer code keeps working, not by how significant the change looks from the API author's side.
Q2
Why does cursor-based pagination stay roughly constant-time while offset pagination gets slower on deeper pages?
Why does cursor-based pagination stay roughly constant-time while offset pagination gets slower on deeper pages?
An OFFSET query still has to scan and discard every row before the requested offset, so the cost grows with how deep into the table the client has paginated. A cursor query filters directly on an indexed column (like a timestamp) with a WHERE clause, so the database can jump straight to the right starting point using the index regardless of how many rows came before it.
Q3
Why does an in-memory rate limit bucket break down once an API is deployed across multiple replicas?
Why does an in-memory rate limit bucket break down once an API is deployed across multiple replicas?
Each replica holds its own independent in-memory map of buckets, with no shared state between them. A client's requests get load-balanced across replicas, so their actual consumption is split across several independently-tracked limits instead of one shared one — effectively multiplying their true rate limit by however many replicas are running, unless the bucket state lives in a shared store like Redis instead.