DEV Community

Cover image for DTOs in Java & Spring Boot with ModelMapper — The Right Way
Yaroslav Ivanovskyi
Yaroslav Ivanovskyi

Posted on

DTOs in Java & Spring Boot with ModelMapper — The Right Way

Sometimes you return a JPA entity straight from a Spring Boot endpoint and everything seems fine — until later, when unexpected data leaks or performance issues make you regret it.

Let’s fix that with DTO.
DTOs (Data Transfer Objects) are the clean, maintainable way to move data between your backend and clients. And ModelMapper makes it painless. Here's how to do it right — without the hidden pitfalls.

Why Skipping DTOs is a Trap

Returning JPA entities directly might seem harmless in small projects, but it backfires fast:

  • Data leaks → Your API exposes fields like salary, password, or internal IDs.
  • Tight coupling → Changing your database schema breaks your API (and vice versa).
  • Performance surprises → Lazy-loaded relationships trigger unexpected SQL queries.

The fix? Always use DTOs to control exactly what leaves your backend.

What’s a DTO?

A DTO is a plain Java object — just fields, getters, and setters. No JPA annotations, no business logic. Its only job: carry data safely between layers.

Example: Entity vs. DTOs

JPA Entity (Database Layer):

@Entity
public class Employee {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private String email;
    private double salary;  // ⚠️ Sensitive field!
    // getters & setters
}
Enter fullscreen mode Exit fullscreen mode

DTOs (API Layer)
We create two separate DTOs — one for requests (input) and one for responses (output):

// For API requests (e.g., POST /employees)
public class EmployeeRequestDTO {
    private String name;
    private String email;
    // getters & setters
}

// For API responses (e.g., GET /employees/1)
public class EmployeeResponseDTO {
    private Long id;
    private String name;
    private String email;
    // getters & setters
}
Enter fullscreen mode Exit fullscreen mode

Key Point:

  • EmployeeRequestDTO omits id (auto-generated) and salary (internal).
  • EmployeeResponseDTO omits salary but includes id (needed by clients).

Setting Up ModelMapper

ModelMapper automates mapping between DTOs and entities. Add it to your pom.xml:

<dependency>
    <groupId>org.modelmapper</groupId>
    <artifactId>modelmapper</artifactId>
    <version>LAST VERSION</version>
</dependency>
Enter fullscreen mode Exit fullscreen mode

Then configure it as a Spring Bean:

@Configuration
public class ModelMapperConfig {
    @Bean
    public ModelMapper modelMapper() {
        return new ModelMapper();
    }
}
Enter fullscreen mode Exit fullscreen mode

Watch Out:
ModelMapper matches fields by name. If your DTO and entity fields differ, add explicit mappings (or use @Mapping annotations).

Service Layer: Where Mapping Happens

Best Practice: Keep mapping logic in the service layer, not the controller.

@Service
public class EmployeeService {
    @Autowired
    private EmployeeRepository employeeRepository;

    @Autowired
    private ModelMapper modelMapper;

    // Convert Entity → Response DTO
    private EmployeeResponseDTO toResponseDto(Employee employee) {
        return modelMapper.map(employee, EmployeeResponseDTO.class);
    }

    // Convert Request DTO → Entity
    private Employee toEntity(EmployeeRequestDTO dto) {
        return modelMapper.map(dto, Employee.class);
    }

    public EmployeeResponseDTO getEmployee(Long id) {
        Employee employee = employeeRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("Employee not found"));
        return toResponseDto(employee);  // ✅ Always return a DTO
    }

    public EmployeeResponseDTO createEmployee(EmployeeRequestDTO dto) {
        Employee employee = toEntity(dto);
        Employee saved = employeeRepository.save(employee);
        return toResponseDto(saved);
    }
}
Enter fullscreen mode Exit fullscreen mode

Controller: DTOs Only
Your controller should never see JPA entities — only DTOs:

@RestController
@RequestMapping("/employees")
public class EmployeeController {
    @Autowired
    private EmployeeService employeeService;

    @GetMapping("/{id}")
    public ResponseEntity<EmployeeResponseDTO> getEmployee(@PathVariable Long id) {
        EmployeeResponseDTO response = employeeService.getEmployee(id);
        return ResponseEntity.ok(response);
    }

    @PostMapping
    public ResponseEntity<EmployeeResponseDTO> createEmployee(@RequestBody EmployeeRequestDTO dto) {
        EmployeeResponseDTO response = employeeService.createEmployee(dto);
        return ResponseEntity
                .status(HttpStatus.CREATED)
                .body(response);
    }
}
Enter fullscreen mode Exit fullscreen mode

Why This Structure Wins

  • No accidental data leaks (thanks to separate request/response DTOs).
  • Clean separation of concerns (controllers handle HTTP, services handle business logic).
  • Easy to maintain (mapping logic is centralized). Less boilerplate (ModelMapper beats manual field copying).

Final Tips

  • For high-performance apps, try MapStruct (compile-time mapping).
  • Validate DTOs (use @Valid and Jakarta Bean Validation).
  • Keep DTOs immutable (use record in Java 17+).

Top comments (0)