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 path | Meaning | Success status |
|---|---|---|
GET /api/books | List books | 200 OK |
GET /api/books/42 | Get one book | 200 OK (404 if missing) |
POST /api/books | Create a book | 201 Created + Location header |
PUT /api/books/42 | Replace a book | 200 OK or 204 No Content |
DELETE /api/books/42 | Delete a book | 204 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);
}
}@RestControllersays “this class answers HTTP requests, and return values become JSON”.@RequestMapping("/api/books")is the base path for every method in the class.@GetMapping,@PostMapping,@DeleteMappingconnect a method to an HTTP method and path.{id}in the path arrives as the@PathVariable.@RequestBodyturns the JSON the app sent into aCreateBookRequest, and@Validchecks its annotations before your code runs.ResponseEntity.created(…)answers 201 with aLocationheader 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.
@Path("/api/books")makes the class a resource (Jakarta REST’s name for a controller) and sets the base path for its methods.@Produces/@Consumessay that it answers with JSON and accepts JSON.@GET,@POST,@DELETEconnect a method to an HTTP method; a method-level@Path("/{id}")adds to the path, and{id}arrives as the@PathParam.- A parameter without an annotation is the request body: the JSON becomes a
CreateBookRequest, and@Validchecks its annotations before your code runs. Response.created(…)answers 201 with aLocationheader. Avoidmethod answers 204 No Content on its own.@ApplicationPathon anApplicationsubclass 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) {}@Valid runs the constraints on CreateBookRequest before the method body. @NotBlank fails for the empty title, Spring throws MethodArgumentNotValidException, and the client gets 400 Bad Request. Your service is never called.
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) {}@Valid runs the constraints on CreateBookRequest before the method body. @NotBlank fails for the empty title, Bean Validation throws a ConstraintViolationException, and Jakarta REST answers 400 Bad Request (the specification says so for invalid parameters). Your service is never called.
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:
- Spring matches the path to
get(@PathVariable long id)and converts"42"to a number. - The controller does no work itself: it calls the service and returns what it gets.
- The service asks the repository for book 42. You never wrote
findById:JpaRepositorygives you it, along withsave,findAll,deleteByIdand more. - No book? The service throws
BookNotFoundException, andApiErrorsturns it into a 404. - 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
@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:
- Jakarta REST matches the path to
get(@PathParam("id") long id)and converts"42"to a number. - The resource does no work itself: it calls the service and returns what it gets.
- The service asks the
EntityManagerfor book 42.em.findreturnsnullwhen there’s no such row. - No book? The service throws
BookNotFoundException, andBookNotFoundMapperturns it into a 404. - 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?
Spring tries to convert abc into the long id parameter before your method runs. The conversion fails, and Spring answers 400 Bad Request on its own: the client sent something malformed. Your method is never called, so the service can't return 404. That's correct: 404 means "this book doesn't exist", while 400 means "that's not even a valid book id".
Predict the output
The app calls GET /api/books/abc. What does it get back?
Jakarta REST tries to convert abc into the long id path parameter, fails, and answers 404 Not Found on its own: the specification treats a path that doesn't convert as a path that doesn't exist. (A bad @HeaderParam gives 400 instead.) Your method is never called. If your clients need to tell "no such book" from "not a valid id", take the id as a String and validate it yourself, or add a regular expression to the path: @Path("/{id: [0-9]+}").
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);
}Hint: repository.findById returns an Optional. What does get() do when it's empty?
findById returns an Optional<Book>, and get() on an empty Optional throws NoSuchElementException. No handler knows that exception, so the app gets a 500 and a generic error instead of a clear 404, and your logs fill up with stack traces for a perfectly normal situation (someone asked for a book that was deleted).
Say what “missing” means right where it can happen:
val book = repository.findByIdOrNull(id) ?: throw BookNotFoundException(id)Book book = repository.findById(id)
.orElseThrow(() -> new BookNotFoundException(id));It’s the same lesson as force unwrapping in the null safety tutorial: get() is Java’s !!.
Tap the line with the bug.
public BookResponse findById(long id) {
Book book = em.find(Book.class, id);
return toResponse(book);
}Hint: What does em.find return when there's no row with that id?
em.find returns null for a missing row, and toResponse(book) reads book.id from it, which throws NullPointerException. No mapper knows that exception, so the app gets a 500 and a generic error instead of a clear 404, and your logs fill up with stack traces for a perfectly normal situation (someone asked for a book that was deleted).
Say what “missing” means right where it can happen:
Book book = em.find(Book.class, id);
if (book == null) throw new BookNotFoundException(id);It’s the same lesson as in the null safety tutorial: a lookup that can find nothing has to be checked before use.
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 +
Locationfor 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,PUTandDELETEmust be safe to repeat;POSTisn’t. Mobile clients retry on timeouts, so a retriedPOST /orderscan create two orders. For anything with money involved, accept anIdempotency-Keyheader, 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-Typeor a failed validation come out of the runtime in its own format. AddExceptionMappers forWebApplicationExceptionandConstraintViolationException(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=titlewith Spring Data’sPageable, returning your own page DTO (items, page, size, total) rather than serializing Spring’sPageimplementation, whose JSON shape isn’t a stable contract.
- Paginate from day one.
GET /api/books?page=0&size=20with@QueryParam("page") @DefaultValue("0") int pageandsetFirstResult/setMaxResultson the JPA query (orPageRequestwith 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?
Spring Boot configures Jackson with FAIL_ON_UNKNOWN_PROPERTIES turned off, so unknown fields are dropped silently. That's the tolerant behavior you want for forward compatibility, but be aware of two consequences: a plain new ObjectMapper() in your own code (or a test) fails on the same JSON, and a typo in a field name from the client is also silently ignored, so validate required fields explicitly.
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?
JSON-B, the default JSON binding in Jakarta EE, ignores properties it doesn't know, so subtitle is dropped silently. That's the tolerant behavior you want for forward compatibility, with two catches: if your runtime uses Jackson instead (some do), check its FAIL_ON_UNKNOWN_PROPERTIES setting, and a typo in a field name from the client is also silently ignored, so validate required fields explicitly.
The query count nobody looked at
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();
}Hint: books is a lazy @OneToMany. How many SQL statements run for 500 authors?
findAll() runs one query, then getBooks() loads each author’s lazy collection with one more query per author: 501 queries for 500 authors (the N+1 problem). It’s fast in development with 10 rows and degrades linearly in production, and it also loads every book entity just to count them.
Ask the database for exactly what the response needs:
interface AuthorRepository : JpaRepository<Author, Long> {
@Query("""
select new com.example.AuthorSummary(a.name, count(b))
from Author a left join a.books b
group by a.id, a.name
""")
fun summaries(): List<AuthorSummary>
}public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query("""
select new com.example.AuthorSummary(a.name, count(b))
from Author a left join a.books b
group by a.id, a.name
""")
List<AuthorSummary> summaries();
}When you do need the entities, fetch the association in the same query (@EntityGraph(attributePaths = ["books"]) or join fetch). And make the problem visible: log the SQL count per request in tests (Hibernate statistics, or a datasource proxy that fails a test above N queries), and set spring.jpa.open-in-view=false so lazy loading can’t happen silently during JSON rendering.
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();
}Hint: books is a lazy @OneToMany. How many SQL statements run for 500 authors?
The query runs once, then getBooks() loads each author’s lazy collection with one more query per author: 501 queries for 500 authors (the N+1 problem). It’s fast in development with 10 rows and degrades linearly in production, and it also loads every book entity just to count them.
Ask the database for exactly what the response needs:
List<AuthorSummary> summaries = em.createQuery("""
select new com.example.AuthorSummary(a.name, count(b))
from Author a left join a.books b
group by a.id, a.name
""", AuthorSummary.class)
.getResultList();When you do need the entities, fetch the association in the same query (join fetch, or an entity graph). And make the problem visible: log the SQL count per request in tests (Hibernate statistics, or a datasource proxy that fails a test above N queries).
- Status codes: 201 +
Locationfor 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
Which status code should a successful POST that creates a book return?
Why use separate DTOs instead of returning database entities?
BookService.importAll() calls this.save(book), and save is a public @Transactional method. Does save run in its own transaction?
Which change is safe for app versions that are already installed?