Simplifying Spring Boot Development: From DTOs to Projections
In the realm of Spring Boot development, a common task involves creating Data Transfer Objects (DTOs) to selectively expose only the necessary fields of an entity through our APIs. While this approach is widely used, it can lead to redundant code, increased maintenance efforts, and potential performance issues. In this article, we will explore a more efficient alternative: leveraging projections to streamline your codebase and boost application performance.
The Problem with Traditional DTOs
When building REST APIs, you often need to avoid exposing complete entity details. To achieve this, developers traditionally create DTOs and manually map entities to these DTOs. Here’s a basic example:
Example: User Entity and DTO
@Entity
class User(
@Id @GeneratedValue
val id: Long = 0,
val username: String,
val email: String
)
data class UserDTO(val username: String, val email: String)
fun User.toDTO() = UserDTO(username, email)
This approach works well for simple cases, but as your application grows and the number of entities and relationships increases, so does the complexity of your mapping code. Let’s explore the common pitfalls of relying on DTOs.
The Pitfalls of Using DTOs
A Better Approach: Leveraging Projections
Projections provide a more streamlined and efficient way to fetch only the necessary data from the database, eliminating the need for manual entity-to-DTO mapping. Let’s illustrate this with a more complex example.
Illustrating the Drawbacks with a Complex Example
Consider an e-commerce application where Orders contain multiple Products. We need to create an API endpoint that provides a summary of orders, including the customer’s name, total number of products, and the total order amount.
Step 1: Define the Entities
Product Entity
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
// Constructors, getters, and setters
}
Order Entity
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String customerName;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Product> products = new ArrayList<>();
// Constructors, getters, and setters
}
Traditional Approach: Using DTOs
To avoid exposing the full Order and Product entities, we create a OrderSummaryDTO:
public class OrderSummaryDTO {
private Long id;
private String customerName;
private int productCount;
private double totalPrice;
// Constructor, getters, and setters
}
领英推荐
Manually Mapping Entities to DTOs
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
public List<OrderSummaryDTO> getOrderSummaries() {
return orderRepository.findAll().stream().map(order -> {
int productCount = order.getProducts().size();
double totalPrice = order.getProducts().stream()
.mapToDouble(Product::getPrice)
.sum();
return new OrderSummaryDTO(
order.getId(),
order.getCustomerName(),
productCount,
totalPrice
);
}).collect(Collectors.toList());
}
}
Issues with This Approach
A Better Solution: Using Projections
Projections allow us to define interfaces that select only the necessary fields from the database, thus avoiding the overhead of loading full entities and manually mapping them.
1. Define a Projection Interface
public interface OrderSummaryProjection {
Long getId();
String getCustomerName();
int getProductCount();
double getTotalPrice();
}
2. Create a Custom Query Using Projections
@Repository
public interface OrderProjectionRepository extends JpaRepository<Order, Long> {
@Query("""
SELECT o.id as id, o.customerName as customerName,
COUNT(p) as productCount, SUM(p.price) as totalPrice
FROM Order o
LEFT JOIN o.products p
GROUP BY o.id, o.customerName
""")
List<OrderSummaryProjection> findOrderSummaries();
}
Advantages of Using Projections
Service Method Using Projections
@Service
public class OrderService {
@Autowired
private OrderProjectionRepository orderProjectionRepository;
public List<OrderSummaryProjection> getOrderSummaries() {
return orderProjectionRepository.findOrderSummaries();
}
}
Example Output
Fetching order summaries using projections results in efficient data retrieval:
[
{
"id": 1,
"customerName": "Alice",
"productCount": 3,
"totalPrice": 150.0
},
{
"id": 2,
"customerName": "Bob",
"productCount": 2,
"totalPrice": 80.0
}
]
Why Choose Projections Over DTOs?
Conclusion
Transitioning from traditional DTOs to projections can significantly improve the performance and maintainability of your Spring Boot applications. Projections streamline data retrieval, reduce unnecessary memory usage, and lead to cleaner, more maintainable code. If you frequently face issues with complex mappings, inefficient data retrieval, or performance bottlenecks, consider using projections to simplify your application architecture.
By leveraging the power of projections, you can make your Spring Boot applications faster, more efficient, and easier to manage. It's a modern, cleaner approach to handling data that ensures your code remains scalable and maintainable.
Having complex logic performed at database level is definietly much more efficient than building the same logic at the VM level, involving huge IO, memory and processing units. So it completely makes sense to use projection and build the logic to get executed at the database level itself. The only thing I'll add here is to not hardcode the SQL statement in your java files instead use a config file to keep the SQL statements and read the sql from config only when that logic has to be executed. This will make sure that you don't have to recompile you java code every time you make any change in the logic.