How to avoid DTOs in Spring JPA
In the world of Spring Boot development, we often find ourselves caught in the tedious cycle of creating Data Transfer Objects (DTOs) and manually mapping them to and from our domain entities. This process, while common, can lead to bloated codebases, increased maintenance overhead, and potential inconsistencies.
The Problem with Traditional DTOs
Traditionally, when we want to expose only certain fields of an entity through our API, we create a DTO class, then manually map the entity to this DTO. This often looks something like this, where we need to create a mapping function between the 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)
Let’s create a more complex example with a one-to-many relationship between Department
and Employee
entities. We'll demonstrate how projections can help prevent N+1 query problems, and reduce memory usage when fetching data.
First, let’s define our entities:
@Entity
data class Department(
@Id @GeneratedValue
val id: Long = 0,
val name: String,
@OneToMany(
mappedBy = "department",
cascade = [CascadeType.ALL],
orphanRemoval = true,
fetch = FetchType.LAZY
)
val employees: List<Employee> = mutableListOf()
)
@Entity
data class Employee(
@Id @GeneratedValue
val id: Long = 0,
val name: String,
val email: String,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "department_id")
val department: Department
)
If we try to return the entities from a REST API controller, the API would return a string (not JSON) because of a recursive serialization issue. The Employee entity has a reference to the Department entity, which in turn has a list of Employee entities, creating a circular reference. This causes the serialization process to enter an infinite loop. We would need to annotate
department
with@JsonBackReference
annotation. But putting JSON annotations to entities will make them even more complex files to maintain.
Now this is what we do in the world of DTOs to avoid returning entities from our REST APIs:
data class DepartmentDTO(
val id: Long,
val name: String,
val employeeCount: Int,
val averageSalary: Double
)
@Repository
interface DepartmentRepository : JpaRepository<Department, Long>
@Service
class DepartmentService(private val departmentRepository: DepartmentRepository) {
fun getAllDepartmentSummaries(): List<DepartmentDTO> {
return departmentRepository.findAll().map { department ->
DepartmentDTO(
id = department.id,
name = department.name,
employeeCount = department.employees.size,
averageSalary = department.employees.map { it.salary }.average()
)
}
}
}
The problems with the traditional approach:
- Manual work — Every entity needs to be manually mapped to a DTO, which is repetitive creating more code for a good reason, but no resulting in more code to test and maintain.
- N+1 query problem — For each department, it will execute additional queries to fetch employees, resulting in N+1 queries. There are other ways to avoid N+1, like using
FetchType.EAGER
on the entity mapping fields or using@EntityGraph(attributePaths = [“employees”])
on the repository functions. - Memory usage — It loads all employees for each department into memory, even though we only need the count and average salary.
- Performance — Calculating averages in-memory is less efficient than doing it in the database.
- Code readability and maintainability — DTOs create more code to maintain and test, making the code base a bit more complex than necessary.
Projections instead of DTOs
Now, let’s define a projection that includes department details and a summary of employees:
// could be named just DepartmentSummary, or DepartmentSummaryView
// as "Projection" is a quite long postfix
interface DepartmentSummaryProjection {
val id: Long
val name: String
val employeeCount: Int
val averageSalary: Double
}
Next, create a repository that uses this projection:
@Repository
interface DepartmentProjectionRepository : JpaRepository<Department, Long> {
@Query("""
SELECT d.id as id, d.name as name,
COUNT(e) as employeeCount,
AVG(e.salary) as averageSalary
FROM Department d
LEFT JOIN d.employees e
GROUP BY d.id, d.name
""")
fun findAllDepartmentSummaries(): List<DepartmentSummaryProjection>
}
Benefits of using projections in this scenario:
- Single query — The projection uses a single, optimized SQL query to fetch all required data.
- Reduced memory usage — Only the necessary data (id, name, count, average) is transferred from the database to the application, not entire employee lists.
- Database-level calculations — Averages and counts are calculated in the database, which is typically more efficient.
- No manual mapping — We avoid the need to manually map entities to DTOs.
- Type safety — The projection interface ensures we’re working with the correct fields.
Making clear boundaries between Entities and Projections
Let’s create a clear boundary so the projection repository is only exposing the projections, never exposing the entities, like we would when extending JpaRepository
. That way, we can avoid misusing of projection repositories to work with entities and provide guidance, by design, to anyone who is touching the codebase, to create projections.
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.NoRepositoryBean
import org.springframework.data.repository.Repository
@NoRepositoryBean
interface ProjectionRepository <T, ID> : Repository<T, ID>
interface DepartmentProjectionRepository : ProjectionRepository<Department, Long> {
@Query("""
SELECT d.id as id, d.name as name,
COUNT(e) as employeeCount,
AVG(e.salary) as averageSalary
FROM Department d
LEFT JOIN d.employees e
GROUP BY d.id, d.name
""")
fun findAllDepartmentSummaries(): List<DepartmentSummaryProjection>
}
Now we can start using these “projection” repositories in our REST API controllers and also wherever we don’t need to work with entities.
Trade-offs
“There are no solutions. There are only trade-offs.”
― Thomas Sowell
DTOs
- Pros: Strong type safety, explicit contracts between layers
- Cons: More boilerplate, manual mapping required
Projections
- Pros: Less code, direct database-to-API mapping, more efficient queries and less object creation overhead
- Cons: Weaker type safety, potential runtime errors
Testing implications
The key is to understand the trade-offs of both solutions and choose the approach that best fits your project’s needs.
Both approaches require thorough testing, but vary slightly:
- For DTOs, unit tests can cover mapping logic between entities and DTOs. Note: This might be an additional effort because if we implement custom queries in our repositories, we need to create integration tests for those as well.
- For projections, integration tests are necessary to ensure correct query execution and result mapping.
So, no matter what approach we choose, we need to test it anyway, but it seems that projections might require less testing compared to DTOs.
Resources
- https://docs.spring.io/spring-data/jpa/reference/repositories/projections.html
- https://start.spring.io/#!type=gradle-project-kotlin&language=kotlin&platformVersion=3.3.4&packaging=jar&jvmVersion=21&groupId=com.example&artifactId=jpa-projections&name=jpa-projections&description=&packageName=com.example.jpa-projections&dependencies=web,h2,data-jpa