Skip to main content

REST Design — Practical Demo

Hands-on examples for REST Design. We build versioned REST endpoints, an idempotency-key dedup pattern, and a paginated list endpoint.

Prerequisites

Understand REST Design and HTTP Fundamentals before working through these examples.


Example 1: Correct Resource Naming & CRUD

A BookController that follows REST naming conventions: plural nouns, no verbs in URLs, sub-resources for relationships.

BookController.java
@RestController
@RequestMapping("/api/v1/books") // ← plural noun, versioned
@RequiredArgsConstructor
public class BookController {

private final BookService bookService;

@GetMapping // GET /api/v1/books
public Page<BookDTO> list(
@RequestParam(required = false) String author,
@PageableDefault(size = 20, sort = "title") Pageable pageable) {
return bookService.findAll(author, pageable);
}

@GetMapping("/{id}") // GET /api/v1/books/{id}
public BookDTO get(@PathVariable Long id) {
return bookService.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Book", id));
}

@PostMapping // POST /api/v1/books
public ResponseEntity<BookDTO> create(@RequestBody @Valid CreateBookRequest req) {
BookDTO book = bookService.create(req);
URI location = URI.create("/api/v1/books/" + book.id());
return ResponseEntity.created(location).body(book);
}

@DeleteMapping("/{id}") // DELETE /api/v1/books/{id}
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
bookService.delete(id);
}

// Sub-resource: GET /api/v1/books/{id}/reviews ← no verbs, nested resource
@GetMapping("/{id}/reviews")
public List<ReviewDTO> listReviews(@PathVariable Long id) {
return bookService.findReviews(id);
}
}

Key rules demonstrated:

  • Path is /books (plural noun), not /getBooks or /book
  • No verb in path — HTTP method is the verb
  • Sub-resource /books/{id}/reviews for a nested collection
Key takeaway

HTTP method + noun URL = REST. Verb URLs (/createBook, /deleteUser) are RPC style, not REST.


Example 2: URI Versioning Side-by-Side

Two controllers serving the same conceptual resource at different API versions.

BookV1Controller.java
@RestController
@RequestMapping("/api/v1/books") // ← version embedded in path
public class BookV1Controller {

@GetMapping("/{id}")
public BookV1DTO get(@PathVariable Long id) {
// V1: only title and author
return new BookV1DTO(id, "Clean Code", "Robert Martin");
}
}

record BookV1DTO(Long id, String title, String author) {}
BookV2Controller.java
@RestController
@RequestMapping("/api/v2/books") // ← v2 adds isbn and publishedYear
public class BookV2Controller {

@GetMapping("/{id}")
public BookV2DTO get(@PathVariable Long id) {
// V2: richer shape — existing clients still use /v1
return new BookV2DTO(id, "Clean Code", "Robert Martin", "9780132350884", 2008);
}
}

record BookV2DTO(Long id, String title, String author, String isbn, int publishedYear) {}

curl comparison:

curl /api/v1/books/1
# {"id":1,"title":"Clean Code","author":"Robert Martin"}

curl /api/v2/books/1
# {"id":1,"title":"Clean Code","author":"Robert Martin","isbn":"9780132350884","publishedYear":2008}
Key takeaway

V1 clients are unaffected by V2's new fields. Old and new contracts coexist until V1 is sunset.


Example 3: Idempotency Key for POST

Prevent duplicate books from being created when a client retries a failed POST.

IdempotentBookController.java
@RestController
@RequestMapping("/api/v1/books")
@RequiredArgsConstructor
public class IdempotentBookController {

private final BookService bookService;
private final IdempotencyCache idempotencyCache; // ← backed by Redis in prod

@PostMapping
public ResponseEntity<BookDTO> create(
@RequestHeader("Idempotency-Key") UUID key, // ← UUID from client
@RequestBody @Valid CreateBookRequest req) {

// Return cached result if this key was already processed
return idempotencyCache.get(key, BookDTO.class) // ← check cache first
.map(cached -> ResponseEntity.ok(cached))// ← 200 on duplicate
.orElseGet(() -> {
BookDTO book = bookService.create(req);
idempotencyCache.put(key, book); // ← store result
URI loc = URI.create("/api/v1/books/" + book.id());
return ResponseEntity.created(loc).body(book); // ← 201 on first call
});
}
}

Sequence:

# First call — creates book, returns 201
curl -X POST -H "Idempotency-Key: abc-123" /api/v1/books -d '{"title":"DDIA"}'
# → 201 Created

# Second call (network retry) — returns cached 200
curl -X POST -H "Idempotency-Key: abc-123" /api/v1/books -d '{"title":"DDIA"}'
# → 200 OK (cached, no duplicate insert)

# Different key — new creation
curl -X POST -H "Idempotency-Key: xyz-999" /api/v1/books -d '{"title":"DDIA"}'
# → 201 Created (second copy only if key is new)
Common Mistake

Forgetting to require Idempotency-Key on POST endpoints that handle financial or irreversible operations leads to duplicates when clients retry. Enforce it at the controller level, not as optional.


Exercises

  1. Easy: Add a PATCH /api/v1/books/{id} endpoint that updates only the title field.
  2. Medium: Add a V3 controller that replaces author (a String) with authors (a list of strings) — a breaking change. Show how V1/V2 are unaffected.
  3. Hard: Implement IdempotencyCache using a ConcurrentHashMap with a TTL-based expiry (hint: use a ScheduledExecutorService to prune old entries).

Back to Topic

Return to REST Design for theory, Richardson Maturity Model, HATEOAS, and interview questions.