DTO Pattern, Validation & Error Handling

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:

  1. If a ResourceNotFoundException is thrown → handleResourceNotFound() handles it (404)
  2. If a MethodArgumentNotValidException is thrown → handleValidationErrors() handles it (400)
  3. If any other Exception is 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 GlobalExceptionHandler handles all exceptions
  • No manual Map parsing — typed DTOs with @Valid
  • No ResponseEntity wrappers 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

  1. 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 @UniqueUsername custom validator.
  2. 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 full PostResponse.
  3. Handle DataIntegrityViolationException: This exception is thrown when a database constraint is violated (e.g., duplicate email). Add a handler in GlobalExceptionHandler that returns a 409 Conflict with a user-friendly message.
  4. 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.
  5. Test every error path: Use curl or Postman to trigger every exception handler in GlobalExceptionHandler. Verify that every response follows the same ErrorResponse format.

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 @Valid on @RequestBody parameters. 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 ErrorResponse format — 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

Leave a Reply

Your email address will not be published. Required fields are marked *