Skip to content
Commitwise
Menu
Java Backend

Building a REST API

Build the API your mobile app talks to with Spring Boot or Jakarta EE, with controllers, DTOs, validation, status codes and clean error responses, in Java and Kotlin.

beginner

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

Your mobile app needs a list of books, the details of one book, and a way to add new ones. On the other side of every URLSession or Retrofit call there’s an API like the one you’ll build here: a controller that speaks REST, validates what it receives, and answers with the right status codes. Pick Spring or Jakarta EE with the switch above; the ideas are the same, the annotations differ.

New to some of these? Open what you need, or skip ahead.

Basics:How do HTTP requests work?

Apps and servers talk with HTTP. A request has a method that says what to do (GET to read, POST to create, PUT to replace, DELETE to remove), a URL (https://api.example.com/books/42), headers (extra information, like who you are) and sometimes a body (the data, usually JSON).

The server sends back a response with a status code that says how it went (200 OK, 201 created, 404 not found, 500 server error), headers, and usually a body. Codes starting with 4 mean the client asked for something wrong; codes starting with 5 mean the server failed.

Basics:What is JSON?

JSON is the text format most APIs use to send data. Objects are written in braces with "key": value pairs, lists in square brackets:

{ "id": 7, "name": "Mug", "price": 9.5, "tags": ["kitchen", "gift"], "discount": null }

Values can be strings (in quotes), numbers, true/false, null, objects or lists. Turning JSON into your own types is called decoding (or parsing, or deserializing); the opposite is encoding.

Basics:What do annotations like @Service mean?

An annotation (the @Something in Java and Kotlin) attaches extra information to a class, method, field or parameter. It doesn’t run code by itself; tools and frameworks read it and act on it.

@Service (Spring) or @ApplicationScoped (Jakarta EE) says “create one of these and manage it”, @GetMapping("/books") (Spring) or @GET @Path("/books") (Jakarta EE) says “call this method for GET requests to /books”, and @Entity tells Hibernate “this class is a database table”. Swift has a related idea in attributes and property wrappers, like @State and @MainActor.

Basics:What is Spring Boot?

Spring Boot is the most popular framework for Java and Kotlin backends. You write classes and mark them with annotations; Spring creates the objects (called beans), connects them to each other (dependency injection: a controller gets the service it needs through its constructor) and runs a web server that sends HTTP requests to your methods.

Starters (spring-boot-starter-web, spring-boot-starter-data-jpa…) add features by adding a dependency, with sensible defaults you can change in application.yml.

Basics:What is Jakarta EE?

Jakarta EE (formerly Java EE) is a set of standard APIs for Java backends, each with its own name: Jakarta REST for HTTP endpoints, CDI for creating objects and connecting them (dependency injection), Jakarta Persistence (JPA) for databases, Bean Validation for checking input.

You write code against the standard, and a runtime provides the implementation: an application server such as WildFly, Payara or Open Liberty, or a lighter runtime built on the same APIs, such as Quarkus or Helidon. Your classes, marked with annotations, are found and managed by that runtime.

Basics:What is a stack trace?

When a program crashes or logs an exception, it prints a stack trace: the list of function calls that were in progress at that moment, innermost first, with file names and line numbers:

java.lang.IllegalStateException: Book 42 not found
    at com.example.BookService.find(BookService.java:27)
    at com.example.BookController.get(BookController.java:18)

Read it from the top: the first line says what went wrong, the next lines say where. It’s the most useful piece of a bug report, which is why code that replaces an exception should keep the original one as its cause.

Basics:What are databases, tables and SQL?

A database stores an app’s data so it survives restarts and can be searched quickly. A relational database (PostgreSQL, MySQL) keeps data in tables: rows (one per book, user or order) and columns (id, title, year).

You talk to it with SQL: SELECT title FROM books WHERE year > 2000 reads, INSERT, UPDATE and DELETE change data. Rows refer to other tables through foreign keys (book.author_id points to a row in author), and indexes make lookups by a column fast.

Basics:What does serializing mean?

Serializing turns an object in memory into something you can store or send: a JSON string, bytes in a file, a value in Redis. Deserializing turns it back into an object.

Both sides must agree on the format. If the code that reads data was changed (a field renamed, a class restructured) while old data is still stored somewhere, deserializing can fail, which is why formats like JSON with clear field names are preferred over storing raw objects.

Part Not started

REST in one minute

Think of a library’s front desk. You don’t walk into the stacks yourself: you ask the desk. “Show me all books.” “Show me book 42.” “Here’s a new book, please add it.” “Remove book 42.” The desk answers each request and tells you how it went.

A REST APIREST APIA web API where things are resources at URLs, and HTTP methods like GET and POST say what to do with them.Open in glossary → is that desk for apps. Every thing it manages is a resource with an address (a URL like /api/books/42), and the kind of request is the HTTP method. Each answer comes with a status codeHTTP status codeThe three-digit number in every HTTP response that says how the request went.Open in glossary → that says how it went: 200 is “here you go”, 201 is “created”, 404 is “no such book”, and 400 is “your request doesn’t make sense”.

Resources as nouns, HTTP methods as verbs, status codes as the outcome, and representations (JSON) separate from storage. The parts that matter in production: correct status codes and Location headers, idempotencyIdempotencyAn operation is idempotent if doing it twice has the same effect as doing it once.Open in glossary → semantics (clients retry on flaky mobile networks), a consistent error format, and a contract that old app versions can rely on for years. Those are in the Detailed version.

Method and pathMeaningSuccess status
GET /api/booksList books200 OK
GET /api/books/42Get one book200 OK (404 if missing)
POST /api/booksCreate a book201 Created + Location header
PUT /api/books/42Replace a book200 OK or 204 No Content
DELETE /api/books/42Delete a book204 No Content

The request and response shapes

Define what goes over the wire as small, separate classes: DTOsDTOData Transfer Object: a simple class that describes exactly what an API sends or receives.Open in glossary →. They’re not your database entities. The validation annotations (@NotBlank, @Min) come from Bean Validation (jakarta.validation), the same in Spring and Jakarta EE.

data class CreateBookRequest(
    @field:NotBlank val title: String,
    @field:NotBlank val author: String,
    @field:Min(1450) val year: Int,
)

data class BookResponse(val id: Long, val title: String, val author: String, val year: Int)
record CreateBookRequest(
    @NotBlank String title,
    @NotBlank String author,
    @Min(1450) int year
) {}

record BookResponse(long id, String title, String author, int year) {}

Kotlin note

@field: puts the annotation on the backing field, where the validator looks for it. Without it, Kotlin puts it on the constructor parameter and the constraint is silently ignored.

The controller

@RestController
@RequestMapping("/api/books")
class BookController(private val books: BookService) {

    @GetMapping
    fun list(): List<BookResponse> = books.findAll()

    @GetMapping("/{id}")
    fun get(@PathVariable id: Long): BookResponse = books.findById(id)

    @PostMapping
    fun create(@Valid @RequestBody request: CreateBookRequest): ResponseEntity<BookResponse> {
        val created = books.create(request)
        return ResponseEntity.created(URI.create("/api/books/${created.id}")).body(created)
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    fun delete(@PathVariable id: Long) = books.delete(id)
}
@RestController
@RequestMapping("/api/books")
class BookController {

    private final BookService books;

    BookController(BookService books) {
        this.books = books;
    }

    @GetMapping
    List<BookResponse> list() {
        return books.findAll();
    }

    @GetMapping("/{id}")
    BookResponse get(@PathVariable long id) {
        return books.findById(id);
    }

    @PostMapping
    ResponseEntity<BookResponse> create(@Valid @RequestBody CreateBookRequest request) {
        BookResponse created = books.create(request);
        return ResponseEntity.created(URI.create("/api/books/" + created.id())).body(created);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    void delete(@PathVariable long id) {
        books.delete(id);
    }
}
  1. @RestController says “this class answers HTTP requests, and return values become JSON”.
  2. @RequestMapping("/api/books") is the base path for every method in the class.
  3. @GetMapping, @PostMapping, @DeleteMapping connect a method to an HTTP method and path. {id} in the path arrives as the @PathVariable.
  4. @RequestBody turns the JSON the app sent into a CreateBookRequest, and @Valid checks its annotations before your code runs.
  5. ResponseEntity.created(…) answers 201 with a Location header pointing at the new book.

The BookService is passed into the constructor: that’s dependency injectionDependency injectionGiving an object the things it needs from outside, instead of letting it create them itself.Open in glossary →, and Spring does the wiring for you.

@Path("/api/books")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RequestScoped
public class BookResource {

    @Inject
    BookService books;

    @GET
    public List<BookResponse> list() {
        return books.findAll();
    }

    @GET
    @Path("/{id}")
    public BookResponse get(@PathParam("id") long id) {
        return books.findById(id);
    }

    @POST
    public Response create(@Valid CreateBookRequest request) {
        BookResponse created = books.create(request);
        return Response.created(URI.create("/api/books/" + created.id())).entity(created).build();
    }

    @DELETE
    @Path("/{id}")
    public void delete(@PathParam("id") long id) {
        books.delete(id);
    }
}

@ApplicationPath("/")
public class BookApi extends Application {}

Kotlin note

Jakarta EE examples are in Java, the language almost all Jakarta EE code is written in. The same annotations work from Kotlin; add the kotlin-allopen and kotlin-noarg (or kotlin-jpa) compiler plugins, because CDI proxies need open classes and JPA entities need a no-argument constructor.

  1. @Path("/api/books") makes the class a resource (Jakarta REST’s name for a controller) and sets the base path for its methods.
  2. @Produces / @Consumes say that it answers with JSON and accepts JSON.
  3. @GET, @POST, @DELETE connect a method to an HTTP method; a method-level @Path("/{id}") adds to the path, and {id} arrives as the @PathParam.
  4. A parameter without an annotation is the request body: the JSON becomes a CreateBookRequest, and @Valid checks its annotations before your code runs.
  5. Response.created(…) answers 201 with a Location header. A void method answers 204 No Content on its own.
  6. @ApplicationPath on an Application subclass turns the REST endpoints on (some runtimes, like Quarkus, don’t need it).

@Inject asks CDI for the BookService: that’s dependency injectionDependency injectionGiving an object the things it needs from outside, instead of letting it create them itself.Open in glossary →, and the runtime does the wiring for you. @RequestScoped means a fresh resource object per request.

Predict the output

The app sends POST /api/books with {"title": "", "author": "George Orwell", "year": 1949}. What does it get back?

@PostMapping
fun create(@Valid @RequestBody request: CreateBookRequest): ResponseEntity<BookResponse> { … }

data class CreateBookRequest(
    @field:NotBlank val title: String,
    @field:NotBlank val author: String,
    @field:Min(1450) val year: Int,
)
@PostMapping
ResponseEntity<BookResponse> create(@Valid @RequestBody CreateBookRequest request) { … }

record CreateBookRequest(@NotBlank String title, @NotBlank String author, @Min(1450) int year) {}

Predict the output

The app sends POST /api/books with {"title": "", "author": "George Orwell", "year": 1949}. What does it get back?

@POST
public Response create(@Valid CreateBookRequest request) { … }

record CreateBookRequest(@NotBlank String title, @NotBlank String author, @Min(1450) int year) {}

The service behind the controller

The controller calls books.findById(id), but where does the book come from? A small service talks to the database through a Spring Data repository and turns entities into DTOs:

@Entity
class Book(
    var title: String,
    var author: String,
    var year: Int,
    @Id @GeneratedValue var id: Long? = null,
)

interface BookRepository : JpaRepository<Book, Long>

@Service
class BookService(private val repository: BookRepository) {

    fun findById(id: Long): BookResponse =
        repository.findByIdOrNull(id)?.toResponse() ?: throw BookNotFoundException(id)

    fun create(request: CreateBookRequest): BookResponse =
        repository.save(Book(request.title, request.author, request.year)).toResponse()

    private fun Book.toResponse() = BookResponse(id!!, title, author, year)
}
@Entity
class Book {
    @Id @GeneratedValue Long id;
    String title;
    String author;
    int year;
}

interface BookRepository extends JpaRepository<Book, Long> {}

@Service
class BookService {

    private final BookRepository repository;

    BookService(BookRepository repository) {
        this.repository = repository;
    }

    BookResponse findById(long id) {
        Book book = repository.findById(id)
            .orElseThrow(() -> new BookNotFoundException(id));
        return toResponse(book);
    }

    BookResponse create(CreateBookRequest request) {
        Book book = new Book();
        book.title = request.title();
        book.author = request.author();
        book.year = request.year();
        return toResponse(repository.save(book));
    }

    private static BookResponse toResponse(Book book) {
        return new BookResponse(book.id, book.title, book.author, book.year);
    }
}

Follow one request, GET /api/books/42, through the layers:

  1. Spring matches the path to get(@PathVariable long id) and converts "42" to a number.
  2. The controller does no work itself: it calls the service and returns what it gets.
  3. The service asks the repository for book 42. You never wrote findById: JpaRepository gives you it, along with save, findAll, deleteById and more.
  4. No book? The service throws BookNotFoundException, and ApiErrors turns it into a 404.
  5. Found it? The service converts the entity to a BookResponse, and Spring writes it as JSON with status 200.

Each layer has one job: the controller speaks HTTP, the service holds the rules, the repository talks to the database. When something breaks, you know where to look.

The resource calls books.findById(id), but where does the book come from? A small service talks to the database through JPA’s EntityManager and turns entities into DTOs:

@Entity
public class Book {
    @Id @GeneratedValue Long id;
    String title;
    String author;
    int year;
}

@ApplicationScoped
public class BookService {

    @PersistenceContext
    EntityManager em;

    public BookResponse findById(long id) {
        Book book = em.find(Book.class, id);
        if (book == null) throw new BookNotFoundException(id);
        return toResponse(book);
    }

    @Transactional
    public BookResponse create(CreateBookRequest request) {
        Book book = new Book();
        book.title = request.title();
        book.author = request.author();
        book.year = request.year();
        em.persist(book);
        return toResponse(book);
    }

    private static BookResponse toResponse(Book book) {
        return new BookResponse(book.id, book.title, book.author, book.year);
    }
}
Dev note: Repositories in Jakarta EE
Jakarta EE 11 added Jakarta Data: declare @Repository interface BookRepository extends CrudRepository<Book, Long> and the runtime writes findById, save and queries from method names, much like Spring Data. On older versions (and in most existing code) the EntityManager is used directly, as here. Quarkus injects it with plain @Inject.

Follow one request, GET /api/books/42, through the layers:

  1. Jakarta REST matches the path to get(@PathParam("id") long id) and converts "42" to a number.
  2. The resource does no work itself: it calls the service and returns what it gets.
  3. The service asks the EntityManager for book 42. em.find returns null when there’s no such row.
  4. No book? The service throws BookNotFoundException, and BookNotFoundMapper turns it into a 404.
  5. Found it? The service converts the entity to a BookResponse, and the runtime writes it as JSON with status 200.

Each layer has one job: the resource speaks HTTP, the service holds the rules, the EntityManager talks to the database. @Transactional on create wraps the insert in a database transaction. When something breaks, you know where to look.

Predict the output

The app calls GET /api/books/abc. What does it get back?

Predict the output

The app calls GET /api/books/abc. What does it get back?

Spot the bugA missing book crashes the server

Tap the line with the bug.

fun findById(id: Long): BookResponse {
    val book = repository.findById(id).get()
    return book.toResponse()
}
BookResponse findById(long id) {
    Book book = repository.findById(id).get();
    return toResponse(book);
}

Spot the bugA missing book crashes the server

Tap the line with the bug.

public BookResponse findById(long id) {
    Book book = em.find(Book.class, id);
    return toResponse(book);
}

Exerciseeasy

Search books by author

The app wants a search box: GET /api/books?author=orwell should return only the books whose author contains “orwell”, ignoring upper and lower case. Without the author parameter, GET /api/books should keep returning all books.

Change the controller, the service and the repository.

›

Read a query parameter with @RequestParam. Make it optional with required = false in Java, or a nullable String? in Kotlin.

Read a query parameter with @QueryParam("author"). When the app doesn’t send it, the parameter is null.

›

Spring Data can write the query for you from a method name: findByAuthorContainingIgnoreCase(String author) becomes WHERE LOWER(author) LIKE LOWER('%orwell%').

Write the query in JPQL, the object version of SQL: select b from Book b where lower(b.author) like :pattern, and pass "%" + author.toLowerCase() + "%" as the parameter.

SolutionTry it yourself first
interface BookRepository : JpaRepository<Book, Long> {
    fun findByAuthorContainingIgnoreCase(author: String): List<Book>
}

// BookService
fun search(author: String?): List<BookResponse> {
    val found = if (author.isNullOrBlank()) {
        repository.findAll()
    } else {
        repository.findByAuthorContainingIgnoreCase(author.trim())
    }
    return found.map { it.toResponse() }
}

// BookController: replaces list()
@GetMapping
fun list(@RequestParam author: String?): List<BookResponse> = books.search(author)
interface BookRepository extends JpaRepository<Book, Long> {
    List<Book> findByAuthorContainingIgnoreCase(String author);
}

// BookService
List<BookResponse> search(String author) {
    List<Book> found = author == null || author.isBlank()
        ? repository.findAll()
        : repository.findByAuthorContainingIgnoreCase(author.trim());
    return found.stream().map(BookService::toResponse).toList();
}

// BookController: replaces list()
@GetMapping
List<BookResponse> list(@RequestParam(required = false) String author) {
    return books.search(author);
}

Notice what you didn’t need: a new URL. Filters on a collection belong in query parameters (/api/books?author=orwell), not in the path (/api/books/author/orwell), and you can add more later (&year=1949) without breaking old apps.

Try it with curl -i 'localhost:8080/api/books?author=ORWELL'. With thousands of books you’d also add paging with Spring Data’s Pageable (the Senior version covers it).

// BookService
public List<BookResponse> search(String author) {
    List<Book> found = author == null || author.isBlank()
        ? em.createQuery("select b from Book b", Book.class).getResultList()
        : em.createQuery("select b from Book b where lower(b.author) like :pattern", Book.class)
            .setParameter("pattern", "%" + author.trim().toLowerCase() + "%")
            .getResultList();
    return found.stream().map(BookService::toResponse).toList();
}

// BookResource: replaces list()
@GET
public List<BookResponse> list(@QueryParam("author") String author) {
    return books.search(author);
}

Notice what you didn’t need: a new URL. Filters on a collection belong in query parameters (/api/books?author=orwell), not in the path (/api/books/author/orwell), and you can add more later (&year=1949) without breaking old apps. And the search text goes in as a parameter (:pattern), never glued into the query string, which would allow SQL injection.

Try it with curl -i 'localhost:8080/api/books?author=ORWELL'. With thousands of books you’d also add paging (the Senior version covers it).

Status codes and retries

  • 201 + Location for creates, 204 for deletes and updates with no body, 409 Conflict for uniqueness violations, 404 for missing resources. Pick 400 or 422 for validation and use it consistently.
  • Idempotency. GET, PUT and DELETE must be safe to repeat; POST isn’t. Mobile clients retry on timeouts, so a retried POST /orders can create two orders. For anything with money involved, accept an Idempotency-Key header, store the key with the result, and return the stored result on a repeat.
  • Turn on problem details for Spring’s own errors with spring.mvc.problemdetails.enabled=true, so a malformed JSON body or a missing parameter uses the same format as your handlers.
  • Map the framework’s own errors too. Malformed JSON, a wrong Content-Type or a failed validation come out of the runtime in its own format. Add ExceptionMappers for WebApplicationException and ConstraintViolationException (with the field errors in the body) so every error uses your problem format.

Contracts that outlive app versions

Old app versions stay installed for years. Treat the API as a public contract:

  • Only additive changes: new optional fields, new endpoints. Never rename or remove a field, and never change its meaning. Clients should ignore unknown fields (Jackson does by default in Spring Boot).
  • Only additive changes: new optional fields, new endpoints. Never rename or remove a field, and never change its meaning. Clients should ignore unknown fields (JSON-B, the default JSON binding in Jakarta EE, does).
  • Version deliberately (/api/v2/... or a media type) when you must break something, and keep v1 running until analytics says it’s unused.
  • Paginate from day one. GET /api/books?page=0&size=20&sort=title with Spring Data’s Pageable, returning your own page DTO (items, page, size, total) rather than serializing Spring’s Page implementation, whose JSON shape isn’t a stable contract.
  • Paginate from day one. GET /api/books?page=0&size=20 with @QueryParam("page") @DefaultValue("0") int page and setFirstResult / setMaxResults on the JPA query (or PageRequest with Jakarta Data in Jakarta EE 11), returning your own page DTO: items, page, size, total.

Throughput

On Java 21+ with Spring Boot 3.2+, spring.threads.virtual.enabled=true serves each request on a virtual thread.

On Java 21+, Jakarta EE 11 lets managed executors and thread factories create virtual threads (virtual = true in Jakarta Concurrency 3.1), and runtimes add their own switch for requests, such as Quarkus’s @RunOnVirtualThread.

With virtual threads, blocking calls (JDBC, HTTP clients) no longer pin a platform thread, which raises the number of concurrent slow requests you can handle without moving to a reactive stack. Watch for synchronized blocks around I/O in older libraries, which can still pin the carrier thread on some JDK versions.

Newer apps, older servers

Apps and servers are deployed independently, so at any moment some app sends fields the server has never heard of:

Predict the output

A newer app sends {"title": "1984", "author": "George Orwell", "year": 1949, "subtitle": "A Novel"} to a server that doesn't know subtitle. What does it get back?

Predict the output

A newer app sends {"title": "1984", "author": "George Orwell", "year": 1949, "subtitle": "A Novel"} to a server that doesn't know subtitle. What does it get back?

The query count nobody looked at

Spot the bugAn authors endpoint that gets slower every month

Tap the line with the bug.

@Transactional(readOnly = true)
fun authors(): List<AuthorSummary> =
    authorRepository.findAll()
        .map { AuthorSummary(it.name, it.books.size) }
@Transactional(readOnly = true)
public List<AuthorSummary> authors() {
    return authorRepository.findAll().stream()
        .map(a -> new AuthorSummary(a.getName(), a.getBooks().size()))
        .toList();
}

Spot the bugAn authors endpoint that gets slower every month

Tap the line with the bug.

@Transactional
public List<AuthorSummary> authors() {
    return em.createQuery("select a from Author a", Author.class).getResultList().stream()
        .map(a -> new AuthorSummary(a.getName(), a.getBooks().size()))
        .toList();
}

  • Status codes: 201 + Location for creates, 204 for empty success, 404, 409 for conflicts, one of 400/422 for validation used consistently.
  • The contract outlives app versions: add optional fields, never rename or tighten; decoders on both sides must ignore unknown fields.
  • Transactions and other interceptors apply through a proxy: a call from inside the same bean skips them (Spring, and standard CDI; Quarkus intercepts self-calls).

Check this part

  1. Which status code should a successful POST that creates a book return?

  2. Why use separate DTOs instead of returning database entities?

  3. BookService.importAll() calls this.save(book), and save is a public @Transactional method. Does save run in its own transaction?

  4. Which change is safe for app versions that are already installed?

Resources have URLs, HTTP methods say what to do, status codes say how it went. Small DTOs with validation define what goes over the wire, and a thin controller maps requests to a service.

Part Not started

Errors the app can understand

When book 42 doesn’t exist, the app should get a 404 with a readable body, not a 500 and a stack trace. Throw a specific exception and translate it in one place:

class BookNotFoundException(id: Long) : RuntimeException("Book $id not found")

@RestControllerAdvice
class ApiErrors {

    @ExceptionHandler(BookNotFoundException::class)
    fun notFound(e: BookNotFoundException): ProblemDetail =
        ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.message ?: "Not found")
}
class BookNotFoundException extends RuntimeException {
    BookNotFoundException(long id) {
        super("Book " + id + " not found");
    }
}

@RestControllerAdvice
class ApiErrors {

    @ExceptionHandler(BookNotFoundException.class)
    ProblemDetail notFound(BookNotFoundException e) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
    }
}

The response uses the standard “problem details” format (application/problem+json), so every error from your API has the same shape:

public class BookNotFoundException extends RuntimeException {
    public BookNotFoundException(long id) {
        super("Book " + id + " not found");
    }
}

public record Problem(String type, String title, int status, String detail) {}

@Provider
public class BookNotFoundMapper implements ExceptionMapper<BookNotFoundException> {

    @Override
    public Response toResponse(BookNotFoundException e) {
        return Response.status(Response.Status.NOT_FOUND)
            .type("application/problem+json")
            .entity(new Problem("about:blank", "Not Found", 404, e.getMessage()))
            .build();
    }
}

An ExceptionMapper marked @Provider is found by the runtime and used for every resource. Jakarta REST has no built-in class for problem details, so a small record does the job; the response uses the standard “problem details” format (application/problem+json), so every error from your API has the same shape:

{
  "type": "about:blank",
  "title": "Not Found",
  "status": 404,
  "detail": "Book 42 not found"
}
Spot the bugBlank books in the database

Tap the line with the bug.

@PostMapping
fun create(@RequestBody request: CreateBookRequest): ResponseEntity<BookResponse> {
    val created = books.create(request)
    return ResponseEntity.created(URI.create("/api/books/" + created.id)).body(created)
}
@PostMapping
ResponseEntity<BookResponse> create(@RequestBody CreateBookRequest request) {
    BookResponse created = books.create(request);
    return ResponseEntity.created(URI.create("/api/books/" + created.id())).body(created);
}

Spot the bugBlank books in the database

Tap the line with the bug.

@POST
public Response create(CreateBookRequest request) {
    BookResponse created = books.create(request);
    return Response.created(URI.create("/api/books/" + created.id())).entity(created).build();
}

Try it

Run the app and call it from a terminal with curl (or from Postman, or the HTTP client in IntelliJ):

curl -i -X POST localhost:8080/api/books \
  -H 'Content-Type: application/json' \
  -d '{"title": "1984", "author": "George Orwell", "year": 1949}'

-i shows the status line and headers, so you can check for HTTP/1.1 201 and the Location header. Then try a blank title, a year of 1200, and GET /api/books/999, and look at each status code.

Testing the web layer

@WebMvcTest(BookController::class)
class BookControllerTest(@Autowired val mvc: MockMvc) {

    @MockitoBean
    lateinit var books: BookService

    @Test
    fun `rejects blank title`() {
        mvc.post("/api/books") {
            contentType = MediaType.APPLICATION_JSON
            content = """{"title": "", "author": "George Orwell", "year": 1949}"""
        }.andExpect { status { isBadRequest() } }
        verifyNoInteractions(books)
    }
}
@WebMvcTest(BookController.class)
class BookControllerTest {

    @Autowired MockMvc mvc;
    @MockitoBean BookService books;

    @Test
    void rejectsBlankTitle() throws Exception {
        mvc.perform(post("/api/books")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {"title": "", "author": "George Orwell", "year": 1949}
                    """))
            .andExpect(status().isBadRequest());
        verifyNoInteractions(books);
    }
}

@WebMvcTest starts only the web layer (controllers, advice, validation, JSON), so it’s fast and tests exactly the contract: status codes, validation and error bodies.

Dev note
@MockitoBean replaced @MockBean in Spring Boot 3.4; on older versions use @MockBean.
@QuarkusTest   // or an Arquillian test deployed to your application server
class BookResourceTest {

    @Test
    void rejectsBlankTitle() {
        given()
            .contentType(ContentType.JSON)
            .body("""
                {"title": "", "author": "George Orwell", "year": 1949}
                """)
        .when()
            .post("/api/books")
        .then()
            .statusCode(400);
    }
}

Jakarta EE has no standard test slice: you test against a running application. Quarkus starts it in-process with @QuarkusTest; on WildFly, Payara or Open Liberty, Arquillian or Testcontainers deploys it to a real server. Either way the test speaks HTTP, so it checks exactly the contract: status codes, validation and error bodies.

Exercisemedium

Idempotent order creation

The mobile app creates orders with POST /api/orders. On a bad connection the request times out after the server already saved the order, the app retries, and the customer is charged twice.

Design idempotency keys end to end:

  1. What does the client send, and when does it generate a new key?
  2. What does the server store, and in which order does it do things?
  3. What happens with two identical requests at the same time, and with the same key but a different body?
›

The key identifies the user’s intent (“place this order”), not the HTTP attempt. Retries reuse it; a new tap on “Buy” after editing the cart gets a new one.

›

A unique constraint does the hard part: insert the key first, inside the same transaction as the order, and let the database reject the second insert. Store a hash of the request body to detect a reused key with a different request.

SolutionTry it yourself first
@PostMapping("/api/orders")
ResponseEntity<OrderResponse> create(@RequestHeader("Idempotency-Key") String key,
                                     @Valid @RequestBody CreateOrderRequest request,
                                     @AuthenticationPrincipal User user) {
    String hash = sha256(canonicalJson(request));
    StoredResponse stored = idempotency.begin(user.id(), key, hash);   // insert IN_PROGRESS, or read the existing row
    return switch (stored.state()) {
        case NEW -> {
            OrderResponse order = orders.create(user, request);          // same transaction as completing the key
            idempotency.complete(user.id(), key, 201, order);
            yield ResponseEntity.status(201).body(order);
        }
        case COMPLETED -> {
            if (!stored.hash().equals(hash)) {
                throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Idempotency-Key reused with a different request");
            }
            yield ResponseEntity.status(stored.code()).body(stored.body(OrderResponse.class));
        }
        case IN_PROGRESS -> throw new ResponseStatusException(HttpStatus.CONFLICT, "Request with this key is still being processed");
    };
}
create table idempotency_keys (
    user_id       bigint      not null,
    key           varchar(64) not null,
    request_hash  char(64)    not null,
    status        varchar(16) not null,          -- IN_PROGRESS, COMPLETED
    response_code int,
    response_body jsonb,
    created_at    timestamptz not null default now(),
    primary key (user_id, key)
);
@POST
@Path("/api/orders")
public Response create(@HeaderParam("Idempotency-Key") String key,
                       @Valid CreateOrderRequest request,
                       @Context SecurityContext security) {
    User user = users.current(security);
    String hash = sha256(canonicalJson(request));
    StoredResponse stored = idempotency.begin(user.id(), key, hash);   // insert IN_PROGRESS, or read the existing row
    return switch (stored.state()) {
        case NEW -> {
            OrderResponse order = orders.create(user, request);          // same transaction as completing the key
            idempotency.complete(user.id(), key, 201, order);
            yield Response.status(201).entity(order).build();
        }
        case COMPLETED -> {
            if (!stored.hash().equals(hash)) {
                throw new WebApplicationException("Idempotency-Key reused with a different request", 422);
            }
            yield Response.status(stored.code()).entity(stored.body(OrderResponse.class)).build();
        }
        case IN_PROGRESS -> throw new WebApplicationException("Request with this key is still being processed", Response.Status.CONFLICT);
    };
}
create table idempotency_keys (
    user_id       bigint      not null,
    key           varchar(64) not null,
    request_hash  char(64)    not null,
    status        varchar(16) not null,          -- IN_PROGRESS, COMPLETED
    response_code int,
    response_body jsonb,
    created_at    timestamptz not null default now(),
    primary key (user_id, key)
);

The rules behind it:

  • Client: generate a UUID when the user confirms the order, persist it with the pending order (so it survives an app restart), and reuse it for every retry. Generate a new one only for a new intent.
  • Server, in order: insert the key as IN_PROGRESS (the primary key makes concurrent duplicates fail here), create the order and charge, then store the response and mark it COMPLETED, in one transaction where possible. If the payment provider is external, pass the same key on to it (Stripe and Adyen accept idempotency keys) so the charge is deduplicated too.
  • Concurrent duplicate: the second insert hits the unique key and gets 409 Conflict while the first is running; the client retries with backoff and then receives the stored response.
  • Same key, different body: 422, because silently returning the old order for a changed cart is worse than an error.
  • Scope and lifetime: keys are per user (one user can’t replay another’s key), and rows expire after a day or two, longer than any client retries.

Deep dives

Deep diveSafe and idempotent methodsInterview favorite

HTTP (RFC 9110) gives each method two properties that clients, proxies and retries rely on. Safe methods don’t change state on the server: GET, HEAD, OPTIONS, TRACE. Idempotent methods have the same effect whether they’re sent once or many times: all safe methods, plus PUT and DELETE.

POST is neither, which is why a retried POST /orders can create two orders, and why APIs add an Idempotency-Key header. PATCH isn’t idempotent in general ({"op": "increment"}), but can be designed to be. A GET that changes data breaks crawlers, prefetching and caches, which all assume it’s safe.

NeedSpring
JSON controller@RestController + @RequestMapping
Create with 201ResponseEntity.created(uri).body(dto)
No content@ResponseStatus(HttpStatus.NO_CONTENT)
Validate body@Valid @RequestBody + spring-boot-starter-validation (Kotlin: @field:)
Central errors@RestControllerAdvice + @ExceptionHandler returning ProblemDetail
Spring’s own errors as problem detailsspring.mvc.problemdetails.enabled=true
Web slice test@WebMvcTest + MockMvc

Gotchas: missing @Valid or validator dependency means no validation. Kotlin constraints need @field:. Don’t return entities. POST retries need idempotency keys. Never remove fields that shipped app versions read.

@PostMapping
fun create(
    @RequestHeader("Idempotency-Key") key: String,
    @Valid @RequestBody request: CreateBookRequest,
): ResponseEntity<BookResponse> {
    // Stores the result per key; a retry with the same key gets the same book back.
    val created = idempotency.once(key) { books.create(request) }
    return ResponseEntity.created(URI.create("/api/books/${created.id}")).body(created)
}
@PostMapping
ResponseEntity<BookResponse> create(
        @RequestHeader("Idempotency-Key") String key,
        @Valid @RequestBody CreateBookRequest request) {
    // Stores the result per key; a retry with the same key gets the same book back.
    BookResponse created = idempotency.once(key, () -> books.create(request));
    return ResponseEntity.created(URI.create("/api/books/" + created.id())).body(created);
}
NeedJakarta EE
JSON resource@Path + @Produces / @Consumes(MediaType.APPLICATION_JSON)
Create with 201Response.created(uri).entity(dto).build()
No contenta void method (204)
Validate body@Valid on the parameter (Bean Validation ships with the server)
Central errors@Provider + ExceptionMapper<E>, with your own problem-details record
The runtime’s own errorsmappers for WebApplicationException and ConstraintViolationException
Path that doesn’t convert (/books/abc for a long)404, not 400
HTTP test@QuarkusTest or Arquillian + REST Assured

Gotchas: missing @Valid means no validation. Path parameters that don’t convert give 404. @Transactional on a method called via this is skipped (except on Quarkus). Don’t return entities. POST retries need idempotency keys. Never remove fields that shipped app versions read.

@POST
public Response create(@HeaderParam("Idempotency-Key") String key, @Valid CreateBookRequest request) {
    // Stores the result per key; a retry with the same key gets the same book back.
    BookResponse created = idempotency.once(key, () -> books.create(request));
    return Response.created(URI.create("/api/books/" + created.id())).entity(created).build();
}

Check this part

  1. A request body has @NotBlank fields, but blank values still get saved. What is missing?

  2. Book 42 doesn't exist. What should the app receive?

  3. Which of these methods is NOT idempotent?

  4. The app's POST /orders times out and the app retries. How do you avoid a duplicate order?

Throw specific exceptions and turn them into one consistent error format (problem details) in one place. Validation only runs when @Valid triggers it, and anything that may be retried must be safe to repeat.

Key takeaways

  • REST: resources at URLs, HTTP methods as actions, status codes as results.
  • Use request and response DTOs, never entities, and validate requests with @Valid.
  • Return 201 with a Location for creates and 204 for deletes; map exceptions to one problem-details format in one place (@RestControllerAdvice in Spring, ExceptionMappers in Jakarta EE).
  • Old app versions live for years: only additive API changes.
  • Clients retry: keep GET, PUT and DELETE idempotent by design, and give POST an idempotency key.
  • The API contract outlives every release: additive changes only, and a new version when you must break it.
  • @Transactional and friends work through proxies (Spring) or interceptors (CDI): only on calls that come from other beans, not on this.method() (Quarkus being a non-standard exception).
  • Test the HTTP contract (@WebMvcTest in Spring, @QuarkusTest or Arquillian with REST Assured in Jakarta EE); keep entities and transactions in the service layer.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.