Week 16: API Design at Scale — Versioning, Pagination & OpenAPI

Week 3 taught you to build a correct REST endpoint. It didn't teach you what happens six months later when that endpoint needs to change shape and forty other teams already depend on the old one. This week is about designing an API surface that can evolve: versioning strategies that don't break existing clients, pagination that scales past a few thousand rows, and documentation a consumer can actually rely on instead of reverse-engineering from your controller code.

Module 13 of 22 Week 16 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Choose and implement an API versioning strategy without breaking existing clients
  • Implement cursor-based pagination and consistent filtering/sorting conventions
  • Generate accurate OpenAPI documentation and apply per-client rate limiting

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).
URI versioning in Spring MVC
@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:

signaling deprecation on the old version
@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)));
}
A breaking change is defined by the consumer, not the producer

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.

offset pagination — the simple default
@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."

cursor-based pagination with a compound key
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.

pom.xml & a documented endpoint
<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.5.0</version>
</dependency>
annotating a controller for accurate docs
@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:

a per-API-key rate limit 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");
        }
    }
}
Rate limit per identity, never per instance

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

Hands-on

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:

  1. Take an existing endpoint and create a v2 under URI versioning that changes its response shape, while keeping v1 working unmodified for existing callers; add Deprecation/Sunset headers to the v1 response.
  2. 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).
  3. Add springdoc-openapi and confirm the generated Swagger UI accurately reflects your endpoints, including validation constraints and possible error responses.
  4. Add a per-API-key rate limit filter and confirm a client exceeding the limit receives a 429 with a Retry-After header.
Hint

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?

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?

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?

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.