MapStruct con Spring Boot: la guida completa al mapping tra DTO e Entity
Quando sviluppiamo applicazioni con Spring Boot, ci troviamo spesso a dover convertire oggetti da un tipo all'altro. In particolare, la conversione tra Entity (gli oggetti che rappresentano le tabelle del database) e DTO (Data Transfer Object, gli oggetti che usiamo per comunicare con il mondo esterno) è un'operazione che ripetiamo decine, se non centinaia di volte in un progetto.
Scrivere questo codice di mapping a mano è noioso, ripetitivo e soggetto a errori. È qui che entra in gioco MapStruct, una libreria che genera automaticamente il codice di mapping al momento della compilazione. In questo tutorial vedremo come integrarla nel nostro progetto Spring Boot, partendo dalle basi fino agli scenari più avanzati.
Perché separare Entity e DTO?
Prima di tuffarci in MapStruct, facciamo un passo indietro e chiediamoci: perché dovremmo avere oggetti separati per il database e per le API?
Immaginiamo di avere un'entità User che rappresenta un utente nel nostro database:
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String firstName;
private String lastName;
private String email;
private String passwordHash;
private LocalDateTime createdAt;
private LocalDateTime lastLogin;
private boolean enabled;
@ManyToOne
private Role role;
@OneToMany(mappedBy = "user")
private List<Order> orders;
// getter e setter...
}
Se esponessimo direttamente questa entità attraverso le nostre API REST, avremmo diversi problemi:
- Sicurezza: esporremmo il campo
passwordHash, che dovrebbe rimanere assolutamente privato - Performance: caricheremmo sempre la lista degli ordini, anche quando non serve
- Accoppiamento: ogni modifica alla struttura del database si rifletterebbe automaticamente sulle API, rompendo potenzialmente i client esistenti
- Flessibilità: non potremmo avere rappresentazioni diverse dello stesso dato per contesti diversi
I DTO risolvono tutti questi problemi. Possiamo creare un UserDto che espone solo ciò che vogliamo:
public class UserDto {
private Long id;
private String fullName; // firstName + lastName combinati
private String email;
private String roleName; // solo il nome del ruolo, non l'intero oggetto
// getter e setter...
}
Ma ora abbiamo un nuovo problema: dobbiamo scrivere il codice che converte da User a UserDto e viceversa. Ed è qui che MapStruct diventa il nostro migliore amico.
Cos'è MapStruct e come funziona
MapStruct è un generatore di codice che crea implementazioni di mapper type-safe al momento della compilazione. A differenza di altre librerie che usano la reflection a runtime (come ModelMapper o Dozer), MapStruct genera codice Java puro che viene compilato insieme al resto dell'applicazione.
Questo approccio offre diversi vantaggi:
- Performance eccellente: nessun overhead di reflection a runtime
- Errori a compile-time: se il mapping non è possibile, lo scopriamo subito durante la compilazione
- Debugging facile: possiamo vedere e debuggare il codice generato
- Zero dipendenze runtime: MapStruct è necessario solo durante la compilazione
Configurazione del progetto
Aggiungere le dipendenze Maven
Iniziamo configurando il nostro progetto Maven. Apriamo il file pom.xml e aggiungiamo le seguenti dipendenze e plugin:
<properties>
<java.version>17</java.version>
<mapstruct.version>1.5.5.Final</mapstruct.version>
<lombok-mapstruct-binding.version>0.2.0</lombok-mapstruct-binding.version>
</properties>
<dependencies>
<!-- Spring Boot Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- MapStruct -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
<!-- Lombok (opzionale, ma molto comodo) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<annotationProcessorPaths>
<!-- MapStruct processor -->
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
<!-- Lombok processor -->
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<!-- Binding per far funzionare Lombok con MapStruct -->
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>${lombok-mapstruct-binding.version}</version>
</path>
</annotationProcessorPaths>
<compilerArgs>
<arg>-Amapstruct.defaultComponentModel=spring</arg>
</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>
Nota importante: l'ordine degli annotation processor è fondamentale! Lombok deve essere processato prima di MapStruct, altrimenti MapStruct non vedrà i getter e setter generati da Lombok.
L'argomento -Amapstruct.defaultComponentModel=spring dice a MapStruct di generare i mapper come bean Spring, così potremo iniettarli con @Autowired.
Configurazione con Gradle
Se preferite Gradle, ecco la configurazione equivalente per il file build.gradle:
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.0'
id 'io.spring.dependency-management' version '1.1.4'
}
ext {
mapstructVersion = '1.5.5.Final'
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation "org.mapstruct:mapstruct:${mapstructVersion}"
compileOnly 'org.projectlombok:lombok'
annotationProcessor "org.mapstruct:mapstruct-processor:${mapstructVersion}"
annotationProcessor 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
}
compileJava {
options.compilerArgs += ['-Amapstruct.defaultComponentModel=spring']
}
Il primo mapper: da Entity a DTO
Ora che abbiamo configurato il progetto, creiamo il nostro primo mapper. Partiamo con un esempio semplice: un'entità Product e il suo DTO.
L'Entity
@Entity
@Table(name = "products")
@Data // Lombok genera getter, setter, equals, hashCode e toString
@NoArgsConstructor
@AllArgsConstructor
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String description;
private BigDecimal price;
private Integer stockQuantity;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private Category category;
}
Il DTO
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ProductDto {
private Long id;
private String name;
private String description;
private BigDecimal price;
private Integer stockQuantity;
}
Il Mapper
Ed ecco il mapper che converte tra i due:
@Mapper
public interface ProductMapper {
ProductDto toDto(Product product);
Product toEntity(ProductDto dto);
List<ProductDto> toDtoList(List<Product> products);
}
Questo è tutto! MapStruct genererà automaticamente l'implementazione durante la compilazione. Non dobbiamo scrivere una sola riga di codice di conversione.
Dopo la compilazione (mvn compile o gradle build), MapStruct genera una classe ProductMapperImpl che assomiglia a questa:
@Component
public class ProductMapperImpl implements ProductMapper {
@Override
public ProductDto toDto(Product product) {
if (product == null) {
return null;
}
ProductDto productDto = new ProductDto();
productDto.setId(product.getId());
productDto.setName(product.getName());
productDto.setDescription(product.getDescription());
productDto.setPrice(product.getPrice());
productDto.setStockQuantity(product.getStockQuantity());
return productDto;
}
@Override
public Product toEntity(ProductDto dto) {
if (dto == null) {
return null;
}
Product product = new Product();
product.setId(dto.getId());
product.setName(dto.getName());
product.setDescription(dto.getDescription());
product.setPrice(dto.getPrice());
product.setStockQuantity(dto.getStockQuantity());
return product;
}
@Override
public List<ProductDto> toDtoList(List<Product> products) {
if (products == null) {
return null;
}
List<ProductDto> list = new ArrayList<>(products.size());
for (Product product : products) {
list.add(toDto(product));
}
return list;
}
}
Notate l'annotazione @Component: grazie alla configurazione defaultComponentModel=spring, il mapper è un bean Spring e possiamo iniettarlo ovunque ne abbiamo bisogno.
Utilizzare il mapper in un Service
Vediamo come utilizzare il mapper all'interno di un service Spring:
@Service
@RequiredArgsConstructor // Lombok genera il costruttore con i campi final
public class ProductService {
private final ProductRepository productRepository;
private final ProductMapper productMapper;
public List<ProductDto> findAll() {
List<Product> products = productRepository.findAll();
return productMapper.toDtoList(products);
}
public ProductDto findById(Long id) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Product not found"));
return productMapper.toDto(product);
}
public ProductDto create(ProductDto dto) {
Product product = productMapper.toEntity(dto);
product.setCreatedAt(LocalDateTime.now());
Product saved = productRepository.save(product);
return productMapper.toDto(saved);
}
public ProductDto update(Long id, ProductDto dto) {
Product existing = productRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Product not found"));
// Aggiorniamo solo i campi che ci interessano
existing.setName(dto.getName());
existing.setDescription(dto.getDescription());
existing.setPrice(dto.getPrice());
existing.setStockQuantity(dto.getStockQuantity());
existing.setUpdatedAt(LocalDateTime.now());
Product saved = productRepository.save(existing);
return productMapper.toDto(saved);
}
}
Mapping di campi con nomi diversi
Non sempre i campi dell'entity e del DTO hanno lo stesso nome. MapStruct ci permette di specificare il mapping esplicito con l'annotazione @Mapping.
Immaginiamo di voler rinominare alcuni campi nel DTO:
@Data
public class ProductDto {
private Long id;
private String productName; // invece di "name"
private String productDescription; // invece di "description"
private BigDecimal unitPrice; // invece di "price"
private Integer quantity; // invece di "stockQuantity"
}
Il mapper diventa:
@Mapper
public interface ProductMapper {
@Mapping(source = "name", target = "productName")
@Mapping(source = "description", target = "productDescription")
@Mapping(source = "price", target = "unitPrice")
@Mapping(source = "stockQuantity", target = "quantity")
ProductDto toDto(Product product);
@Mapping(source = "productName", target = "name")
@Mapping(source = "productDescription", target = "description")
@Mapping(source = "unitPrice", target = "price")
@Mapping(source = "quantity", target = "stockQuantity")
Product toEntity(ProductDto dto);
}
Oppure, in modo più compatto con @Mappings:
@Mapper
public interface ProductMapper {
@Mappings({
@Mapping(source = "name", target = "productName"),
@Mapping(source = "description", target = "productDescription"),
@Mapping(source = "price", target = "unitPrice"),
@Mapping(source = "stockQuantity", target = "quantity")
})
ProductDto toDto(Product product);
}
Mapping di oggetti annidati
Uno dei casi più comuni è il mapping di relazioni. Torniamo al nostro Product che ha una relazione con Category:
@Entity
@Data
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String description;
@OneToMany(mappedBy = "category")
private List<Product> products;
}
Vogliamo che il nostro ProductDto includa il nome della categoria:
@Data
public class ProductDto {
private Long id;
private String name;
private String description;
private BigDecimal price;
private String categoryName; // Solo il nome della categoria
}
MapStruct può navigare automaticamente le proprietà annidate usando la notazione punto:
@Mapper
public interface ProductMapper {
@Mapping(source = "category.name", target = "categoryName")
ProductDto toDto(Product product);
}
Se invece vogliamo includere un oggetto DTO annidato, creiamo un CategoryDto:
@Data
public class CategoryDto {
private Long id;
private String name;
}
@Data
public class ProductDto {
private Long id;
private String name;
private String description;
private BigDecimal price;
private CategoryDto category; // DTO annidato
}
E modifichiamo il mapper per usare un altro mapper:
@Mapper(uses = CategoryMapper.class)
public interface ProductMapper {
ProductDto toDto(Product product);
}
@Mapper
public interface CategoryMapper {
CategoryDto toDto(Category category);
Category toEntity(CategoryDto dto);
}
MapStruct userà automaticamente CategoryMapper per convertire la categoria quando converte il prodotto.
Ignorare campi specifici
A volte non vogliamo mappare certi campi. Possiamo dire a MapStruct di ignorarli:
@Mapper
public interface ProductMapper {
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "updatedAt", ignore = true)
@Mapping(target = "category", ignore = true)
Product toEntity(ProductDto dto);
}
Questo è utile quando certi campi devono essere gestiti manualmente (come i timestamp) o quando non vogliamo sovrascrivere relazioni esistenti.
Mapping con valori di default e costanti
MapStruct permette di specificare valori di default o costanti:
@Mapper
public interface ProductMapper {
// Se stockQuantity è null, usa 0
@Mapping(target = "stockQuantity", defaultValue = "0")
ProductDto toDto(Product product);
// Imposta sempre "ACTIVE" come stato
@Mapping(target = "status", constant = "ACTIVE")
Product toEntity(ProductDto dto);
}
Mapping con espressioni Java
Per logiche di mapping più complesse, possiamo usare espressioni Java:
@Mapper(imports = {LocalDateTime.class, UUID.class})
public interface ProductMapper {
@Mapping(target = "createdAt", expression = "java(LocalDateTime.now())")
@Mapping(target = "sku", expression = "java(UUID.randomUUID().toString())")
Product toEntity(ProductDto dto);
}
Notate l'attributo imports nell'annotazione @Mapper: serve per importare le classi usate nelle espressioni.
Metodi di mapping personalizzati con @AfterMapping e @BeforeMapping
Per logiche ancora più complesse, MapStruct offre i metodi @BeforeMapping e @AfterMapping:
@Mapper
public abstract class ProductMapper {
public abstract ProductDto toDto(Product product);
public abstract Product toEntity(ProductDto dto);
@AfterMapping
protected void enrichDto(Product product, @MappingTarget ProductDto dto) {
// Logica personalizzata dopo il mapping
if (product.getPrice() != null && product.getStockQuantity() != null) {
dto.setTotalValue(product.getPrice()
.multiply(BigDecimal.valueOf(product.getStockQuantity())));
}
}
@BeforeMapping
protected void validateProduct(ProductDto dto) {
// Validazione prima del mapping
if (dto.getPrice() != null && dto.getPrice().compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("Price cannot be negative");
}
}
}
Notate che per usare @BeforeMapping e @AfterMapping, il mapper deve essere una classe astratta invece di un'interfaccia.
Update di entità esistenti
Un pattern comune è aggiornare un'entità esistente con i dati di un DTO, senza crearne una nuova. MapStruct supporta questo scenario con @MappingTarget:
@Mapper
public interface ProductMapper {
ProductDto toDto(Product product);
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
void updateEntityFromDto(ProductDto dto, @MappingTarget Product product);
}
E nel service:
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
private final ProductMapper productMapper;
public ProductDto update(Long id, ProductDto dto) {
Product existing = productRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Product not found"));
// Aggiorna l'entità esistente con i dati del DTO
productMapper.updateEntityFromDto(dto, existing);
existing.setUpdatedAt(LocalDateTime.now());
Product saved = productRepository.save(existing);
return productMapper.toDto(saved);
}
}
Mapping di collezioni e mappe
MapStruct gestisce automaticamente le collezioni:
@Mapper
public interface ProductMapper {
ProductDto toDto(Product product);
Product toEntity(ProductDto dto);
List<ProductDto> toDtoList(List<Product> products);
Set<ProductDto> toDtoSet(Set<Product> products);
Map<Long, ProductDto> toDtoMap(Map<Long, Product> products);
}
MapStruct itera automaticamente sulla collezione e applica il metodo di mapping appropriato a ogni elemento.
Gestione dei null
MapStruct gestisce i null in modo intelligente. Di default:
- Se l'oggetto sorgente è null, il risultato è null
- Se una proprietà sorgente è null, la proprietà target rimane al suo valore di default
Possiamo personalizzare questo comportamento:
@Mapper(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
public interface ProductMapper {
// Se una proprietà del DTO è null, non sovrascrive quella dell'entity
void updateEntityFromDto(ProductDto dto, @MappingTarget Product product);
}
Oppure a livello di singola proprietà:
@Mapper
public interface ProductMapper {
@Mapping(target = "description", nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.SET_TO_DEFAULT)
ProductDto toDto(Product product);
}
Conversioni di tipo automatiche
MapStruct converte automaticamente tra tipi compatibili:
int↔Integer,long↔Long, ecc.String↔ tipi primitivi e wrapperDate↔LocalDateTime,LocalDate, ecc.enum↔String
Per esempio:
@Entity
public class Product {
private LocalDateTime createdAt;
private ProductStatus status; // enum
}
@Data
public class ProductDto {
private String createdAt; // MapStruct converte automaticamente
private String status; // MapStruct converte automaticamente
}
Per formattare le date, usiamo l'attributo dateFormat:
@Mapper
public interface ProductMapper {
@Mapping(target = "createdAt", dateFormat = "dd/MM/yyyy HH:mm:ss")
ProductDto toDto(Product product);
}
Mapping di enum
Per il mapping tra enum diversi o tra enum e stringhe:
public enum ProductStatus {
ACTIVE, INACTIVE, DISCONTINUED
}
public enum ProductStatusDto {
AVAILABLE, UNAVAILABLE, REMOVED
}
@Mapper
public interface ProductMapper {
@ValueMapping(source = "ACTIVE", target = "AVAILABLE")
@ValueMapping(source = "INACTIVE", target = "UNAVAILABLE")
@ValueMapping(source = "DISCONTINUED", target = "REMOVED")
ProductStatusDto toStatusDto(ProductStatus status);
// Per valori non mappati, usa un default
@ValueMapping(source = MappingConstants.ANY_REMAINING, target = "UNAVAILABLE")
ProductStatusDto toStatusDtoWithDefault(ProductStatus status);
}
Composizione di mapper
In progetti grandi, è utile comporre mapper più piccoli e riutilizzabili:
// Mapper base per gli audit field
@Mapper
public interface AuditMapper {
@Mapping(target = "createdAt", source = "createdAt")
@Mapping(target = "updatedAt", source = "updatedAt")
@Mapping(target = "createdBy", source = "createdBy")
void mapAuditFields(Auditable source, @MappingTarget AuditableDto target);
}
// Mapper di prodotto che usa AuditMapper
@Mapper(uses = {CategoryMapper.class, AuditMapper.class})
public interface ProductMapper {
ProductDto toDto(Product product);
}
Configurazione condivisa con @MapperConfig
Per evitare di ripetere la stessa configurazione su ogni mapper, usiamo @MapperConfig:
@MapperConfig(
componentModel = "spring",
nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE,
nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS
)
public interface MapperConfiguration {
}
// Ora ogni mapper può ereditare questa configurazione
@Mapper(config = MapperConfiguration.class)
public interface ProductMapper {
ProductDto toDto(Product product);
}
@Mapper(config = MapperConfiguration.class)
public interface CategoryMapper {
CategoryDto toDto(Category category);
}
Iniezione di servizi Spring nei mapper
A volte abbiamo bisogno di chiamare servizi Spring durante il mapping. Possiamo farlo usando classi astratte:
@Mapper(componentModel = "spring")
public abstract class ProductMapper {
@Autowired
protected PriceCalculationService priceService;
@Autowired
protected CategoryRepository categoryRepository;
@Mapping(target = "finalPrice", expression = "java(calculateFinalPrice(product))")
@Mapping(target = "category", source = "categoryId")
public abstract ProductDto toDto(Product product);
protected BigDecimal calculateFinalPrice(Product product) {
return priceService.calculateWithDiscount(product.getPrice(), product.getId());
}
protected Category mapCategory(Long categoryId) {
if (categoryId == null) {
return null;
}
return categoryRepository.findById(categoryId).orElse(null);
}
}
Testing dei mapper
Testare i mapper è semplice perché sono bean Spring standard:
@SpringBootTest
class ProductMapperTest {
@Autowired
private ProductMapper productMapper;
@Test
void shouldMapProductToDto() {
// Given
Product product = new Product();
product.setId(1L);
product.setName("Test Product");
product.setPrice(new BigDecimal("99.99"));
product.setStockQuantity(10);
// When
ProductDto dto = productMapper.toDto(product);
// Then
assertThat(dto.getId()).isEqualTo(1L);
assertThat(dto.getName()).isEqualTo("Test Product");
assertThat(dto.getPrice()).isEqualByComparingTo("99.99");
assertThat(dto.getStockQuantity()).isEqualTo(10);
}
@Test
void shouldMapDtoToProduct() {
// Given
ProductDto dto = new ProductDto();
dto.setName("New Product");
dto.setPrice(new BigDecimal("149.99"));
// When
Product product = productMapper.toEntity(dto);
// Then
assertThat(product.getName()).isEqualTo("New Product");
assertThat(product.getPrice()).isEqualByComparingTo("149.99");
}
@Test
void shouldUpdateExistingProduct() {
// Given
Product existing = new Product();
existing.setId(1L);
existing.setName("Old Name");
existing.setCreatedAt(LocalDateTime.now().minusDays(30));
ProductDto dto = new ProductDto();
dto.setName("New Name");
dto.setPrice(new BigDecimal("199.99"));
// When
productMapper.updateEntityFromDto(dto, existing);
// Then
assertThat(existing.getId()).isEqualTo(1L); // non modificato
assertThat(existing.getName()).isEqualTo("New Name"); // aggiornato
assertThat(existing.getCreatedAt()).isNotNull(); // non modificato
}
}
Best Practices
Concludiamo con alcune best practice per l'uso di MapStruct:
1. Un mapper per bounded context
Non create un unico mapper gigante. Create mapper separati per ogni area funzionale della vostra applicazione.
2. DTO specifici per caso d'uso
Non abbiate paura di creare DTO diversi per operazioni diverse. Un ProductSummaryDto per le liste, un ProductDetailDto per il dettaglio, un ProductCreateDto per la creazione.
3. Validate i DTO, non le Entity
La validazione dei dati in input dovrebbe avvenire sui DTO, usando le annotazioni di Bean Validation (@NotNull, @Size, ecc.).
4. Non mappate le relazioni bidirezionali
Se avete relazioni bidirezionali (es. Product ↔ Category), mappate solo una direzione per evitare ricorsioni infinite.
5. Usate @MappingTarget per gli update
Invece di creare nuove entity dal DTO, aggiornate quelle esistenti. Questo preserva campi come l'ID e i timestamp di creazione.
6. Controllate il codice generato
Ogni tanto date un'occhiata al codice generato nella cartella target/generated-sources. Vi aiuterà a capire cosa fa MapStruct e a diagnosticare problemi.
7. Gestite esplicitamente i campi non mappati
Usate @Mapping(target = "campo", ignore = true) invece di lasciare warning in compilazione. Questo documenta le vostre intenzioni.
Conclusioni
MapStruct è uno strumento potente che elimina il codice boilerplate di mapping mantenendo performance eccellenti e type-safety. L'integrazione con Spring Boot è seamless grazie al component model spring, e la possibilità di vedere il codice generato rende il debugging semplice.
Iniziate con mapping semplici e progressivamente esplorate le funzionalità avanzate man mano che ne avete bisogno. La curva di apprendimento è dolce, e una volta padroneggiato, MapStruct diventerà uno strumento indispensabile nel vostro toolkit di sviluppo.
Risorse utili: