Building REST APIs with Spring
In this tutorial, you will learn about Building REST APIs with Spring. We cover key concepts, practical examples, and best practices to help you master this topic.
RESTful Design Principles
Representational State Transfer (REST) is an architectural style for designing networked applications. REST APIs use HTTP methods as verbs (GET, POST, PUT, DELETE, PATCH) and URLs as nouns representing resources. A well-designed REST API is intuitive, stateless, and leverages HTTP status codes to communicate results.
Spring Boot makes building REST APIs straightforward with @RestController, which combines @Controller and @ResponseBody to automatically serialize return values to JSON. Combined with Spring's validation, Exception Handling, and HATEOAS support, you can build production-quality APIs with minimal code.
flowchart LR
Client[Client] -->|HTTP Request| API[REST API]
API -->|Validation| Controller
Controller --> Service
Service --> Repository
Repository --> DB[(Database)]
Controller -->|HTTP Response| Client
subgraph API
Controller[@RestController]
EH[@ControllerAdvice
Error Handler]
Validation[Jakarta Validation]
end
Creating a Resource API
Let's build a complete REST API for managing books.
The Entity and DTO
// Entity
@Entity
@Table(name = "books")
public class Book {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String author;
@Column(unique = true)
private String isbn;
private double price;
private int publicationYear;
// getters and setters
}
// DTO (Record)
public record BookRequest(
@NotBlank String title,
@NotBlank String author,
@Pattern(regexp = "^(?:\\d{9}X|\\d{13})$") String isbn,
@Positive double price,
@Min(1900) @Max(2026) int publicationYear
) {}
public record BookResponse(
Long id, String title, String author,
String isbn, double price, int publicationYear
) {}
The Controller
@RestController
@RequestMapping("/api/v1/books")
public class BookController {
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
@GetMapping
public ResponseEntity<Page<BookResponse>> getAllBooks(
@PageableDefault(size = 20, sort = "title") Pageable pageable,
@RequestParam(required = false) String author) {
Page<BookResponse> books = bookService.findAll(author, pageable);
return ResponseEntity.ok(books);
}
@GetMapping("/{id}")
public ResponseEntity<BookResponse> getBook(@PathVariable Long id) {
return ResponseEntity.ok(bookService.findById(id));
}
@PostMapping
public ResponseEntity<BookResponse> createBook(
@Valid @RequestBody BookRequest request) {
BookResponse created = bookService.create(request);
URI location = URI.create("/api/v1/books/" + created.id());
return ResponseEntity.created(location).body(created);
}
@PutMapping("/{id}")
public ResponseEntity<BookResponse> updateBook(
@PathVariable Long id, @Valid @RequestBody BookRequest request) {
return ResponseEntity.ok(bookService.update(id, request));
}
@PatchMapping("/{id}")
public ResponseEntity<BookResponse> partialUpdate(
@PathVariable Long id,
@RequestBody Map<String, Object> updates) {
return ResponseEntity.ok(bookService.partialUpdate(id, updates));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteBook(@PathVariable Long id) {
bookService.delete(id);
return ResponseEntity.noContent().build();
}
}
Validation
Built-in Validation Annotations
public record CreateOrderRequest(
@NotNull Long customerId,
@NotEmpty
@Size(min = 1, max = 50)
List<@Valid OrderItemRequest> items,
@Future LocalDateTime deliveryDate,
@Pattern(regexp = "^(PENDING|PAID|SHIPPED)$")
String status,
@Email String notificationEmail,
@PositiveOrZero double discount,
@NotNull @Currency("USD") String currency
) {}
Custom Validator
@Target({FIELD})
@Retention(RUNTIME)
@Constraint(validatedBy = IsbnValidator.class)
@interface ValidIsbn {
String message() default "Invalid ISBN";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
class IsbnValidator implements ConstraintValidator<ValidIsbn, String> {
@Override
public boolean isValid(String isbn, ConstraintValidatorContext context) {
if (isbn == null) return false;
return isbn.matches("^(?:\\d{9}X|\\d{13})$") &&
validateChecksum(isbn);
}
private boolean validateChecksum(String isbn) {
// ISBN-10 or ISBN-13 checksum validation
return true;
}
}
Error Handling
Global Exception Handler
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("NOT_FOUND", ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ValidationErrorResponse> handleValidation(
MethodArgumentNotValidException ex) {
var errors = ex.getBindingResult().getFieldErrors().stream()
.map(fe -> new FieldError(fe.getField(), fe.getDefaultMessage()))
.toList();
return ResponseEntity.badRequest()
.body(new ValidationErrorResponse("VALIDATION_FAILED", errors));
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorResponse> handleConstraintViolation(
ConstraintViolationException ex) {
return ResponseEntity.badRequest()
.body(new ErrorResponse("VALIDATION_FAILED", ex.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneral(Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred"));
}
}
record ErrorResponse(String code, String message) {}
record FieldError(String field, String message) {}
record ValidationErrorResponse(String code, List<FieldError> errors) {}
API Documentation with OpenAPI
Springdoc-openapi generates OpenAPI 3.0 documentation automatically.
Dependency
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.5.0</version>
</dependency>
Customizing the Docs
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("Bookstore API")
.version("1.0.0")
.description("REST API for managing bookstore inventory"))
.addSecurityItem(new SecurityRequirement().addList("bearerAuth"))
.components(new Components()
.addSecuritySchemes("bearerAuth",
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")));
}
}
Documenting Endpoints
@RestController
@RequestMapping("/api/v1/books")
@Tag(name = "Books", description = "Book management endpoints")
public class BookController {
@GetMapping("/{id}")
@Operation(summary = "Get book by ID", description = "Returns details of a single book")
@ApiResponse(responseCode = "200", description = "Book found")
@ApiResponse(responseCode = "404", description = "Book not found")
public ResponseEntity<BookResponse> getBook(@PathVariable Long id) {
// ...
}
}
Content Negotiation
@GetMapping(value = "/{id}", produces = {MediaType.APPLICATION_JSON_VALUE,
MediaType.APPLICATION_XML_VALUE})
public ResponseEntity<BookResponse> getBook(@PathVariable Long id) {
// Response format based on Accept header
}
Common Mistakes
1. Exposing Entity Classes Directly
Returning entities directly in API responses couples your database schema to your API contract. Use DTOs instead.
// Bad: returns entity with all fields including password hash
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) { ... }
// Good: returns only needed data
@GetMapping("/users/{id}")
public UserResponse getUser(@PathVariable Long id) { ... }
2. Not Using HTTP Status Codes Properly
| Situation | Status Code |
|---|---|
| Success | 200 OK |
| Created | 201 Created |
| No content | 204 No Content |
| Bad request | 400 Bad Request |
| Unauthorized | 401 Unauthorized |
| Forbidden | 403 Forbidden |
| Not found | 404 Not Found |
| Conflict | 409 Conflict |
| Validation error | 422 Unprocessable Entity |
| Server error | 500 Internal Server Error |
3. Ignoring Idempotency
GET, PUT, DELETE, and HEAD should be idempotent. POST is not idempotent.
// PUT replaces the resource - calling it multiple times has the same effect
@PutMapping("/books/{id}")
public ResponseEntity<BookResponse> updateBook(@PathVariable Long id,
@Valid @RequestBody BookRequest request) {
// Always results in the same state
}
4. Returning Stack Traces in Production
Never return exception stack traces to API consumers. Always log the full error server-side and return a user-friendly error message.
5. Not Versioning APIs
@RequestMapping("/api/v1/books")
// Later: @RequestMapping("/api/v2/books")
Versioning allows you to evolve your API without breaking existing clients.
6. Missing Rate Limiting
Production APIs should implement rate limiting to protect against abuse. Use Spring Cloud Gateway, Bucket4j, or a dedicated rate-limiting service.
Practice Questions
- What is the difference between @PutMapping and @PatchMapping?
- How do you handle validation errors in a REST API?
- Why should you separate entities from DTOs?
- What is HATEOAS and when would you use it?
- How does content negotiation work in Spring REST APIs?
Challenge: Build a versioned REST API (v1 and v2) for a library system. v1 returns flat JSON. v2 returns nested JSON with _links (HAL format). Both versions share the same service layer. Implement proper error handling, validation, pagination, and OpenAPI documentation.
FAQ
Mini Project: Library Management REST API
Build a complete REST API for a library management system with the following features:
- CRUD for books, members, and loans
- Search books by title, author, ISBN, or genre with filtering and sorting
- Borrow and return books with due date tracking (PATCH for updating loan status)
- Late fee calculation based on return date
- Member borrowing history with pagination
- OpenAPI documentation with all endpoints documented
- Global exception handling for validation errors and business rule violations
- Version 2 of the API (at /api/v2) that returns HAL-formatted responses with links
Use DTOs, services, repositories, and proper HTTP status codes throughout.
What's Next
You can now build sophisticated REST APIs. In the next lesson, we will zoom out and explore Microservices Architecture with Java, including service discovery, API gateways, and inter-service communication patterns.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro