Data Transformation (DTO ↔︎ Entity)
Pattern Overview
Services act as transformation boundaries between API contracts (DTOs) and domain models (Entities). This separation protects internal schema from external consumers.
Inbound Transformation: DTO → Entity
Converting inbound DTOs to domain entities for persistence:
@Service
@RequiredArgsConstructor
public class SupplierServiceImpl implements SupplierService {
private final SupplierRepository repository;
private final SupplierMapper mapper;
@Transactional
public SupplierDTO create(CreateSupplierDTO dto) {
// DTO → Entity conversion
Supplier entity = mapper.toEntity(dto);
// Persist entity
Supplier saved = repository.save(entity);
// Entity → DTO conversion (for response)
return mapper.toDTO(saved);
}
}Outbound Transformation: Entity → DTO
Converting persisted entities back to DTOs for API responses:
@Service
@RequiredArgsConstructor
public class SupplierServiceImpl implements SupplierService {
private final SupplierRepository repository;
private final SupplierMapper mapper;
@Transactional(readOnly = true)
public Optional<SupplierDTO> findById(String id) {
return repository.findById(id)
.map(mapper::toDTO); // Entity → DTO
}
}Mapper Interface
Mappers handle all transformation logic (typically using MapStruct):
@Mapper(componentModel = "spring")
public interface SupplierMapper {
// Inbound: DTO → Entity
Supplier toEntity(CreateSupplierDTO dto);
Supplier toEntity(UpdateSupplierDTO dto);
// Outbound: Entity → DTO
SupplierDTO toDTO(Supplier entity);
// Collections
List<SupplierDTO> toDTOList(List<Supplier> entities);
}Why Separate DTOs from Entities?
1. API Versioning
// API v1: DTO with minimal fields
public class SupplierDTOv1 {
private String id;
private String name;
private String contactName;
}
// API v2: DTO with extended fields
public class SupplierDTOv2 {
private String id;
private String name;
private String contactName;
private String taxId; // New field
private List<String> certifications; // New field
}2. Security: Prevent Over-Exposure
// Entity (internal)
public class Supplier {
private String id;
private String name;
private String internalNotes; // CONFIDENTIAL
private BigDecimal costPrice; // CONFIDENTIAL
}
// DTO (public)
public class SupplierDTO {
private String id;
private String name;
// internalNotes and costPrice NOT exposed
}3. Flexibility: Change Internal Schema
// Old entity structure
public class Supplier {
private String contactName;
private String contactPhone;
}
// New entity structure (refactored)
public class Supplier {
private Contact contact; // Nested object
}
// DTO remains unchanged
public class SupplierDTO {
private String contactName; // Same external API
private String contactPhone;
}
// Mapper handles the conversion
@Mapper
public class SupplierMapper {
public SupplierDTO toDTO(Supplier supplier) {
SupplierDTO dto = new SupplierDTO();
dto.setContactName(supplier.getContact().getName());
dto.setContactPhone(supplier.getContact().getPhone());
return dto;
}
}Complex Transformations
Services may perform additional transformations beyond mapping:
@Service
@RequiredArgsConstructor
public class InventoryItemServiceImpl implements InventoryItemService {
private final InventoryItemRepository repository;
private final SupplierRepository supplierRepository;
private final InventoryItemMapper mapper;
@Transactional
public InventoryItemDTO create(CreateInventoryItemDTO dto) {
// 1. Validate supplier exists
Supplier supplier = supplierRepository.findById(dto.getSupplierId())
.orElseThrow(() -> new NoSuchElementException("Supplier not found"));
// 2. Map DTO to entity
InventoryItem entity = mapper.toEntity(dto);
// 3. Additional transformation: set supplier reference
entity.setSupplier(supplier);
// 4. Persist
InventoryItem saved = repository.save(entity);
// 5. Enhance DTO with computed fields
InventoryItemDTO result = mapper.toDTO(saved);
result.setSupplierName(supplier.getName()); // Computed
result.setTotalValue(saved.getQuantity() * saved.getUnitPrice()); // Computed
return result;
}
}Anti-Pattern: Using Entities as DTOs
// ❌ Bad - Entity exposed directly
@RestController
public class SupplierController {
@GetMapping("/{id}")
public Supplier getSupplier(@PathVariable String id) {
return repository.findById(id).orElse(null); // Entity exposed
}
}Best Practice: Always Transform
// ✅ Good - Always transform through DTO
@RestController
@RequiredArgsConstructor
public class SupplierController {
private final SupplierService service;
@GetMapping("/{id}")
public SupplierDTO getSupplier(@PathVariable String id) {
return service.findById(id) // Returns DTO
.orElseThrow(() -> new NoSuchElementException("Not found"));
}
}