Why Not Expose Entities Directly? The DTO Pattern
Throughout Lectures 5–7, we returned JPA entities directly from our REST controllers. This worked for learning, but it causes serious problems in real applications.
Problem 1: Exposing Internal Structure
Your entities mirror your database schema. When you return them as JSON, you expose your database structure to the outside world:
{
"id": 1,
"title": "My Post",
"content": "...",
"passwordHash": "$2a$10$abc...",
"status": "DRAFT",
"author": { "id": 1, "passwordHash": "...", "role": "ROLE_ADMIN" },
"internalNotes": "This post needs review by legal team"
}
Clients can see sensitive fields like passwordHash and internalNotes. You tried to fix this with @JsonIgnore, but that is a band-aid — you are mixing API concerns into your domain model.
Problem 2: Infinite Recursion
Bidirectional relationships cause infinite loops during JSON serialization:
Post → author → posts → [Post → author → posts → ...]
You fixed this with @JsonIgnore, but now you cannot serialize the author at all, even when you want to.
Problem 3: Tight Coupling Between API and Database
If you rename a database column, the API response changes. If you add an internal field to the entity, it appears in the API. Your API contract is tightly coupled to your database schema — any internal change breaks external clients.
Problem 4: Different Shapes for Different Operations
When creating a post, the client sends title, content, authorId, and tagIds. But the response should include authorName, categoryName, and tagNames — not just IDs. The input shape and output shape are different, but entities have only one shape.
The Solution: DTO Pattern
A DTO (Data Transfer Object) is a plain Java class designed specifically for data transfer between layers. You create separate DTOs for:
- Request DTOs — what the client sends to the server
- Response DTOs — what the server sends back to the client
Client Controller Service Repository
| | | |
|--- JSON Request -------->| | |
| |-- Request DTO ------>| |
| | |-- Entity ----------->|
| | | |
| | |<-- Entity -----------|
| |<-- Response DTO -----| |
|<--- JSON Response -------| | |
The entity never crosses the controller boundary. The controller receives a Request DTO, the service converts it to an entity, works with the database, and then converts the result back to a Response DTO.
Creating Request and Response DTOs
Request DTOs — What the Client Sends
File: src/main/java/com/example/blogapi/dto/CreatePostRequest.java
package com.example.blogapi.dto;
import java.util.List;
// This class represents the data the client sends when creating a post.
// It contains ONLY the fields the client should provide — no id, no timestamps,
// no internal fields. The field types match what makes sense for the API
// (e.g., authorId instead of a full User object).
public class CreatePostRequest {
private String title;
private String content;
private String excerpt;
private Long authorId;
private Long categoryId;
private List<Long> tagIds;
// Default constructor for Jackson deserialization
public CreatePostRequest() { }
// Getters and setters
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public String getExcerpt() { return excerpt; }
public void setExcerpt(String excerpt) { this.excerpt = excerpt; }
public Long getAuthorId() { return authorId; }
public void setAuthorId(Long authorId) { this.authorId = authorId; }
public Long getCategoryId() { return categoryId; }
public void setCategoryId(Long categoryId) { this.categoryId = categoryId; }
public List<Long> getTagIds() { return tagIds; }
public void setTagIds(List<Long> tagIds) { this.tagIds = tagIds; }
}
File: src/main/java/com/example/blogapi/dto/UpdatePostRequest.java
package com.example.blogapi.dto;
import java.util.List;
// Update request — all fields are optional (only provided fields are updated).
// This aligns with PATCH semantics from Lecture 3.
public class UpdatePostRequest {
private String title;
private String content;
private String excerpt;
private Long categoryId;
private List<Long> tagIds;
// No authorId — you cannot change the author of a post
public UpdatePostRequest() { }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public String getExcerpt() { return excerpt; }
public void setExcerpt(String excerpt) { this.excerpt = excerpt; }
public Long getCategoryId() { return categoryId; }
public void setCategoryId(Long categoryId) { this.categoryId = categoryId; }
public List<Long> getTagIds() { return tagIds; }
public void setTagIds(List<Long> tagIds) { this.tagIds = tagIds; }
}
Response DTOs — What the Server Returns
File: src/main/java/com/example/blogapi/dto/PostResponse.java
package com.example.blogapi.dto;
import java.time.LocalDateTime;
import java.util.List;
// This class represents the data the server sends back.
// It includes derived/computed fields that do not exist in the entity:
// - authorName (instead of the full User object)
// - categoryName (instead of the full Category object)
// - tagNames (instead of full Tag objects)
// - commentCount (instead of the full list of comments)
//
// No passwordHash, no internal fields, no circular references.
public class PostResponse {
private Long id;
private String title;
private String slug;
private String content;
private String excerpt;
private String status;
// Author info — flattened, not a nested User object
private Long authorId;
private String authorName;
private String authorUsername;
// Category info — flattened
private Long categoryId;
private String categoryName;
// Tags — just the names, not full Tag objects
private List<String> tagNames;
// Computed field — count instead of full list
private int commentCount;
// Timestamps
private LocalDateTime publishedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
// Default constructor
public PostResponse() { }
// --- Getters and Setters ---
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getSlug() { return slug; }
public void setSlug(String slug) { this.slug = slug; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public String getExcerpt() { return excerpt; }
public void setExcerpt(String excerpt) { this.excerpt = excerpt; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Long getAuthorId() { return authorId; }
public void setAuthorId(Long authorId) { this.authorId = authorId; }
public String getAuthorName() { return authorName; }
public void setAuthorName(String authorName) { this.authorName = authorName; }
public String getAuthorUsername() { return authorUsername; }
public void setAuthorUsername(String authorUsername) { this.authorUsername = authorUsername; }
public Long getCategoryId() { return categoryId; }
public void setCategoryId(Long categoryId) { this.categoryId = categoryId; }
public String getCategoryName() { return categoryName; }
public void setCategoryName(String categoryName) { this.categoryName = categoryName; }
public List<String> getTagNames() { return tagNames; }
public void setTagNames(List<String> tagNames) { this.tagNames = tagNames; }
public int getCommentCount() { return commentCount; }
public void setCommentCount(int commentCount) { this.commentCount = commentCount; }
public LocalDateTime getPublishedAt() { return publishedAt; }
public void setPublishedAt(LocalDateTime publishedAt) { this.publishedAt = publishedAt; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}
The JSON Output Comparison
Without DTOs (returning entity directly):
{
"id": 1,
"title": "My Post",
"slug": "my-post",
"content": "...",
"status": "PUBLISHED",
"authorId": null,
"categoryId": null,
"publishedAt": "2024-01-15T10:30:00",
"createdAt": "2024-01-15T09:00:00",
"updatedAt": "2024-01-15T10:30:00"
}
Missing: author name, category name, tags, comment count. The authorId and categoryId are null because we used @JsonIgnore on the relationships to avoid infinite recursion.
With DTOs:
{
"id": 1,
"title": "My Post",
"slug": "my-post",
"content": "...",
"excerpt": "A short summary",
"status": "PUBLISHED",
"authorId": 1,
"authorName": "Alice Johnson",
"authorUsername": "alice",
"categoryId": 2,
"categoryName": "Tutorial",
"tagNames": ["Java", "Spring Boot", "JPA"],
"commentCount": 5,
"publishedAt": "2024-01-15T10:30:00",
"createdAt": "2024-01-15T09:00:00",
"updatedAt": "2024-01-15T10:30:00"
}
Rich, complete, no sensitive fields, no circular references. This is a proper API response.
Manual Mapping vs MapStruct
Manual Mapping
The simplest approach is to write a mapper class that converts between entities and DTOs by hand:
File: src/main/java/com/example/blogapi/mapper/PostMapper.java
package com.example.blogapi.mapper;
import com.example.blogapi.dto.PostResponse;
import com.example.blogapi.model.Post;
import com.example.blogapi.model.Tag;
import org.springframework.stereotype.Component;
import java.util.stream.Collectors;
// @Component makes this a Spring bean — injectable into services
@Component
public class PostMapper {
// Convert a Post entity to a PostResponse DTO.
// This method must be called while the Hibernate session is open
// (inside a @Transactional method) to safely access lazy-loaded fields.
public PostResponse toResponse(Post post) {
PostResponse dto = new PostResponse();
// Direct field mapping
dto.setId(post.getId());
dto.setTitle(post.getTitle());
dto.setSlug(post.getSlug());
dto.setContent(post.getContent());
dto.setExcerpt(post.getExcerpt());
dto.setStatus(post.getStatus());
dto.setPublishedAt(post.getPublishedAt());
dto.setCreatedAt(post.getCreatedAt());
dto.setUpdatedAt(post.getUpdatedAt());
// Relationship mapping — flatten related entities into simple fields
if (post.getAuthor() != null) {
dto.setAuthorId(post.getAuthor().getId());
dto.setAuthorName(post.getAuthor().getFullName());
dto.setAuthorUsername(post.getAuthor().getUsername());
}
if (post.getCategory() != null) {
dto.setCategoryId(post.getCategory().getId());
dto.setCategoryName(post.getCategory().getName());
}
// Collection mapping — extract just the names from Tag objects
if (post.getTags() != null && !post.getTags().isEmpty()) {
dto.setTagNames(
post.getTags().stream()
.map(Tag::getName)
.sorted()
.collect(Collectors.toList())
);
}
// Computed field — use the collection size
if (post.getComments() != null) {
dto.setCommentCount(post.getComments().size());
}
return dto;
}
// Overloaded method when comment count comes from a separate query
public PostResponse toResponse(Post post, long commentCount) {
PostResponse dto = toResponse(post);
dto.setCommentCount((int) commentCount);
return dto;
}
}
When Manual Mapping Gets Tedious
Manual mapping is clear and simple, but it becomes repetitive when you have many entities and DTOs. For each entity, you write:
- Entity → Response DTO mapping
- Request DTO → Entity mapping
- Potentially multiple response variants (summary, detail, admin view)
For a project with 10 entities, you might end up writing 30+ mapping methods. That is where automated mappers help.
MapStruct — Compile-Time Code Generation
MapStruct generates mapping code at compile time. You declare an interface, and MapStruct generates the implementation.
Step 1: Add MapStruct dependency to pom.xml:
<properties>
<java.version>17</java.version>
<mapstruct.version>1.5.5.Final</mapstruct.version>
</properties>
<dependencies>
<!-- MapStruct -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
Step 2: Declare a mapper interface:
package com.example.blogapi.mapper;
import com.example.blogapi.dto.PostResponse;
import com.example.blogapi.model.Post;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
// componentModel = "spring" makes MapStruct generate a Spring @Component.
// You can inject this mapper like any other Spring bean.
@Mapper(componentModel = "spring")
public interface PostMapper {
// @Mapping maps fields with different names between source and target.
// Fields with the same name are mapped automatically.
@Mapping(source = "author.id", target = "authorId")
@Mapping(source = "author.fullName", target = "authorName")
@Mapping(source = "author.username", target = "authorUsername")
@Mapping(source = "category.id", target = "categoryId")
@Mapping(source = "category.name", target = "categoryName")
@Mapping(target = "tagNames", expression = "java(post.getTags().stream().map(t -> t.getName()).sorted().collect(java.util.stream.Collectors.toList()))")
@Mapping(target = "commentCount", expression = "java(post.getComments() != null ? post.getComments().size() : 0)")
PostResponse toResponse(Post post);
}
MapStruct generates the implementation at compile time — no runtime reflection, no performance overhead.
Which Approach to Choose?
| Criteria | Manual Mapping | MapStruct |
|---|---|---|
| Simplicity | Easy to understand and debug | Requires learning MapStruct annotations |
| Boilerplate | Repetitive for many entities | Minimal — declare interface, get implementation |
| Performance | Same as MapStruct | Compile-time generation, zero runtime overhead |
| Flexibility | Full control over every mapping | Complex mappings need expression or custom methods |
| Debugging | Step through the code directly | Generated code is readable but harder to debug |
| Team familiarity | Everyone knows plain Java | Team must learn MapStruct |
For this series, we use manual mapping. It is simpler to understand and debug, and our blog has a manageable number of entities. In larger projects (20+ entities), MapStruct pays for itself quickly.
Bean Validation with @Valid and Jakarta Validation Annotations
The Problem: Manual Validation is Messy
In our service layer, we wrote validation like this:
if (post.getTitle() == null || post.getTitle().trim().isEmpty()) {
throw new IllegalArgumentException("Post title cannot be empty");
}
if (post.getTitle().length() > 500) {
throw new IllegalArgumentException("Post title cannot exceed 500 characters");
}
// ... and so on for every field
This scatters validation logic across the service layer and makes it hard to maintain. Jakarta Bean Validation provides a declarative approach — you annotate the DTO fields, and Spring validates them automatically.
Adding the Validation Dependency
spring-boot-starter-web includes the validation dependency since Spring Boot 3.x. If it is missing, add:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Annotating the Request DTO
package com.example.blogapi.dto;
import jakarta.validation.constraints.*;
import java.util.List;
public class CreatePostRequest {
// @NotBlank — must not be null AND must contain at least one non-whitespace character.
// The message is returned to the client when validation fails.
@NotBlank(message = "Title is required")
@Size(min = 3, max = 500, message = "Title must be between 3 and 500 characters")
private String title;
@NotBlank(message = "Content is required")
@Size(min = 10, message = "Content must be at least 10 characters")
private String content;
@Size(max = 1000, message = "Excerpt cannot exceed 1000 characters")
private String excerpt;
// @NotNull — must not be null (but can be 0, empty string, etc.)
@NotNull(message = "Author ID is required")
@Positive(message = "Author ID must be a positive number")
private Long authorId;
@Positive(message = "Category ID must be a positive number")
private Long categoryId; // Optional — no @NotNull
private List<Long> tagIds; // Optional
// constructors, getters, setters...
public CreatePostRequest() { }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public String getExcerpt() { return excerpt; }
public void setExcerpt(String excerpt) { this.excerpt = excerpt; }
public Long getAuthorId() { return authorId; }
public void setAuthorId(Long authorId) { this.authorId = authorId; }
public Long getCategoryId() { return categoryId; }
public void setCategoryId(Long categoryId) { this.categoryId = categoryId; }
public List<Long> getTagIds() { return tagIds; }
public void setTagIds(List<Long> tagIds) { this.tagIds = tagIds; }
}
Common Validation Annotations
| Annotation | Applies To | Rule |
|---|---|---|
@NotNull |
Any type | Must not be null |
@NotEmpty |
String, Collection | Must not be null or empty |
@NotBlank |
String | Must not be null, empty, or only whitespace |
@Size(min, max) |
String, Collection | Length/size must be within range |
@Min(value) |
Numbers | Must be >= value |
@Max(value) |
Numbers | Must be <= value |
@Positive |
Numbers | Must be > 0 |
@PositiveOrZero |
Numbers | Must be >= 0 |
@Email |
String | Must be a valid email format |
@Pattern(regexp) |
String | Must match the regular expression |
@Past |
Date/Time | Must be a date in the past |
@Future |
Date/Time | Must be a date in the future |
Triggering Validation in the Controller
Add @Valid before the @RequestBody parameter to trigger validation:
@RestController
@RequestMapping("/api/posts")
public class PostController {
// @Valid tells Spring to validate the CreatePostRequest before calling this method.
// If validation fails, Spring throws MethodArgumentNotValidException
// BEFORE the method body executes — your code never runs with invalid data.
@PostMapping
public ResponseEntity<PostResponse> createPost(
@Valid @RequestBody CreatePostRequest request) {
// If we get here, ALL validation constraints have passed.
// request.getTitle() is guaranteed to be non-blank and 3-500 chars.
// request.getAuthorId() is guaranteed to be non-null and positive.
PostResponse created = postService.createPost(request);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
}
Without @Valid, the annotations on the DTO do nothing. The @Valid annotation is the trigger.
Validating Nested Objects
If your DTO contains another object that also needs validation, use @Valid on the field:
public class CreateUserRequest {
@NotBlank
private String username;
@NotBlank
@Email
private String email;
// @Valid triggers validation on the nested Address object too
@Valid
@NotNull(message = "Address is required")
private AddressDto address;
}
public class AddressDto {
@NotBlank(message = "City is required")
private String city;
@NotBlank(message = "Country is required")
private String country;
}
Custom Validators
Sometimes the built-in annotations are not enough. You need custom validation logic — for example, checking that a username is not already taken.
Creating a Custom Annotation
Step 1: Define the annotation:
package com.example.blogapi.validation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
// @Constraint links this annotation to the validator class that contains the logic
@Constraint(validatedBy = UniqueUsernameValidator.class)
@Target({ElementType.FIELD}) // Can be applied to fields
@Retention(RetentionPolicy.RUNTIME) // Available at runtime
@Documented
public @interface UniqueUsername {
// Default error message
String message() default "Username is already taken";
// Required by the Bean Validation spec — you rarely use these directly
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
Step 2: Implement the validator:
package com.example.blogapi.validation;
import com.example.blogapi.repository.UserRepository;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
// ConstraintValidator<AnnotationType, FieldType>
// UniqueUsername is the annotation, String is the type of the field it validates
public class UniqueUsernameValidator
implements ConstraintValidator<UniqueUsername, String> {
private final UserRepository userRepository;
// You can inject Spring beans into validators!
public UniqueUsernameValidator(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public boolean isValid(String username, ConstraintValidatorContext context) {
// Null values are typically handled by @NotBlank or @NotNull,
// so we return true here to avoid double validation messages.
if (username == null) {
return true;
}
// Check the database for uniqueness
return !userRepository.existsByUsername(username);
}
}
Step 3: Use the annotation on a DTO field:
public class CreateUserRequest {
@NotBlank(message = "Username is required")
@Size(min = 3, max = 50, message = "Username must be between 3 and 50 characters")
@UniqueUsername // Our custom validator — checks the database
private String username;
@NotBlank(message = "Email is required")
@Email(message = "Email must be valid")
private String email;
@NotBlank(message = "Password is required")
@Size(min = 8, message = "Password must be at least 8 characters")
private String password;
// getters and setters...
public CreateUserRequest() { }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
}
Cross-Field Validation
Sometimes validation depends on multiple fields (e.g., “confirm password must match password”). Use a class-level custom validator:
// Annotation applied to the class, not a field
@Constraint(validatedBy = PasswordMatchValidator.class)
@Target({ElementType.TYPE}) // Applied to the class
@Retention(RetentionPolicy.RUNTIME)
public @interface PasswordMatch {
String message() default "Passwords do not match";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class PasswordMatchValidator
implements ConstraintValidator<PasswordMatch, CreateUserRequest> {
@Override
public boolean isValid(CreateUserRequest request, ConstraintValidatorContext context) {
if (request.getPassword() == null || request.getConfirmPassword() == null) {
return true; // Let @NotBlank handle null checks
}
return request.getPassword().equals(request.getConfirmPassword());
}
}
@PasswordMatch // Class-level validation
public class CreateUserRequest {
@NotBlank private String password;
@NotBlank private String confirmPassword;
// ...
}
Global Exception Handling with @ControllerAdvice
The Problem: Inconsistent Error Responses
Right now, our API returns different error formats depending on what goes wrong:
// Spring's default for validation errors:
{ "timestamp": "...", "status": 400, "errors": [...] }
// Our manual error:
{ "error": "Post not found with id: 999" }
// Unhandled exception:
{ "timestamp": "...", "status": 500, "error": "Internal Server Error", "path": "/api/posts" }
Three different formats for three different error types. Clients have to handle each one differently. We need a consistent format.
@ControllerAdvice — Centralized Exception Handling
@ControllerAdvice creates a class that intercepts exceptions thrown by any controller in the application. Instead of handling exceptions in every controller method with try-catch, you handle them in one place.
package com.example.blogapi.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import java.time.LocalDateTime;
import java.util.Map;
// @ControllerAdvice — this class handles exceptions from ALL controllers.
// Think of it as a global try-catch wrapper around every controller method.
@ControllerAdvice
public class GlobalExceptionHandler {
// @ExceptionHandler specifies which exception type this method handles.
// When a RuntimeException is thrown from ANY controller, this method catches it.
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<Map<String, Object>> handleRuntimeException(RuntimeException ex) {
Map<String, Object> error = Map.of(
"status", 500,
"error", "Internal Server Error",
"message", ex.getMessage(),
"timestamp", LocalDateTime.now().toString()
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
}
Now every RuntimeException from any controller returns the same format. Let us build this out properly.
Custom Exception Classes
Instead of throwing generic RuntimeException everywhere, create specific exception classes that carry meaningful information:
File: src/main/java/com/example/blogapi/exception/ResourceNotFoundException.java
package com.example.blogapi.exception;
// This exception is thrown when a requested resource does not exist.
// It carries the resource type and the identifier for clear error messages.
public class ResourceNotFoundException extends RuntimeException {
private final String resourceName;
private final String fieldName;
private final Object fieldValue;
public ResourceNotFoundException(String resourceName, String fieldName, Object fieldValue) {
// Produces messages like: "Post not found with id: 42"
super(String.format("%s not found with %s: %s", resourceName, fieldName, fieldValue));
this.resourceName = resourceName;
this.fieldName = fieldName;
this.fieldValue = fieldValue;
}
public String getResourceName() { return resourceName; }
public String getFieldName() { return fieldName; }
public Object getFieldValue() { return fieldValue; }
}
File: src/main/java/com/example/blogapi/exception/DuplicateResourceException.java
package com.example.blogapi.exception;
// Thrown when trying to create a resource that already exists
// (e.g., a user with a duplicate email)
public class DuplicateResourceException extends RuntimeException {
private final String resourceName;
private final String fieldName;
private final Object fieldValue;
public DuplicateResourceException(String resourceName, String fieldName, Object fieldValue) {
super(String.format("%s already exists with %s: %s", resourceName, fieldName, fieldValue));
this.resourceName = resourceName;
this.fieldName = fieldName;
this.fieldValue = fieldValue;
}
public String getResourceName() { return resourceName; }
public String getFieldName() { return fieldName; }
public Object getFieldValue() { return fieldValue; }
}
File: src/main/java/com/example/blogapi/exception/BadRequestException.java
package com.example.blogapi.exception;
// Thrown when the request is invalid for business reasons
// (not validation errors, but logical errors)
public class BadRequestException extends RuntimeException {
public BadRequestException(String message) {
super(message);
}
}
Using Custom Exceptions in the Service Layer
@Service
public class PostService {
public Post getPostById(Long id) {
return postRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Post", "id", id));
// Throws: "Post not found with id: 42"
}
public Post createPost(CreatePostRequest request) {
if (postRepository.existsBySlug(generateSlug(request.getTitle()))) {
throw new DuplicateResourceException("Post", "slug", generateSlug(request.getTitle()));
}
// ...
}
public void publishPost(Long id) {
Post post = getPostById(id);
if ("PUBLISHED".equals(post.getStatus())) {
throw new BadRequestException("Post is already published");
}
// ...
}
}
Building a Consistent Error Response Format
The Error Response Class
File: src/main/java/com/example/blogapi/dto/ErrorResponse.java
package com.example.blogapi.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
// @JsonInclude(NON_NULL) — omit null fields from the JSON response.
// This keeps error responses clean — the "errors" field only appears
// for validation errors, not for simple 404 errors.
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ErrorResponse {
private int status; // HTTP status code (400, 404, 500, etc.)
private String error; // Short error type ("Not Found", "Bad Request")
private String message; // Human-readable error description
private String path; // The request URL that caused the error
private LocalDateTime timestamp; // When the error occurred
// For validation errors — a map of field name → error messages
private Map<String, List<String>> validationErrors;
public ErrorResponse() {
this.timestamp = LocalDateTime.now();
}
public ErrorResponse(int status, String error, String message, String path) {
this();
this.status = status;
this.error = error;
this.message = message;
this.path = path;
}
// --- Getters and Setters ---
public int getStatus() { return status; }
public void setStatus(int status) { this.status = status; }
public String getError() { return error; }
public void setError(String error) { this.error = error; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public String getPath() { return path; }
public void setPath(String path) { this.path = path; }
public LocalDateTime getTimestamp() { return timestamp; }
public void setTimestamp(LocalDateTime timestamp) { this.timestamp = timestamp; }
public Map<String, List<String>> getValidationErrors() { return validationErrors; }
public void setValidationErrors(Map<String, List<String>> validationErrors) {
this.validationErrors = validationErrors;
}
}
Sample Error Responses
404 Not Found:
{
"status": 404,
"error": "Not Found",
"message": "Post not found with id: 999",
"path": "/api/posts/999",
"timestamp": "2024-01-15T10:30:00"
}
400 Validation Error:
{
"status": 400,
"error": "Validation Failed",
"message": "One or more fields have validation errors",
"path": "/api/posts",
"timestamp": "2024-01-15T10:30:00",
"validationErrors": {
"title": ["Title is required"],
"content": ["Content must be at least 10 characters"],
"authorId": ["Author ID is required"]
}
}
409 Conflict:
{
"status": 409,
"error": "Conflict",
"message": "User already exists with email: alice@example.com",
"path": "/api/users",
"timestamp": "2024-01-15T10:30:00"
}
Every error follows the same structure. Clients can parse them with a single error-handling function.
Handling Specific Exceptions
Now let us build the complete GlobalExceptionHandler that handles all exception types:
File: src/main/java/com/example/blogapi/exception/GlobalExceptionHandler.java
package com.example.blogapi.exception;
import com.example.blogapi.dto.ErrorResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ControllerAdvice
public class GlobalExceptionHandler {
// ========== 404 Not Found ==========
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleResourceNotFound(
ResourceNotFoundException ex, HttpServletRequest request) {
ErrorResponse error = new ErrorResponse(
HttpStatus.NOT_FOUND.value(), // 404
"Not Found",
ex.getMessage(), // "Post not found with id: 42"
request.getRequestURI() // "/api/posts/42"
);
return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
}
// ========== 409 Conflict ==========
@ExceptionHandler(DuplicateResourceException.class)
public ResponseEntity<ErrorResponse> handleDuplicateResource(
DuplicateResourceException ex, HttpServletRequest request) {
ErrorResponse error = new ErrorResponse(
HttpStatus.CONFLICT.value(), // 409
"Conflict",
ex.getMessage(),
request.getRequestURI()
);
return new ResponseEntity<>(error, HttpStatus.CONFLICT);
}
// ========== 400 Bad Request (Business Logic) ==========
@ExceptionHandler(BadRequestException.class)
public ResponseEntity<ErrorResponse> handleBadRequest(
BadRequestException ex, HttpServletRequest request) {
ErrorResponse error = new ErrorResponse(
HttpStatus.BAD_REQUEST.value(), // 400
"Bad Request",
ex.getMessage(),
request.getRequestURI()
);
return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
}
// ========== 400 Validation Errors ==========
// This is triggered when @Valid fails on a @RequestBody parameter.
// MethodArgumentNotValidException contains all field-level validation errors.
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationErrors(
MethodArgumentNotValidException ex, HttpServletRequest request) {
// Collect all validation errors grouped by field name.
// A single field can have multiple errors (e.g., @NotBlank AND @Size both fail).
Map<String, List<String>> validationErrors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(fieldError -> {
String fieldName = fieldError.getField();
String errorMessage = fieldError.getDefaultMessage();
validationErrors
.computeIfAbsent(fieldName, key -> new ArrayList<>())
.add(errorMessage);
});
ErrorResponse error = new ErrorResponse(
HttpStatus.BAD_REQUEST.value(),
"Validation Failed",
"One or more fields have validation errors",
request.getRequestURI()
);
error.setValidationErrors(validationErrors);
return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
}
// ========== 400 Type Mismatch ==========
// Triggered when a path variable or query parameter has the wrong type
// (e.g., GET /api/posts/abc — "abc" cannot be converted to Long)
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ErrorResponse> handleTypeMismatch(
MethodArgumentTypeMismatchException ex, HttpServletRequest request) {
String message = String.format("Parameter '%s' should be of type %s",
ex.getName(),
ex.getRequiredType() != null ? ex.getRequiredType().getSimpleName() : "unknown");
ErrorResponse error = new ErrorResponse(
HttpStatus.BAD_REQUEST.value(),
"Bad Request",
message,
request.getRequestURI()
);
return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
}
// ========== 400 Illegal Argument ==========
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleIllegalArgument(
IllegalArgumentException ex, HttpServletRequest request) {
ErrorResponse error = new ErrorResponse(
HttpStatus.BAD_REQUEST.value(),
"Bad Request",
ex.getMessage(),
request.getRequestURI()
);
return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
}
// ========== 500 Catch-All ==========
// Catches any unhandled exception — this is your safety net.
// Log the full exception for debugging, but return a generic message
// to the client (do not expose internal details).
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(
Exception ex, HttpServletRequest request) {
// In production, log this properly (we will add logging in Lecture 9)
System.err.println("Unhandled exception: " + ex.getMessage());
ex.printStackTrace();
ErrorResponse error = new ErrorResponse(
HttpStatus.INTERNAL_SERVER_ERROR.value(), // 500
"Internal Server Error",
"An unexpected error occurred. Please try again later.",
request.getRequestURI()
);
return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
How It Works
The exception handler methods are matched by exception type specificity:
- If a
ResourceNotFoundExceptionis thrown →handleResourceNotFound()handles it (404) - If a
MethodArgumentNotValidExceptionis thrown →handleValidationErrors()handles it (400) - If any other
Exceptionis thrown →handleGenericException()handles it (500)
More specific handlers take priority over general ones. This is why the Exception handler is the catch-all — it only catches what none of the specific handlers matched.
Hands-on: Refactor Blog API with DTOs, Validation & Error Handling
Let us refactor the blog API to use everything from this lecture.
Step 1: Create All DTOs
Create a dto package with the following files. We have already seen CreatePostRequest, PostResponse, and ErrorResponse above. Let us add the missing pieces:
File: src/main/java/com/example/blogapi/dto/CreateCommentRequest.java
package com.example.blogapi.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
public class CreateCommentRequest {
@NotBlank(message = "Comment content is required")
@Size(min = 1, max = 5000, message = "Comment must be between 1 and 5000 characters")
private String content;
@NotNull(message = "Author ID is required")
@Positive(message = "Author ID must be a positive number")
private Long authorId;
// Optional — null for top-level comments, set for replies
private Long parentId;
public CreateCommentRequest() { }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public Long getAuthorId() { return authorId; }
public void setAuthorId(Long authorId) { this.authorId = authorId; }
public Long getParentId() { return parentId; }
public void setParentId(Long parentId) { this.parentId = parentId; }
}
File: src/main/java/com/example/blogapi/dto/CommentResponse.java
package com.example.blogapi.dto;
import java.time.LocalDateTime;
import java.util.List;
public class CommentResponse {
private Long id;
private String content;
private Long authorId;
private String authorUsername;
private String authorFullName;
private Long parentId;
private List<CommentResponse> replies;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public CommentResponse() { }
// --- Getters and Setters ---
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public Long getAuthorId() { return authorId; }
public void setAuthorId(Long authorId) { this.authorId = authorId; }
public String getAuthorUsername() { return authorUsername; }
public void setAuthorUsername(String authorUsername) { this.authorUsername = authorUsername; }
public String getAuthorFullName() { return authorFullName; }
public void setAuthorFullName(String authorFullName) { this.authorFullName = authorFullName; }
public Long getParentId() { return parentId; }
public void setParentId(Long parentId) { this.parentId = parentId; }
public List<CommentResponse> getReplies() { return replies; }
public void setReplies(List<CommentResponse> replies) { this.replies = replies; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}
Step 2: Create the Comment Mapper
File: src/main/java/com/example/blogapi/mapper/CommentMapper.java
package com.example.blogapi.mapper;
import com.example.blogapi.dto.CommentResponse;
import com.example.blogapi.model.Comment;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
@Component
public class CommentMapper {
public CommentResponse toResponse(Comment comment) {
CommentResponse dto = new CommentResponse();
dto.setId(comment.getId());
dto.setContent(comment.getContent());
dto.setCreatedAt(comment.getCreatedAt());
dto.setUpdatedAt(comment.getUpdatedAt());
if (comment.getAuthor() != null) {
dto.setAuthorId(comment.getAuthor().getId());
dto.setAuthorUsername(comment.getAuthor().getUsername());
dto.setAuthorFullName(comment.getAuthor().getFullName());
}
if (comment.getParent() != null) {
dto.setParentId(comment.getParent().getId());
}
return dto;
}
// Map with nested replies (for hierarchical comment display)
public CommentResponse toResponseWithReplies(Comment comment) {
CommentResponse dto = toResponse(comment);
if (comment.getReplies() != null && !comment.getReplies().isEmpty()) {
List<CommentResponse> replyDtos = comment.getReplies().stream()
.map(this::toResponseWithReplies) // Recursive mapping for nested replies
.collect(Collectors.toList());
dto.setReplies(replyDtos);
}
return dto;
}
}
Step 3: Refactor the Post Controller
File: src/main/java/com/example/blogapi/controller/PostController.java
package com.example.blogapi.controller;
import com.example.blogapi.dto.*;
import com.example.blogapi.service.PostService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/posts")
public class PostController {
private final PostService postService;
public PostController(PostService postService) {
this.postService = postService;
}
// POST /api/posts — @Valid triggers validation on CreatePostRequest
@PostMapping
public ResponseEntity<PostResponse> createPost(
@Valid @RequestBody CreatePostRequest request) {
PostResponse created = postService.createPost(request);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
// GET /api/posts?page=0&size=10&sort=createdAt&direction=desc&status=PUBLISHED
@GetMapping
public Page<PostResponse> listPosts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "createdAt") String sort,
@RequestParam(defaultValue = "desc") String direction,
@RequestParam(required = false) String status) {
Sort sortOrder = direction.equalsIgnoreCase("asc")
? Sort.by(sort).ascending()
: Sort.by(sort).descending();
Pageable pageable = PageRequest.of(page, size, sortOrder);
return postService.getPosts(status, pageable);
}
// GET /api/posts/42
@GetMapping("/{id}")
public PostResponse getPost(@PathVariable Long id) {
// No try-catch needed! ResourceNotFoundException is thrown by the service
// and caught by GlobalExceptionHandler, which returns a proper 404 response.
return postService.getPostById(id);
}
// PUT /api/posts/42
@PutMapping("/{id}")
public PostResponse updatePost(
@PathVariable Long id,
@Valid @RequestBody UpdatePostRequest request) {
return postService.updatePost(id, request);
}
// PATCH /api/posts/42/publish
@PatchMapping("/{id}/publish")
public PostResponse publishPost(@PathVariable Long id) {
return postService.publishPost(id);
}
// DELETE /api/posts/42
@DeleteMapping("/{id}")
public ResponseEntity<Void> deletePost(@PathVariable Long id) {
postService.deletePost(id);
return ResponseEntity.noContent().build();
}
// GET /api/posts/search?keyword=spring&page=0&size=10
@GetMapping("/search")
public Page<PostResponse> searchPosts(
@RequestParam String keyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
return postService.searchPosts(keyword, pageable);
}
// POST /api/posts/42/comments
@PostMapping("/{postId}/comments")
public ResponseEntity<CommentResponse> addComment(
@PathVariable Long postId,
@Valid @RequestBody CreateCommentRequest request) {
CommentResponse comment = postService.addComment(postId, request);
return ResponseEntity.status(HttpStatus.CREATED).body(comment);
}
// GET /api/posts/42/comments?page=0&size=20
@GetMapping("/{postId}/comments")
public Page<CommentResponse> getComments(
@PathVariable Long postId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Pageable pageable = PageRequest.of(page, size);
return postService.getComments(postId, pageable);
}
}
Notice how clean the controller is now:
- No try-catch blocks — the
GlobalExceptionHandlerhandles all exceptions - No manual
Mapparsing — typed DTOs with@Valid - No
ResponseEntitywrappers for success cases — just return the DTO directly - The controller is pure HTTP plumbing: receive request, call service, return response
Step 4: Test the Complete Flow
# Test validation — missing required fields
curl -X POST http://localhost:8080/api/posts \
-H "Content-Type: application/json" \
-d '{}'
# Expected 400 response:
# {
# "status": 400,
# "error": "Validation Failed",
# "message": "One or more fields have validation errors",
# "path": "/api/posts",
# "timestamp": "...",
# "validationErrors": {
# "title": ["Title is required"],
# "content": ["Content is required"],
# "authorId": ["Author ID is required"]
# }
# }
# Test validation — title too short
curl -X POST http://localhost:8080/api/posts \
-H "Content-Type: application/json" \
-d '{"title": "Hi", "content": "Short", "authorId": 1}'
# Expected 400:
# "validationErrors": {
# "title": ["Title must be between 3 and 500 characters"],
# "content": ["Content must be at least 10 characters"]
# }
# Test 404 — non-existent resource
curl http://localhost:8080/api/posts/99999
# Expected 404:
# {
# "status": 404,
# "error": "Not Found",
# "message": "Post not found with id: 99999",
# "path": "/api/posts/99999",
# "timestamp": "..."
# }
# Test type mismatch
curl http://localhost:8080/api/posts/abc
# Expected 400:
# {
# "status": 400,
# "error": "Bad Request",
# "message": "Parameter 'id' should be of type Long",
# "path": "/api/posts/abc",
# "timestamp": "..."
# }
# Test successful creation with validation passing
curl -X POST http://localhost:8080/api/posts \
-H "Content-Type: application/json" \
-d '{
"title": "Complete Guide to Spring Boot DTOs",
"content": "In this guide we explore how DTOs improve your API design...",
"excerpt": "Learn the DTO pattern",
"authorId": 1,
"categoryId": 1,
"tagIds": [1, 2]
}'
# Expected 201 — clean response with flattened relationships
Step 5: Exercises
- Create a CreateUserRequest DTO: Add validation annotations: username (3-50 chars, required), email (valid format, required), password (8+ chars, required), fullName (optional, max 100 chars). Add the
@UniqueUsernamecustom validator. - Create a PostSummaryResponse DTO: A lightweight DTO for list pages that excludes
content— only id, title, slug, excerpt, status, authorName, categoryName, commentCount, and createdAt. Use this in the list endpoint instead of the fullPostResponse. - Handle DataIntegrityViolationException: This exception is thrown when a database constraint is violated (e.g., duplicate email). Add a handler in
GlobalExceptionHandlerthat returns a 409 Conflict with a user-friendly message. - Add validation to UpdatePostRequest: Title should be 3-500 chars (if provided), content should be at least 10 chars (if provided). Since all fields are optional for updates, use custom validation logic or conditional validation groups.
- Test every error path: Use curl or Postman to trigger every exception handler in
GlobalExceptionHandler. Verify that every response follows the sameErrorResponseformat.
Summary
This lecture transformed your API from a quick prototype into a production-quality design:
- DTO Pattern: Separate Request DTOs (what the client sends) from Response DTOs (what the server returns). Entities never cross the controller boundary. This decouples your API contract from your database schema.
- Manual Mapping vs MapStruct: Manual mapping is simple and debuggable. MapStruct generates boilerplate at compile time for larger projects. Both produce the same result.
- Bean Validation: Annotate DTO fields with
@NotBlank,@Size,@Email,@Positive, etc. Trigger validation with@Validon@RequestBodyparameters. Spring rejects invalid requests before your code runs. - Custom Validators: Create annotation + validator class pairs for complex validation (database uniqueness checks, cross-field validation). Validators can inject Spring beans.
- @ControllerAdvice: Centralized exception handling for the entire application. One class catches all exceptions and converts them to consistent error responses.
- Custom Exceptions:
ResourceNotFoundException(404),DuplicateResourceException(409),BadRequestException(400) carry meaningful context about what went wrong. - Consistent Error Responses: Every error follows the same
ErrorResponseformat — status, error type, message, path, timestamp, and optional validation details. Clients need only one error-handling function.
What is Next
In Lecture 9, we will focus on Service Layer Design & Business Logic — proper transaction management with @Transactional, read-only optimizations, propagation levels, logging with SLF4J, and structuring complex business logic across services.
Quick Reference
| Concept | Description |
|---|---|
| DTO | Data Transfer Object — separates API contract from domain model |
| Request DTO | What the client sends (only writable fields, no id/timestamps) |
| Response DTO | What the server returns (flattened relationships, computed fields) |
| Manual Mapping | Write mapper classes by hand (simple, debuggable) |
| MapStruct | Generates mapper implementations at compile time |
@Valid |
Triggers Bean Validation on a @RequestBody parameter |
@NotNull |
Field must not be null |
@NotBlank |
String must not be null, empty, or whitespace |
@Size(min, max) |
String/collection size must be within range |
@Email |
String must be a valid email format |
@Positive |
Number must be greater than 0 |
@Pattern |
String must match a regular expression |
| Custom Validator | Annotation + ConstraintValidator implementation |
@ControllerAdvice |
Global exception handler for all controllers |
@ExceptionHandler |
Method that handles a specific exception type |
ResourceNotFoundException |
Custom exception for 404 Not Found |
DuplicateResourceException |
Custom exception for 409 Conflict |
ErrorResponse |
Consistent error format for all API errors |
MethodArgumentNotValidException |
Thrown when @Valid validation fails |
