Project: A Robust, Validated API
Throughout this tutorial you have learned each piece of the puzzle separately: Bean Validation constraints, @Valid, custom validators, @ExceptionHandler, @ControllerAdvice, and consistent error response design. In this final lesson you assemble every piece into a single, production-grade Spring Boot 3 application. The goal is not to introduce new APIs — it is to show how the parts compose and where the seams are when you build something real.
What You Are Building
A small Product Catalog API with two resources: categories and products. The API enforces:
- All request bodies are validated with Jakarta Validation constraints before any business logic runs.
- Domain violations (duplicate slug, category not found) are converted to clean
4xx responses.
- Every error response has the same JSON shape regardless of the source of the error.
- Unexpected exceptions yield a sanitised
500 with no internal detail leaking to the client.
Project Skeleton
Start with a standard Spring Boot 3 project and add these starters to pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
The Consistent Error Response Shape
Define this once. Every error the API ever returns uses this record:
package com.example.catalog.error;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.time.Instant;
import java.util.List;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ApiError(
int status,
String error,
String message,
List<FieldViolation> violations,
Instant timestamp
) {
public record FieldViolation(String field, String message) {}
/** Convenience factory for single-message errors. */
public static ApiError of(int status, String error, String message) {
return new ApiError(status, error, message, null, Instant.now());
}
/** Convenience factory for validation errors with field details. */
public static ApiError validation(List<FieldViolation> violations) {
return new ApiError(422, "Unprocessable Entity",
"Validation failed", violations, Instant.now());
}
}
Why a record? Java records give you an immutable, concise value type with auto-generated constructor, getters, equals, hashCode, and toString — exactly what an error DTO needs. @JsonInclude(NON_NULL) keeps the violations field out of the JSON when it is not relevant (e.g. a 404).
Request DTOs with Validation
Place all validation constraints on the DTO, not on the entity. Here is the CreateProductRequest:
package com.example.catalog.product;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
public record CreateProductRequest(
@NotBlank(message = "Name is required")
@Size(min = 2, max = 120, message = "Name must be between 2 and 120 characters")
String name,
@NotBlank(message = "Slug is required")
@Pattern(regexp = "^[a-z0-9]+(?:-[a-z0-9]+)*$",
message = "Slug must be lowercase letters, digits, and hyphens only")
String slug,
@NotNull(message = "Price is required")
@DecimalMin(value = "0.01", message = "Price must be greater than zero")
@Digits(integer = 8, fraction = 2, message = "Price must have at most 2 decimal places")
BigDecimal price,
@NotNull(message = "Category ID is required")
@Positive(message = "Category ID must be a positive integer")
Long categoryId
) {}
Use BigDecimal for money, never double. Floating-point types cannot represent many decimal fractions exactly, which causes silent rounding errors in financial calculations. BigDecimal combined with @Digits ensures both the scale and precision of the stored value are controlled.
Custom Domain Exception Hierarchy
Define a small, focused exception hierarchy so that the global handler can match on type rather than on magic strings:
package com.example.catalog.error;
/** Base for all domain-level client errors (will map to 4xx). */
public abstract class DomainException extends RuntimeException {
protected DomainException(String message) { super(message); }
}
public class ResourceNotFoundException extends DomainException {
public ResourceNotFoundException(String resource, Object id) {
super(resource + " with id '" + id + "' was not found");
}
}
public class ConflictException extends DomainException {
public ConflictException(String message) { super(message); }
}
The Service Layer — Where Domain Exceptions Are Thrown
package com.example.catalog.product;
import com.example.catalog.category.CategoryRepository;
import com.example.catalog.error.ConflictException;
import com.example.catalog.error.ResourceNotFoundException;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class ProductService {
private final ProductRepository products;
private final CategoryRepository categories;
public ProductService(ProductRepository products, CategoryRepository categories) {
this.products = products;
this.categories = categories;
}
public Product create(CreateProductRequest req) {
if (products.existsBySlug(req.slug())) {
throw new ConflictException("A product with slug '" + req.slug() + "' already exists");
}
var category = categories.findById(req.categoryId())
.orElseThrow(() -> new ResourceNotFoundException("Category", req.categoryId()));
var product = new Product(null, req.name(), req.slug(), req.price(), category);
return products.save(product);
}
public Product findById(Long id) {
return products.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product", id));
}
public List<Product> findAll() {
return products.findAll();
}
}
Keep validation out of the service. The service layer should trust that inputs are structurally valid — that is the controller's responsibility via @Valid. Domain rules (like uniqueness) belong in the service. This separation keeps each layer focused and independently testable.
The Controller — Thin and Clean
package com.example.catalog.product;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.List;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService service;
public ProductController(ProductService service) {
this.service = service;
}
@GetMapping
public List<Product> list() {
return service.findAll();
}
@GetMapping("/{id}")
public Product get(@PathVariable Long id) {
return service.findById(id);
}
@PostMapping
public ResponseEntity<Product> create(
@Valid @RequestBody CreateProductRequest req,
UriComponentsBuilder ucb) {
Product saved = service.create(req);
var location = ucb.path("/api/products/{id}").buildAndExpand(saved.id()).toUri();
return ResponseEntity.created(location).body(saved);
}
}
The Global Exception Handler
All error translation lives in one place. The controller and service layers never build HTTP responses for errors — they only throw exceptions.
package com.example.catalog.error;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.List;
import java.util.stream.Collectors;
@RestControllerAdvice
public class GlobalExceptionHandler {
/** Jakarta Validation constraint failures from @Valid. */
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleValidation(MethodArgumentNotValidException ex) {
List<ApiError.FieldViolation> violations = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(fe -> new ApiError.FieldViolation(fe.getField(), fe.getDefaultMessage()))
.collect(Collectors.toList());
return ResponseEntity.unprocessableEntity().body(ApiError.validation(violations));
}
/** Domain not-found errors. */
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiError> handleNotFound(ResourceNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiError.of(404, "Not Found", ex.getMessage()));
}
/** Domain conflict errors (e.g. duplicate slug). */
@ExceptionHandler(ConflictException.class)
public ResponseEntity<ApiError> handleConflict(ConflictException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ApiError.of(409, "Conflict", ex.getMessage()));
}
/** Safety net: any unhandled exception. */
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleAll(Exception ex) {
// Log the real cause — do NOT return it to the client
return ResponseEntity.internalServerError()
.body(ApiError.of(500, "Internal Server Error",
"An unexpected error occurred. Please try again later."));
}
}
Never expose stack traces or internal exception messages in the 500 response. They reveal package names, library versions, and sometimes SQL — all valuable to an attacker. Log the full exception server-side (with a correlation ID) and return only a generic message to the client.
What a Real Error Response Looks Like
A POST to /api/products with a missing name and an invalid slug produces:
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{
"status": 422,
"error": "Unprocessable Entity",
"message": "Validation failed",
"violations": [
{ "field": "name", "message": "Name is required" },
{ "field": "slug", "message": "Slug must be lowercase letters, digits, and hyphens only" }
],
"timestamp": "2024-11-15T09:42:07.831Z"
}
A GET to /api/products/9999 for a non-existent product produces:
HTTP/1.1 404 Not Found
Content-Type: application/json
{
"status": 404,
"error": "Not Found",
"message": "Product with id '9999' was not found",
"timestamp": "2024-11-15T09:43:11.204Z"
}
Trade-offs and Design Decisions to Know
- 422 vs 400 for validation failures: RFC 9110 recommends 422 when the syntax is correct but semantically invalid. Some teams prefer 400 for simplicity. Pick one and apply it consistently.
- Fail-fast vs accumulate:
@Valid collects all constraint violations in one pass and returns them together. This is almost always better UX than returning errors one at a time, because the client can fix everything in one round-trip.
- DTO vs entity validation: Validate on the DTO. Entities should only ever contain valid state — a constraint violation on an entity is a programming error, not a user error.
- Message sources: Externalise constraint messages to
ValidationMessages.properties when you need i18n. Hard-coded strings are fine while the project is small.
Test the error handler, not just the happy path. Write a @WebMvcTest that posts an invalid body and asserts the exact JSON shape of the response. If the structure of ApiError changes, that test will catch it before a client breaks in production.
Summary
A production-ready Spring Boot API handles errors at two distinct layers: structural validation at the boundary (constraints on DTOs, enforced by @Valid) and domain validation inside the service (business rules, enforced by throwing typed exceptions). The @RestControllerAdvice global handler is the single translator between Java exceptions and HTTP responses, ensuring every error — validation failure, not found, conflict, or unexpected — reaches the client in the same predictable JSON envelope. With these patterns in place your API is both honest (it always tells the client what went wrong and how to fix it) and safe (it never leaks internal state).