When designing a REST API, it’s often necessary to vary the level of detail returned depending on the endpoint. For example, a “list all books” endpoint might only return a subset of each book’s fields, while a “get book by ID” endpoint could return the full details.

Consider a Spring REST controller like this:

@RestController
@RequestMapping("/books")
@RequiredArgsConstructor
public class BookController {

    ...

    @GetMapping
    public PagedModel<BookBase> findAll(Pageable pageable) {
        ...
    }

    @GetMapping("/{id}")
    public ResponseEntity<BookDetailed> findById(@PathVariable int id) {
        ...
    }
}

A common approach is to let BookDetailed extend BookBase. While this may seem convenient at first, it introduces problems:

  • Poor API documentation: Tools like SpringDoc/OpenAPI may struggle to generate accurate schemas.
  • Code duplication: Converting from your domain model to multiple DTOs becomes repetitive and error-prone.

For instance:

@Service
@RequiredArgsConstructor
public class BookService {

    private final BookRepository bookRepository;

    @Transactional(readOnly = true)
    public PagedModel<BookBase> getMany(Pageable pageable) {
        return new PagedModel<>(
            bookRepository.findAll(pageable)
                .map(this::toBookBase)
        );
    }

    @Transactional(readOnly = true)
    public Optional<BookDetailed> getOne(int id) {
        return bookRepository.findById(id)
            .map(this::toBookDetailed);
    }

    private BookBase toBookBase(Book book) {
        return BookBase.builder()
            .id(book.getId())
            .title(book.getTitle())
            .build();
    }

    private BookDetailed toBookDetailed(Book book) {
        return BookDetailed.builder()
            .id(book.getId())       // Code repetition
            .title(book.getTitle()) // Code repetition
            .description(book.getDescription())
            .build();
    }
}

There’s no easy way to reuse the BookBase logic when building BookDetailed.

A Better Alternative: Composition with @JsonUnwrapped

Instead of inheritance, you can use composition to structure your DTOs more cleanly. With Jackson’s @JsonUnwrapped, you can embed a BookBase instance directly into BookDetailed and have its fields serialized as if they were declared directly.

@Data
@Builder
@FieldDefaults(level = AccessLevel.PRIVATE)
public class BookDetailed {
    @JsonUnwrapped BookBase book;
    String description;
}

Now you can build the DTO like this:

private BookDetailed toBookDetailed(Book book) {
    return BookDetailed.builder()
        .book(toBookBase(book)) // Reuse previous logic
        .description(book.getDescription())
        .build();
}

This way, you keep the DTOs decoupled, avoid boilerplate, and preserve flexibility for future changes. It’s also much easier to test and reuse your projection logic across services and layers.