Spring Boot strips away the configuration ceremony that used to make Spring applications time-consuming to set up. You add a dependency, annotate a class, and a production-grade REST endpoint is running in seconds. This guide builds a complete, working REST API from a blank project to a tested, structured service – explaining every decision along the way.
By the end you will have a runnable Spring Boot application with GET, POST, PUT, and DELETE endpoints, proper HTTP status codes, global exception handling, validation, and a structure that scales to a real project.
Prerequisites
- Java 21 (any recent LTS will work)
- Maven 3.8+ or Gradle 8+
- Basic familiarity with Java classes and annotations
1. Project Setup
The fastest way to create a Spring Boot project is start.spring.io. Select Maven, Java 21, and add these dependencies: Spring Web, Spring Boot DevTools, and Validation. Download the zip, unpack it, and open in your IDE.
Your pom.xml parent and key dependencies will look like this:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.0</version> <!-- use the latest stable release -->
</parent>
<dependencies>
<!-- Web: includes Spring MVC, embedded Tomcat, Jackson -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Validation: JSR 380 / Hibernate Validator -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- DevTools: hot reload during development -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<!-- Test: JUnit 5, Mockito, MockMvc -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
2. Project Structure
A clean structure separates concerns into distinct layers. This makes the code easy to navigate and straightforward to test each layer in isolation.
src/main/java/com/example/demo/
โโโ DemoApplication.java โ Spring Boot entry point
โโโ controller/
โ โโโ BookController.java โ HTTP layer: handles requests, returns responses
โโโ service/
โ โโโ BookService.java โ Business logic: orchestrates the work
โโโ model/
โ โโโ Book.java โ Domain model / data class
โโโ exception/
โโโ BookNotFoundException.java
โโโ GlobalExceptionHandler.java
3. The Model
We model a simple Book. Java 21 records make this concise; the @NotBlank and @Positive annotations from Hibernate Validator enforce input rules before data reaches the service layer.
package com.example.demo.model;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
// A Java record is immutable by default: getters, equals, hashCode, and toString
// are generated automatically. The 'id' field is set by the service, not the caller.
public record Book(
Long id,
@NotBlank(message = "Title must not be blank")
String title,
@NotBlank(message = "Author must not be blank")
String author,
@Positive(message = "Year must be a positive number")
int year
) {
// Compact canonical constructor - validation annotations are checked by Spring
// before this record reaches the service layer when used with @Valid in the controller.
}
4. The Service Layer
The service holds the in-memory store (a HashMap) and the business rules. In a real application this layer would call a repository that talks to a database.
package com.example.demo.service;
import com.example.demo.exception.BookNotFoundException;
import com.example.demo.model.Book;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
// @Service marks this as a Spring-managed bean; Spring will inject it wherever needed.
@Service
public class BookService {
// In-memory store for this demo (replace with JPA repository for a real app)
private final Map store = new HashMap<>();
private final AtomicLong idGen = new AtomicLong(1); // thread-safe ID counter
// --- CRUD operations ---
public List findAll() {
return new ArrayList<>(store.values()); // return a copy
}
public Book findById(Long id) {
Book book = store.get(id);
if (book == null) {
throw new BookNotFoundException("Book not found: id=" + id);
}
return book;
}
public Book create(Book book) {
long id = idGen.getAndIncrement(); // assign the next ID
Book saved = new Book(id, book.title(), book.author(), book.year());
store.put(id, saved);
return saved;
}
public Book update(Long id, Book updated) {
findById(id); // throws 404 if absent
Book replacement = new Book(id, updated.title(), updated.author(), updated.year());
store.put(id, replacement);
return replacement;
}
public void delete(Long id) {
findById(id); // throws 404 if absent
store.remove(id);
}
}
5. The Controller
The controller maps HTTP verbs and URLs to service calls and sets the correct HTTP status code on each response. @RestController combines @Controller and @ResponseBody, so every method return value is serialised to JSON automatically by Jackson.
package com.example.demo.controller;
import com.example.demo.model.Book;
import com.example.demo.service.BookService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController // combines @Controller + @ResponseBody
@RequestMapping("/api/books") // base path for all endpoints in this class
public class BookController {
private final BookService bookService;
// Constructor injection - preferred over @Autowired field injection
public BookController(BookService bookService) {
this.bookService = bookService;
}
// GET /api/books - returns all books
@GetMapping
public List getAllBooks() {
return bookService.findAll(); // 200 OK by default
}
// GET /api/books/{id} - returns one book or 404
@GetMapping("/{id}")
public ResponseEntity getBookById(@PathVariable Long id) {
return ResponseEntity.ok(bookService.findById(id)); // 200 OK
}
// POST /api/books - creates a new book, returns 201 Created
@PostMapping
public ResponseEntity createBook(@Valid @RequestBody Book book) {
// @Valid triggers JSR 380 validation on the incoming Book record.
// If validation fails, Spring returns 400 Bad Request automatically.
Book created = bookService.create(book);
return ResponseEntity.status(HttpStatus.CREATED).body(created); // 201
}
// PUT /api/books/{id} - replaces a book; 200 on success, 404 if not found
@PutMapping("/{id}")
public ResponseEntity updateBook(
@PathVariable Long id,
@Valid @RequestBody Book book) {
return ResponseEntity.ok(bookService.update(id, book));
}
// DELETE /api/books/{id} - removes a book; 204 No Content on success
@DeleteMapping("/{id}")
public ResponseEntity deleteBook(@PathVariable Long id) {
bookService.delete(id);
return ResponseEntity.noContent().build(); // 204 No Content
}
}
6. Exception Handling
A global exception handler using @RestControllerAdvice intercepts exceptions thrown anywhere in the application and maps them to clean JSON error responses. Without this, Spring returns a raw 500 stacktrace.
// BookNotFoundException.java
package com.example.demo.exception;
public class BookNotFoundException extends RuntimeException {
public BookNotFoundException(String message) {
super(message);
}
}
// GlobalExceptionHandler.java
package com.example.demo.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.stream.Collectors;
// @RestControllerAdvice intercepts exceptions from all @RestController classes.
// ProblemDetail (RFC 7807) is Spring Boot 3's standard error response format.
@RestControllerAdvice
public class GlobalExceptionHandler {
// 404 Not Found - when a book ID doesn't exist
@ExceptionHandler(BookNotFoundException.class)
public ProblemDetail handleNotFound(BookNotFoundException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
pd.setTitle("Book Not Found");
return pd;
}
// 400 Bad Request - when @Valid constraint violations occur
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
String detail = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.collect(Collectors.joining("; "));
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, detail);
pd.setTitle("Validation Failed");
return pd;
}
}
7. Running and Testing the API
Start the application with mvn spring-boot:run. Spring Boot embeds Tomcat and starts on port 8080. You can then test every endpoint with curl:
# Create a book (POST)
curl -s -X POST http://localhost:8080/api/books
-H "Content-Type: application/json"
-d '{"title":"Clean Code","author":"Robert Martin","year":2008}' | jq
# Output:
# { "id": 1, "title": "Clean Code", "author": "Robert Martin", "year": 2008 }
# Get all books (GET)
curl -s http://localhost:8080/api/books | jq
# Get one book (GET)
curl -s http://localhost:8080/api/books/1 | jq
# Update a book (PUT)
curl -s -X PUT http://localhost:8080/api/books/1
-H "Content-Type: application/json"
-d '{"title":"Clean Code (2nd Ed)","author":"Robert Martin","year":2024}' | jq
# Delete a book (DELETE)
curl -s -X DELETE http://localhost:8080/api/books/1
# Returns: 204 No Content
# Test validation (POST with blank title)
curl -s -X POST http://localhost:8080/api/books
-H "Content-Type: application/json"
-d '{"title":"","author":"Unknown","year":2020}' | jq
# Output: 400 Bad Request, "title: Title must not be blank"
8. Annotation Quick Reference
| Annotation | Where used | What it does |
|---|---|---|
@SpringBootApplication | Main class | Enables auto-configuration, component scan, and configuration support |
@RestController | Controller class | Marks as controller + serialises return values to JSON |
@RequestMapping | Class or method | Maps a URL prefix to the class (or full path to a method) |
@GetMapping | Method | Maps HTTP GET to the method |
@PostMapping | Method | Maps HTTP POST |
@PutMapping | Method | Maps HTTP PUT |
@DeleteMapping | Method | Maps HTTP DELETE |
@PathVariable | Parameter | Binds a URI template variable (e.g., {id}) to a method parameter |
@RequestBody | Parameter | Deserialises the JSON request body into a Java object |
@Valid | Parameter | Triggers Bean Validation on the bound object |
@Service | Class | Marks as a Spring-managed service bean |
@RestControllerAdvice | Class | Intercepts exceptions from all controllers and maps them to responses |
@ExceptionHandler | Method in advice | Maps a specific exception type to an HTTP response |
See Also
- Testing REST APIs with JUnit 6: MockMvc vs WebTestClient
- Master Java Bean Validation with Hibernate Validator 7
- Jackson ObjectMapper: The Complete Guide to Reading and Writing JSON in Java
- Java Collections Framework: Choosing the Right Data Structure
- Java Records: The Complete Guide to Immutable Data Classes
- Spring Data JPA: Complete Guide โ Repositories, Queries, and Projections
Conclusion
Spring Boot reduces REST API development to a small set of well-understood building blocks: a model, a service, a controller, and an exception handler. The framework handles serialisation, HTTP mapping, validation, and error formatting so you can focus entirely on domain logic. The structure shown here – separate packages per layer, constructor injection, global exception handling, and RFC 7807 error responses – scales from a tutorial project to a production microservice with minimal refactoring.