Sending an entire customer object with a password, credit card number, and national ID to the client sounds like a developer’s nightmare, but in fact, it is a pretty realistic scenario.
The most popular approach to avoiding such situations is to use Data Transfer Objects (DTOs). But are DTOs a necessity or overkill? Is there an alternative approach?
This article covers the essentials of safely passing data between the server and client.
Expect:
- DTO fundamentals and benefits,
- A tutorial on using DTOs with records in Spring Boot,
- A guide to manual vs automated mapping with MapStruct,
- Additional approaches to retrieving data from the database.
Table of Contents
What Are DTOs (Data Transfer Objects)?
Data Transfer Objects (DTOs) are POJOs that serve as data carriers between processes, layers, or services. Their only purpose is to define the shape of the data passed to or from the client. In this sense, entities can also serve as DTOs if you use an alternative approach to creating additional data carrier classes.
In this article, I’ll refer to classes with JPA annotations as entities and records without JPA annotations as DTOs.
How can DTOs help us in production?
- They enable the separation of concerns. While entities represent the domain's relational model, DTOs aim to transfer data between system layers. Consequently, they help to avoid the exposure of DB internal details to API consumers.
- They enhance security. DTOs help developers expose only necessary information, keeping confidential data such as passwords safe.
- They can improve performance. DTOs can carry data only from necessary fields, helping to avoid serialization and network overhead.
How to Use DTOs with Spring Boot
This article focuses on using DTOs with Spring Data JPA. The Database Connectivity API — JPA, JDBC, jOOQ, etc. — doesn’t change the concept of DTOs, but it may affect the mapping strategy. Therefore, the article may be replenished with other APIs in the future.
To complete this guide, you will need:
- Java 17 or higher. As it is a Spring Boot demo, I'm using Liberica JDK recommended by Spring.
- Your favorite IDE. I'm using IntelliJ IDEA.
- The following dependencies: Spring Web, Spring Data JPA, H2 Database, Validation.
The code from this article is available on GitHub.
DTOs with Modern Java: Using Records
Records represent immutable data carriers. On the surface, they require only the specification of the fields. Under the hood, they make all fields private and final and automatically create accessors, a constructor, equals()
, hashCode()
, and toString()
. This makes the declaration of simple data carriers more concise.
DTOs are a good match for records.
For instance, let’s look at the following entity class:
@Entity
@Table(name="employees")
public class Employee {
@Id
@Column(name = "id", nullable = false)
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
private String password;
// getters, setters, constructors are omitted for brevity
}
Let’s create the corresponding DTO:
public record EmployeeDTO(Long id,
String name,
String email) {
}
As you can see, EmployeeDTO contains only three fields and no password.
This was an example of a Response DTO. In some cases, if DTO and entity fields match, one DTO may suffice.
But what if the data you receive from the client differs from the data you send from the server? In this case, you can create a Request DTO in addition to a Response DTO.
The Request DTO for Employee may look like this:
public record EmployeeRequestDTO(String name,
String email,
String password) {
}
But you can enhance it with validation because records allow us to add annotations to their fields:
public record EmployeeRequestDTO(@NotNull String name,
@NotNull String email,
@NotNull String password) {
}
What if the entity contains nested entities? Let’s look at the following example, where Customer has a List of Orders, and Order contains Customer:
@Entity
@Table(name="customers")
public class Customer {
@Id
@Column(name = "id", nullable = false)
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
private String password;
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL)
@JoinColumn(name = "customer_id")
List<Order> orders = new ArrayList<>();
// getters, setters, constructors are omitted for brevity
}
@Entity
@Table(name="orders")
public class Order {
@Id
@Column(name = "id", nullable = false)
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "total_price")
private double totalPrice;
@ManyToOne(cascade =
{CascadeType.DETACH, CascadeType.MERGE, CascadeType.PERSIST, CascadeType.REFRESH})
@JoinColumn(name="customer_id")
private Customer customer;
// getters, setters, constructors are omitted for brevity
}
In this case, we can use a Composite DTO, where CustomerResponse DTO includes a List of OrderResponse DTOs:
public record CustomerResponse(Long id,
String name,
String email,
List<OrderResponse> orders) {
}
public record OrderResponse(Long id,
double totalPrice) {
}
Mapping Entities to DTOs: Manual Mapping
Manual mapping is a reliable approach to working with DTOs, as you can tailor the mappers exactly as needed. In addition, manual mapping doesn’t require third-party libraries. Hence, no surprises at runtime and a smooth debugging experience.
Let’s see how we can implement mappers for our DTOs. A mapper is a regular Java class with primary methods toDto()
and toEntity()
and possibly additional methods for formatting and computing values.
A basic mapper for our Customer model could look like that:
public class ManualCustomerMapper {
public CustomerResponse mapToCustomerResponse(Customer customer) {
return new CustomerResponse(
customer.getId(),
customer.getName(),
customer.getEmail(),
customer.getOrders()
.stream()
.map(this::mapToOrderResponse)
.toList());
}
public OrderResponse mapToOrderResponse(Order order) {
return new OrderResponse(order.getId(), order.getTotalPrice());
}
public Customer mapToCustomer(CustomerRequest customerRequest) {
Customer customer = new Customer();
customer.setName(customerRequest.name());
customer.setEmail(customerRequest.email());
customer.setPassword(customerRequest.password());
return customer;
}
}
Then, we can add this mapper to CustomerService:
@Service
public class CustomerService {
private final CustomerRepository customerRepository;
private final CustomerMapper customerMapper;
public CustomerService(CustomerRepository customerRepository, CustomerMapper customerMapper) {
this.customerRepository = customerRepository;
this.customerMapper = customerMapper;
}
public List<CustomerResponse> findAll() {
return customerRepository
.findAll()
.stream()
.map(customerMapper::mapToCustomerResponse)
.toList();
}
If the API requires a different format for some data, you can add a value transformation to the mapper.
For instance, let’s add an enum to our Customer:
@Entity
@Table(name="customers")
public class Customer {
@Id
@Column(name = "id", nullable = false)
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
private String password;
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL)
@JoinColumn(name = "customer_id")
List<Order> orders = new ArrayList<>();
private Status status;
// getters, setters, constructors, etc. are ommitted for brevity
}
public enum Status { BRONZE, SILVER, GOLDEN }
Also, let’s update the CustomerResponse DTO to include this new field and an additional field, totalExpenses, which represents the sum of all order prices:
public record CustomerResponse(Long id,
String name,
String email,
String status,
List<OrderResponse> orders,
double totalExpenses) {
}
The corresponding mapper could be:
public CustomerResponse mapToCustomerResponse(Customer customer) {
List<OrderResponse> orderResponses = customer.getOrders()
.stream()
.filter(Objects::nonNull)
.map(this::mapToOrderResponse)
.toList();
return new CustomerResponse(
customer.getId(),
customer.getName(),
customer.getEmail(),
statusToString(customer.getStatus()),
orderResponses,
orderResponses
.stream()
.filter(Objects::nonNull)
.map(OrderResponse::totalPrice)
.reduce(0.0, Double::sum));
}
private String statusToString(Status status) {
return status.name();
}
When we want to make a request, we usually prefer to reference the object by ID, which is a typical pattern of usage for DTOs. We don’t want to fetch an object from the database when creating a model object to avoid extra SELECTs. So, we can use EntityManager and its method getReference()
that returns only the object proxy with only the Id field initialized:
public class OrderMapper {
private EntityManager entityManager;
public Order toOrder(Long customerId, OrderRequest request) {
Order order = new Order();
order.setCustomer(entityManager.getReference(Customer.class, customerId));
order.setTotalPrice(request.totalPrice());
return order;
}
}
DTO Projections
To avoid overfetching and related N+1 / LazyInitializationException issues when building a DTO, you can use query-time projection to fetch only the required fields. In this case, you get the DTO based on the attribute types returned by a SQL query without the need for a mapper.
Let’s look at the following Entity and its DTO:
@Entity
@Table(name="device")
public class Device {
@Id
@Column(name = "id", nullable = false)
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String model;
@ManyToOne(fetch = FetchType.LAZY, cascade =
{CascadeType.DETACH, CascadeType.MERGE, CascadeType.PERSIST, CascadeType.REFRESH})
@JoinColumn(name="manufacturer_id")
private Manufacturer manufacturer;
private String serialNumber;
private int lotNumber;
@ManyToOne(fetch = FetchType.LAZY, cascade =
{CascadeType.DETACH, CascadeType.MERGE, CascadeType.PERSIST, CascadeType.REFRESH})
@JoinColumn(name="supplier_id")
private Supplier supplier;
// getters, setters, constructors, etc. are ommitted for brevity
}
public record DeviceDTO(String serialNumber, String model) {
}
We want to return only the serial number and model, leaving behind all other fields, including nested objects.
If the DTO field names match exactly the entity field names, you can use a query method:
public interface DeviceRepository extends CrudRepository<Device, Long> {
List<DeviceDTO> findByLotNumber(int lotNumber);
}
You can also return a Projection DTO using JPQL or Native queries. Here’s the example with a Native query:
public interface DeviceRepository extends CrudRepository<Device, Long> {
@Query("SELECT d.serialNumber, d.model FROM device d WHERE d.lotNumber = :lotNumber")
List<DeviceDTO> findByLotNumber(int lotNumber);
}
You can also use dynamic projections in case you want to decide whether to return an Entity or a DTO at invocation time:
public interface DeviceRepository extends CrudRepository<Device, Long> {
<T> Collection<T> findByLotNumber(int lotNumber, Class<T> type);
}
Then, in the service layer:
void doSomethingMeaningful(int lotNumber) {
Collection<Device> devices =
deviceRepository.findByLotNumber(123456, Device.class);
Collection<DeviceDTO> deviceDtos =
deviceRepository.findByLotNumber(123456, DeviceDTO.class);
}
What if we have nested entities and want to include specific fields into our DeviceDTO, such as the name of the manufacturer or a ManufacturerDTO?
In case you want to retrieve only some fields from nested entities, you can build a JPQL query:
public record DeviceDTO(String serialNumber, String manufacturerName) {}
public interface DeviceRepository extends CrudRepository<Device, Long> {
@Query("""
select new dev.cat.device.dto.DeviceDTO(d.serialNumber, d.manufacturer.name)
from Device d
where d.lotNumber = :lotNumber
""")
List<DeviceDTO> findByLotNumber(int lotNumber);
}
Note that with the JPQL query, we must invoke the DTO constructor using a new keyword and a fully qualified name of the DTO.
What if you have nested DTOs?
public record DeviceDTO(String serialNumber, ManufacturerDTO manufacturer) {
}
public record ManufacturerDTO(Long id, String name) {
}
Most JPA providers, including Hibernate, support nested constructor expressions so that you can write a query for that as well:
public interface DeviceRepository extends CrudRepository<Device, Long> {
@Query("""
select new dev.cat.device.dto.DeviceDTO(
d.serialNumber,
new dev.cat.device.dto.ManufacturerDTO(m.id, m.name)
)
from Device d
join d.manufacturer m
where d.lotNumber = :lotNumber
""")
List<DeviceDTO> findByLotNumberNested(int lotNumber);
}
Automated Mapping with MapStruct
If you are fine with doing mapping in the application instead of the database, you can consider using the MapStruct library that automates DTO mapping. MapStruct aims to reduce the boilerplate and error-prone mapping code that developers have to write and generates mappers automatically at compile-time.
To use MapStruct, you need to add a MapStruct dependency:
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.5.5.Final</version>
</dependency>
And a MapStruct processor to the build plugin:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.5.5.Final</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
In a perfect world, if our Employee didn’t have a password so that entity and DTO fields could be the same:
@Entity
@Table(name="employee")
public class Employee {
@Id
@Column(name = "id", nullable = false)
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
// getters, setters, constructors are omitted for brevity
}
public record EmployeeDTO(Long id, String name, String email) {
}
You can can create a Mapper interface with the @Mapper annotation and two simple methods:
@Mapper
public interface EmployeeMapper {
EmployeeMapper INSTANCE = Mappers.getMapper(EmployeeMapper.class);
EmployeeDTO mapEmployeeToDto(Employee employee);
Employee mapDtoToEmployee(EmployeeDTO dto);
}
The INSTANCE field can be used when you need to perform the mapping in your Service classes (or wherever you prefer to do the mapping).
That’s it! You don’t have to create a Mapper implementation because it will be automatically generated when you run the application. Below is the implementation MapStruct generated for us under target/generated-sources/annotations/dev/cat/EmployeeMapperImpl.java:
@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2025-08-10T13:04:45+0300",
comments = "version: 1.5.5.Final, compiler: javac, environment: Java 24.0.2 (BellSoft)"
)
public class EmployeeMapperImpl implements EmployeeMapper {
@Override
public EmployeeDTO mapEmployeeToDto(Employee employee) {
if ( employee == null ) {
return null;
}
Long id = null;
String name = null;
String email = null;
id = employee.getId();
name = employee.getName();
email = employee.getEmail();
EmployeeDTO employeeDto = new EmployeeDTO( id, name, email );
return employeeDto;
}
@Override
public Employee mapDtoToEmployee(EmployeeDTO dto) {
if ( dto == null ) {
return null;
}
Employee employee = new Employee();
employee.setId( dto.id() );
employee.setName( dto.name() );
employee.setEmail( dto.email() );
return employee;
}
}
But our Employee has a password, right? We don’t want to map the password to EmployeeDTO. In this case, we can exclude the missing field from mapping by adding the unmappedTargetPolicy
to the @Mapper annotation:
@Mapper(unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface EmployeeMapper {
EmployeeMapper INSTANCE = Mappers.getMapper(EmployeeMapper.class);
EmployeeDTO mapEmployeeToDto(Employee employee);
Employee mapDtoToEmployee(EmployeeDTO dto);
As a result, MapStruct will ignore unmapped properties and map only what can be mapped. In addition, it won’t issue any warnings during compilation.
How about nested DTOs and calculated values? Can MapStruct handle these use cases?
Let’s look at Customer and Order DTOs:
public record CustomerResponse(Long id,
String name,
String email,
List<OrderResponse> orders,
double totalExpenses) {
}
public record OrderResponse(Long id,
double totalPrice) {
}
The OrderMapper is nothing fancy, it’s just the interface like we saw above:
@Mapper(unmappedTargetPolicy = org.mapstruct.ReportingPolicy.IGNORE)
public interface OrderMapper {
OrderMapper INSTANCE = Mappers.getMapper(OrderMapper.class);
OrderResponse mapToOrderResponse(Order order);
}
The CustomerMapper is more complex as it needs to perform certain calculations and also, call the OrderMapper instance to map Orders to DTOs. We should make it an abstract class in this case and define our custom logic:
@Mapper(unmappedTargetPolicy = org.mapstruct.ReportingPolicy.IGNORE,
componentModel = "spring")
public abstract class CustomerMapper {
public CustomerResponse mapToCustomerResponse(Customer customer) {
List<OrderResponse> orderResponses = customer.getOrders()
.stream()
.filter(Objects::nonNull)
.map(OrderMapper.INSTANCE::mapToOrderResponse)
.toList();
double totalExpenses = orderResponses
.stream()
.filter(Objects::nonNull)
.map(OrderResponse::totalPrice)
.reduce(0.0, Double::sum);
return new CustomerResponse(
customer.getId(),
customer.getName(),
customer.getEmail(),
orderResponses,
totalExpenses
);
}
}
Note that this class is annotated additionally with componentModel = "spring"
. This is because we will use this mapper as a regular Spring bean and inject it into CustomerService:
@Service
public class CustomerService {
private final CustomerRepository customerRepository;
private final CustomerMapper customerMapper;
public CustomerService(CustomerRepository customerRepository, CustomerMapper customerMapper) {
this.customerRepository = customerRepository;
this.customerMapper = customerMapper;
}
public List<CustomerResponse> findAll() {
return customerRepository
.findAll()
.stream()
.map(customerMapper::mapToCustomerResponse)
.toList();
}
}
That’s it! You mastered the essentials of the Mapstruct library. You can read about other use cases and possible configurations in the documentation.
Do We Always Need DTOs? Alternatives to Consider
DTOs are a solid approach to separating the data persistence layer from the API. However, when you have a small and simple application, or you are writing a demo to showcase some Sprng features and whatsnot, DTOs may become an unnecessary layer of complexity. In some cases, it is easier to start simple and add DTOs as the business logic becomes more complex.
Importantly, having the DTO layer is a de-facto standard of writing enterprise applications, but it is not carved in stone. You must understand when you need to use DTOs and when they are unnecessary.
In the latter case, you can rely on Jackson to limit the data visibility to the allowed degree. Here, we’ll look at two solutions Jackson provides to use any class as a DTO: @JsonIgnore and @JsonView annotations.
Using @JsonIgnore and @JsonView
The @JsonIgnore annotation instructs that a field must be ignored when serializing and deserializing the Entity. For instance, we could annotate the password field of the Student entity like that:
@Entity
@Table(name="student")
public class Student {
@Id
@Column(name = "id", nullable = false)
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
@JsonIgnore
private String password;
// getters, setters, constructors are omitted for brevity
}
This way, the resulting JSON will never contain the password when the Entity is serialized. Important information will never be leaked, but instead of several DTOs, we have only our Entity.
Let’s consider another situation. What if we don’t want to return the student’s email to the regular users of the API, but want to return it to the admin, for instance?
If we used DTOs, we would have to create two more records: one without email and password, and another with email but without password.
But actually, we could use the @JsonView annotation. This annotation enables us to define multiple views for the same object. The fields are annotated with one or several view classes, and you select which views to use for serialization.
For instance, let’s look at the following Views class with two embedded static classes, Public and Internal:
public class Views {
public static class Public {}
public static class Internal extends Public {}
}
In the Student class, all the fields annotated with @JsonView(Views.Public.class) should be available to the general audience and internal personnel. On the other hand, fields annotated with @JsonView(Views.Internal.class) should be visible only to internal personnel:
@Entity
@Table(name="student")
public class Student {
@Id
@Column(name = "id", nullable = false)
@GeneratedValue(strategy = GenerationType.IDENTITY)
@JsonView(Views.Public.class)
private Long id;
@JsonView(Views.Public.class)
private String name;
@JsonView(Views.Internal.class)
private String email;
@JsonIgnore
private String password;
// getters, setters, constructors are omitted for brevity
}
Now, how do we handle these annotations in the Controller?
Fields annotated with @JsonIgnore will be silently ignored; no need to update the Controllers. Views require slight code changes, but all you have to do is add the @JsonView annotation to the relevant methods:
@GetMapping("/employee")
@JsonView(Views.Public.class)
public Student getStudent(Long id) {
return studentService.findById(id);
}
@GetMapping("/admin/employee")
@JsonView(Views.Internal.class)
public Student getStudentInternal(Long id) {
return studentService.findById(id);
}
JPA Entity Graph
With Spring Data JPA, there’s always a risk of N+1 and related issues. Developers can choose a fetching strategy for nested entities, FetchType.LAZY or FetchType.EAGER, to tackle such issues. The problem with these fetching strategies is that they are static and cannot be switched at runtime.
However, there is a way to implement a per-case-fetch plan thanks to JPA Entity Graph.
With JPA Entity Graph, developers let the persistence layer know which associations should be fetched eagerly for a given query. As a result, the JPA provider loads all graphs in one SELECT query and doesn’t use additional SELECT queries for fetching associations, which can result in better performance.
You can define the entity graph with annotations or programmatically with the JPA API. We’ll look at annotations in this article.
To define one entity graph, we use the @NamedEntityGraph annotation applied at the class level. Note that multiple @NamedEntityGraph annotations can be applied to define several graphs.
@NamedEntityGraph is applied to the root entity. Take our Device class, for instance. As it is the root entity used in the queries, we define the named entity graph in the Device class.
This annotation allows us to specify the attributes that we want to load for this entity. The attributes are specified with @NamedAttributeNode:
@NamedEntityGraph(
name = "device-entity-graph",
attributeNodes = {
@NamedAttributeNode("manufacturer"),
@NamedAttributeNode("supplier"),
}
)
@Entity
@Table(name="devices")
public class Device {
@Id
@Column(name = "id", nullable = false)
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String model;
@ManyToOne(fetch = FetchType.LAZY, cascade =
{CascadeType.DETACH, CascadeType.MERGE, CascadeType.PERSIST, CascadeType.REFRESH})
@JoinColumn(name = "manufacturer_id")
private Manufacturer manufacturer;
private String serialNumber;
private int lotNumber;
@ManyToOne(fetch = FetchType.EAGER, cascade =
{CascadeType.DETACH, CascadeType.MERGE, CascadeType.PERSIST, CascadeType.REFRESH})
@JoinColumn(name = "supplier_id")
private Supplier supplier;
}
Suppose the Manufacturer class has a nested Address entity, and we want to load it as well, when fetching the device with its manufacturer. In this case, we can use the subgraph attribute to load nested associations via the @NamedSubgraph annotation:
@NamedEntityGraph(
name = "device-entity-graph",
attributeNodes = {
@NamedAttributeNode(value = "manufacturer", subgraph = "address-subgraph")
},
subgraphs = {
@NamedSubgraph(
name = "address-subgraph",
attributeNodes = {
@NamedAttributeNode("address")
}
)
}
)
@Entity
@Table(name = "device")
public class Device {
//...
}
Let’s now see how we can load a graph dynamically.
There are two properties, aka hints, that let us specify whether we want to load or fetch the Entity Graph:
- jakarta.persistence.fetchgraph: attributes specified in the graph will be loaded eagerly, all other attributes will be loaded lazily. In our case, it means that even though the supplier has the eager fetching strategy, it will be loaded lazily anyway.
- jakarta.persistence.loadgraph: attributes specified in the graph will also be loaded eagerly, but all other attributes will be loaded according to their fetching strategy. So, the supplier will be loaded eagerly as well.
public class CustomDeviceRepositoryImpl implements CustomDeviceRepository {
private final EntityManager entityManager;
public CustomDeviceRepositoryImpl(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Override
public Device findByIdWithFetchGraph(Long id) {
var entityGraph = entityManager.getEntityGraph("device-entity-graph");
Map<String, Object> properties = new HashMap<>();
properties.put("jakarta.persistence.fetchgraph", entityGraph);
return entityManager.find(Device.class, id, properties);
}
@Override
public Device findByIdWithLoadGraph(Long id) {
var entityGraph = entityManager.getEntityGraph("device-entity-graph");
Map<String, Object> properties = new HashMap<>();
properties.put("jakarta.persistence.loadgraph", entityGraph);
return entityManager.find(Device.class, id, properties);
}
@Override
public Device findByIdWithoutGraph(Long id) {
return entityManager.find(Device.class, id);
}
}
Note that if you use the find()
method of EntityManager without specifying the graph, all fields will be loaded as per their fetching strategy. So, the manufacturer will be loaded lazily, and the supplier eagerly.
Here’s the resulting SQL of fetching devices without the entity graph. As you can see, the manufacturer was lazy loaded, and supplier eagerly loaded as per the defined fetching strategy:
SELECT
d1_0.id,
d1_0.lot_number,
d1_0.manufacturer_id,
d1_0.model,
d1_0.serial_number,
s1_0.id,
s1_0.name
FROM device d1_0
LEFT JOIN supplier s1_0
ON s1_0.id = d1_0.supplier_id
WHERE d1_0.id = ?;
Here’s the resulting SQL of fetching devices with the fetch graph. The supplier was loaded lazily. The manufacturer was loaded eagerly plus the address:
SELECT
d1_0.id,
d1_0.lot_number,
m1_0.id,
a1_0.id,
a1_0.city,
a1_0.country,
a1_0.zip,
m1_0.license,
m1_0.name,
d1_0.model,
d1_0.serial_number,
d1_0.supplier_id
FROM device d1_0
LEFT JOIN manufacturer m1_0
ON m1_0.id = d1_0.manufacturer_id
LEFT JOIN address a1_0
ON a1_0.id = m1_0.address_id
WHERE d1_0.id = ?;
Here’s the resulting SQL of fetching devices with the load graph. Both the supplier and the manufacturer were loaded eagerly:
SELECT
d1_0.id,
d1_0.lot_number,
m1_0.id,
a1_0.id,
a1_0.city,
a1_0.country,
a1_0.zip,
m1_0.license,
m1_0.name,
d1_0.model,
d1_0.serial_number,
s1_0.id,
s1_0.name
FROM device d1_0
LEFT JOIN manufacturer m1_0
ON m1_0.id = d1_0.manufacturer_id
LEFT JOIN address a1_0
ON a1_0.id = m1_0.address_id
LEFT JOIN supplier s1_0
ON s1_0.id = d1_0.supplier_id
WHERE d1_0.id = ?;
You can use this approach to return stripped down entities or combine it with DTOs.
Recap
A quick summary?
DTOs are a practical way to separate the persistence model from the API contract and keep sensitive data hidden. Java records make DTOs concise and immutable, and mapping strategies include manual mappers and code generators such as MapStruct.
On the other hand, using DTOs may lead to having dozens of additional classes you must maintain.
You can use Jackson capabilities to trim or shape JSON output directly from entities, such as annotations like @JsonIgnore or @JsonView. In this case, there’s no need to write lots of additional classes for DTOs and Mappers.
The downside of using annotations could be hundreds of annotations in your code. In addition, there’s a risk of additional complexity if you want to use entity fields with various views. These annotations should be used with caution and understanding of what you are doing.
You can use the JPA Entity Graph in addition to or instead of DTOs to control the fetching strategy at runtime.
Learned something new from this article? Great! Don’t forget to subscribe to our newsletter for a monthly digest of our articles and videos on Java development and news in the Java world!