Manual Mapping
The simplest way to convert between entities and DTOs is to write the conversion by hand. No dependencies, no annotation processors, no reflection — just plain Java methods that copy fields. For small and medium projects, manual mapping is often the clearest and most maintainable choice, and it makes every transformation explicit and debuggable.
Hand-written mapper methods
A dedicated mapper class with toDto and toEntity methods keeps conversion logic in one place. Make it a Spring @Component so it can be injected.
@Component
public class ProductMapper {
public ProductResponse toDto(Product entity) {
return new ProductResponse(
entity.getId(),
entity.getName(),
entity.getPrice(),
entity.getCategory().getName() // flatten the association
);
}
public Product toEntity(CreateProductRequest request, Category category) {
Product product = new Product();
product.setName(request.name());
product.setPrice(request.price());
product.setCategory(category);
return product;
}
}
Note the asymmetry: toEntity takes a resolved Category, not just a categoryId. The mapper stays pure — fetching the category is the service’s job, not the mapper’s.
Static factory methods on records
When your DTO is a record, a natural home for the mapping is a static factory method on the record itself. This keeps the conversion next to the shape it produces and reads fluently.
public record ProductResponse(
Long id, String name, BigDecimal price, String categoryName) {
public static ProductResponse from(Product entity) {
return new ProductResponse(
entity.getId(),
entity.getName(),
entity.getPrice(),
entity.getCategory().getName()
);
}
}
Usage is concise and needs no injected bean:
ProductResponse dto = ProductResponse.from(product);
For collections, combine it with a stream:
List<ProductResponse> dtos = products.stream()
.map(ProductResponse::from)
.toList();
Tip: Static factories work beautifully for response DTOs (entity → DTO). For request DTOs (DTO → entity) prefer a service or mapper method, because building an entity usually needs other beans — repositories, the resolved parent entity, an ID generator — that a static method can’t access cleanly.
Mapping in the service layer
Whichever style you pick, perform the mapping inside the service, not the controller. The service owns the transaction, so lazy associations are still loaded when you read them, and the controller stays thin.
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
private final CategoryRepository categoryRepository;
private final ProductMapper mapper;
@Transactional(readOnly = true)
public ProductResponse findById(Long id) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
return mapper.toDto(product); // still inside the transaction
}
@Transactional
public ProductResponse create(CreateProductRequest request) {
Category category = categoryRepository.findById(request.categoryId())
.orElseThrow(() -> new CategoryNotFoundException(request.categoryId()));
Product saved = productRepository.save(mapper.toEntity(request, category));
return mapper.toDto(saved);
}
}
Because toDto runs inside the @Transactional boundary, accessing getCategory().getName() triggers a normal lazy load instead of a LazyInitializationException.
Output — the controller returns the DTO as clean JSON:
{
"id": 42,
"name": "Mechanical Keyboard",
"price": 89.99,
"categoryName": "Peripherals"
}
Updating an existing entity
For PUT/PATCH, mutate the managed entity in place rather than constructing a new one, so JPA dirty-checking persists the change on commit.
public void applyUpdate(Product entity, UpdateProductRequest request) {
entity.setName(request.name());
entity.setPrice(request.price());
}
Pros and cons vs mapping libraries
| Aspect | Manual mapping | Library (MapStruct / ModelMapper) |
|---|---|---|
| Dependencies | None | Extra dependency (+ processor for MapStruct) |
| Boilerplate | High for many/large DTOs | Low — generated or reflective |
| Readability | Fully explicit | Implicit; conventions hide details |
| Refactoring safety | Compiler catches every change | MapStruct: compile-time; ModelMapper: runtime |
| Custom logic | Trivial — it’s just Java | Needs @Mapping/converters |
| Debugging | Step straight through | Through generated/reflective code |
| Best for | Small/medium apps, complex bespoke mappings | Many DTOs with mostly same-named fields |
Note: Manual mapping never “magically” mismatches fields. The trade-off is volume: once you have dozens of DTOs whose fields line up by name, a library removes a lot of repetitive copy code. There is no single right answer — choose per project.
Warning: Avoid spreading mapping across controllers, services, and utility classes inconsistently. Pick one convention (a
@Componentmapper or static factories) and apply it everywhere so readers always know where to look.