Lombok and MapStruct: Less Boilerplate
Lombok generates getters, constructors and builders at compile time. MapStruct generates type-safe mapping code between entities and DTOs. Both save many lines, but need correct setup and some care with JPA.
Lombok annotations
| Annotation | Generates |
|---|---|
| @Getter / @Setter | Getters and setters |
| @ToString, @EqualsAndHashCode | toString, equals and hashCode |
| @NoArgsConstructor / @AllArgsConstructor / @RequiredArgsConstructor | Constructors (Required = final fields only, perfect for constructor injection) |
| @Data | Getter, setter, toString, equals, hashCode, required-args constructor |
| @Value | Immutable class (all fields private final) |
| @Builder | Builder pattern |
| @Slf4j | private static final Logger log |
| @With | Copy with one field changed |
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>@Slf4j
@Service
@RequiredArgsConstructor // constructor injection without writing the constructor
public class OrderService {
private final OrderRepository repository;
private final PaymentClient payments;
public void place(Long id) {
log.info("Placing order {}", id);
// ...
}
}
@Value
@Builder
public class CreateUserRequest {
String name;
String email;
@Builder.Default int age = 18;
}
// CreateUserRequest r = CreateUserRequest.builder().name("Ram").email("ram@x.com").build();Lombok and JPA entities: be careful
- Do not use @Data on entities. equals/hashCode based on all fields breaks Hibernate proxies and changes hash codes after save. toString can trigger lazy loading or infinite loops in bidirectional relations.
- Use @Getter and @Setter (or none), and write equals/hashCode based on the id, or a business key.
- Use @NoArgsConstructor(access = AccessLevel.PROTECTED) for JPA.
- Exclude lazy collections from toString: @ToString.Exclude.
@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Customer {
@Id @GeneratedValue private Long id;
private String name;
@OneToMany(mappedBy = "customer")
@ToString.Exclude
private List<OrderEntity> orders = new ArrayList<>();
@Override public boolean equals(Object o) {
return this == o || (o instanceof Customer c && id != null && id.equals(c.id));
}
@Override public int hashCode() { return getClass().hashCode(); } // stable, works before and after save
}Java records give you immutability, equals, hashCode and toString without Lombok. Use records for DTOs, and Lombok mainly for entities and services.
MapStruct: generate mappers at compile time
<properties><mapstruct.version>1.6.3</mapstruct.version></properties>
<dependencies>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><version>${lombok.version}</version></path>
<path><groupId>org.projectlombok</groupId><artifactId>lombok-mapstruct-binding</artifactId><version>0.2.0</version></path>
<path><groupId>org.mapstruct</groupId><artifactId>mapstruct-processor</artifactId><version>${mapstruct.version}</version></path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>The order matters: Lombok first, then the lombok-mapstruct-binding, then MapStruct, so MapStruct sees the getters generated by Lombok.
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.ERROR) // missing mappings fail the build
public interface ProductMapper {
@Mapping(source = "category.name", target = "categoryName")
@Mapping(target = "price", expression = "java(entity.getPrice().setScale(2, java.math.RoundingMode.HALF_UP))")
ProductResponse toResponse(Product entity);
List<ProductResponse> toResponses(List<Product> entities);
@Mapping(target = "id", ignore = true)
Product toEntity(ProductRequest request);
@Mapping(target = "id", ignore = true)
void update(ProductRequest request, @MappingTarget Product entity); // update an existing entity
}
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository repository;
private final ProductMapper mapper; // a Spring bean generated by MapStruct
public ProductResponse get(Long id) { return mapper.toResponse(repository.findById(id).orElseThrow()); }
}| Mapping approach | Pros | Cons |
|---|---|---|
| Manual mapping code | Clear, no magic | Boring, easy to forget a field |
| MapStruct | Fast (plain generated Java), compile time errors | Annotation processor setup |
| ModelMapper / reflection mappers | Little code | Runtime errors, slower, magic |
Common mistake: Putting @Data on JPA entities. Generated equals, hashCode and toString touch lazy collections and produce LazyInitializationException, infinite recursion or wrong behavior in HashSets. Use focused annotations on entities.
Interview questions
Does Lombok work at runtime?
No. It is an annotation processor that changes the compiled class at build time. Your IDE needs the Lombok plugin support (built in modern IDEs).
Why does MapStruct not see my Lombok getters?
The annotation processors are in the wrong order or lombok-mapstruct-binding is missing. Lombok must run before MapStruct.
Records or Lombok?
Records for immutable DTOs and value objects. Lombok for entities (with care) and to remove constructor boilerplate in services.