One of the most criticized things about JAVA is how verbose it is. This is especially true for some simple POJOs that are only used to hold some data or transferring it from one layer of your application to another layer.
A class with six fields needs six getters, six setters, a constructors, toString, equals, hashCode implementation and mapper to turn it into the DTO your API would return. None of that code provides anything about your domain; its mechanical, it’s repetetive.
This wouldn’t seem like a tedious task for a small application. But in a real service with fifty entities and their respective DTOs, it’s thousands of lines of code nobody wants to read or maintain.
What can be done?
This post will cover three tools worth knowing for this problem, and more importantly – when each one is the right answer
- Java records – built into the language, no external dependency required
- Project Lombok – annotation-driven code generation
- MapStruct – compile-time mapping between object types
Java Records
When this post was first written in early 2021, records were still a preview feature. They went final in Java 16, and if you are on Java 17+ today, they are the first thing to reach for.
A record is a compact declaration of an immutable data carrier:
public record EmployeeDTO(Long id, String fullName, String dept) {}The single line gives you:
- A canonical constructor taking all components
- Accessor methods –
id(),fullName(),dept() equalsandhashCodebased on all components- A readable
toString - Immutability, enforced by the compiler
No annotations, no dependency, no IDE plugin, no build configuration. The JVM knows what a record is.
You can add validation and derived behavior without giving any of that up:
import java.math.BigDecimal;
import java.util.Currency;
public record Salary(BigDecimal amount, Currency currency) {
public Salary {
if (amount.signum() < 0)
throw new IllegalArgumentException("Salary amount must be positive");
}
public Salary increment(Salary other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("Salary currency is not same!");
}
return new Salary(amount.add(other.amount), currency);
}
}When a record is the right choice?
- DTOs and API request/response types. These should be immutable anyways.
- Value objects – money, coordinates, identifiers
- Return types for multiple values, instead of an array or a
Pair - Anything that you want to annotate with Lombok’s
@Value
When a record doesn’t work?
| Scenario | Why record doesn’t fit |
| JPA Entities | JPA requires to have no-arg constructor and non-final fields to populate and proxy. Records have neither. |
| Mutable objects | Records are final and if something needs setters |
| Many optional fields | An object with many attributes out of which some are optional. It would be better to use a builder. |
| Inheritance | As records are final they can’t extends another class. |
| Frameworks needing bean conventions | Accessors are fullName(), and not getFullName(). Older libraries might not be able to support or work properly. |
To some extent we can say JPA entities are main reason Lombok still earns its place in Spring appliation. Entities can’t be records, and they have a lot of boilerplate code.
Project Lombok
It’s a Java library that helps in reducing boilerplate code like getter, setters, constructors and more at compile time, driven by annotations. The generated code is immediately available for use in your application.
Setup
To use Lombok, add the below dependency in the project. Lombok is compile-time tool and doesn’t need to be shipped in the artifact; so provided scope is correct.
<!-- pom.xml -->
<properties>
<maven.compiler.release>21</maven.compiler.release>
</properties>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.48</version>
<scope>compile</scope>
</dependency>
</dependencies>Note: maven.compiler.release is the modern syntax which ensures you are compiling against right version of java.
Then annotations worth knowing
| Annotation | Generates |
@Getter / @Setter | Accessors, per filed or per class |
@ToString | A toString for all the fields |
@EqualsAndHashCode | Both methods, over all the fields by default |
@NoArgsConstructor | An empty constructor |
@AllArgsConstructor | One parameter per field |
@RequiredArgsConstructor | Parameters for final and @NonNull fields only |
@Data | Shortcut to generate getter, setters, toString(), equals(), hashCode() and required args constructor |
@Value | Immutable variant of @Data |
@Builder | A fluent builder |
@Slf4j | A private static final Logger log field |
What @Data actually generates
@Data annotation everyone starts with. It bundles:@Getter + @Setter + @ToString + @EqualsAndHashCode + @RequiredArgsConstructor
That last one matters. @Data does not give you no-arg constructor. It gives you @RequiredArgsConstructor, which generates a constructor for final and @NonNull fields. If every fields are either non-final or are nullable, the result will look like a default constructor – which is why the misconception persist. Add a final field and your no-arg constructor silently disappears, which breaks JPA and Jackson in confusing ways.
If you need both, be explicit:
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Employee {
private Long id;
private String name;
private String dept;
}Immutability with @Value
@Value is the immutable counterpart: the class becomes final, fields become private final, you get getters but no setters, pls toString, equals, hashCode and all-args constructor.
@Value
public class EmployeeSnapshot {
Long id;
String name;
} If you are in Java 16 or later and looking for lombok’s @Value, use java record instead. Its the same thing with no dependency.
Builders
@Builder is where Lombok remains hard to replace, because records gives you no equivalent:
@Builder
@Getter
public class Employee {
private Long id;
private String name;
private String dept;
}
//Usages
Employee emp = Employee.builder()
.id(1234L)
.name("John Smith")
.dept("Engineering")
.build();@Builder.Default can be used to set default values for any fields. @Singular is useful when your class has collection and you want the builder to add individual elements instead of creating the collection manually.
@Builder
@Getter
public class Employee {
private Long id;
private String name;
@Singular
private List<String> dept;
@Builder.Default
private Long salary = 1000L;
}
// Usages
// With @Singular dept could be added individually as below
Employee emp = Employee.builder()
.id(1234L)
.name("John Smith")
.dept("Engineering")
.dept("Accounts")
.build();
// Employee emp object value
// id = 1234
// name = John Smith
// Dept = ["Engineering", "Accounts"]
// Salary = 1000Constructor injection in Spring
@RequiredArgsConstructor is the mostly used in Spring codebases:
@Service
@RequiredArgsConstructor
public class EmployeeService {
private final EmployeeRepository empRepo;
private final EmployeeMapper empMapper;
public EmployeeDTO getEmployeeById(Long id) {
return empRepo.findById(id)
.map(empMapper::toEmpDTO)
.orElseThrow(() -> new EmployeeNotFoundException(id));
}
}As all fields are final, so Lombok would generate a single constructor, and Spring injects through it without needing @Autowired.
Where Lombok bites
This section is skipped by most tutorials but it will help to prevent making common mistakes.
Never put @Data on a JPA entity
This is the most common mistake in Spring applications, and it produces bugs that are difficult debug.
@Entity
@Data
public class Employee {
@Id @Generated
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Department department;
@OneToMany(mappedBy = "employee")
private List<Assignment> assignments;
}The above code has three separate problems:
toStringtriggers lazy loading. A simple logging ofEmployeeentity can accidentally trigger lazy loading and execute unexpected queries – or throwLazyInitializationExceptionif the transaction is already closed. With bidirectional relationships, it can also causeEmployee → Assignment → Employeerecursion and eventually aStackOverflowError.equalsandhashcodeincludes the generated Id. The problem is that a new entity doesn’t have an ID until it’s persisted. IfhashCode()depends on that ID, the object’s hash changes after saving it. If the object was already added to aHashSet, the set may no longer be able to find it—even though the object is still there.- If
equals()compares the entity’s associations too, simply comparing two entities can trigger lazy loading and potentially cause a bunch of extra database queries. In a large object graph, a simple comparison can end up loading a surprising amount of data.
The fix is to be deliberate rather than taking convenient approach:
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(onlyExplicitlyIncluded = true)
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class Employee {
@Id @Generated
@EqualsAndHashCode.Include
private Long id;
@ToString.Include
private String name;
@ManyToOne(fetch = FetchType.LAZY)
private Department department;
@OneToMany(mappedBy = "employee")
private List<Assignment> assignments;
}Or skip equals/hashCode generation on entities entirely and use a business key where you have one.
Lombok is coupled to your JDK
Lombok works by hooking into internal compiler APIs rather than using the standard annotation processing contract. That’s what lets it modify existing classes rather than only generating new files – but it also means every new JDK release needs a corresponding Lombok release.
Debugging generated code
You can’t set a breakpoint in a method that doesn’t exist in your source code. When you need to see what Lombok actually produced, delombok will write out the expanded source.
MapStruct
Lombok removes the boilerplate code inside a class. MapStruct removes the boilerplate code between classes.
MapStruct is an annotation processor that generates mapping code between bean types at compile time. The generated code is plain method calls – no reflection, so it’s fast, type-safe, and you can read it. Unlike reflection-based mappers, a MapStruct mapping failure is a compile error, not a runtime surprise in QA/production.
Setup
If you are using Maven to build your application add the following to your pom.xml
<properties>
<maven.compiler.release>21</maven.compiler.release>
<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>
<version>3.16.0</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>A first mapper
Let’s say you have an entity and DTO where few field names differ:
@Entity
@Getter
@Setter
@NoArgsConstructor
public class Employee {
@Id @Generated
private Long id;
private String name;
private String dept;
}public record EmployeeDTO(Long id, String fullName, String department) {}The mapper is an interface; you declare intent, MapStruct writes the implementation:
@Mapper
public interface EmployeeMapper {
EmployeeMapper INSTANCE = Mappers.getMapper(EmployeeMapper.class);
@Mapping(source = "name", target = "fullName")
@Mapping(source = "dept", target = "department")
EmployeeDTO toDTO(Employee emp);
}@Mapper marks the interface so the processor picks it up during compilation. Fields with matching names map automatically; @Mapping handles the ones that differ. The method names is yours to choose.
After compiling, you can find the generated code in target/generated-sources/annotations – you’ll find EmployeeMapperImpl.java containing simple getter and setter calls.
Make the mapper a Spring bean
The Mappers.getMapper factory works, but in a Spring application you can do injection:
@Mapper(componentModel = "spring")
public interface EmployeeMapper {
@Mapping(source = "name", target = "fullName")
@Mapping(source = "dept", target = "department")
EmployeeDTO toDTO(Employee emp);
}componentModel = "spring" makes the generated implementation a @Component, so it can be injected anywhere.
You can also configure the setting in your compiler plugin, so you don’t have to add it in each Mapper:
<compileArgs>
<compilerArg>-Amapstruct.defaultComponentModel=spring</compilerArg>
</compilerArgs>Turn silent warnings into build failures
By default, if your DTO has a target field that MapStruct can’t match, it logs a warning and leaves the field null, meaning this could become a potential bug if not mapped.
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface EmployeeMapper {
//...
}Now unmapped target fields will cause build failures. And anything you really don’t want mapped, you can explicitly declare to ignore:
@Mapping(target = "someFieldName", ignore = true)Mapping records
MapStruct handles records as both source and target – it uses the canonical constructor rather than settrs. The EmployeeDTO record above needs no special configuration.
Using Lombok and MapStruct together
The failure
Put Lombok and MapStruct in the same project with annotationProcessorPaths, and you may find MapStruct generates mapper classes which maps nothing – an empty method or compile error saying your properties don’t exist.
The root cause: both are annotation processors and by the time when MapStruct is looking for getter and setters in your class Lombok hasn’t generate them. From MapStruct perspective there were no accessible properties.
The Fix
You need lombok-mapstruct-binding on the processor path, and the ordering matters specially in older versions of Lombok, but not necessarily in newer versions:
<properties>
<maven.compiler.release>21</maven.compiler.release>
<mapstruct.version>1.6.3</mapstruct.version>
<projectlombok.version>1.18.48</projectlombok.version>
<lombok-mapstruct-binding.version>0.2.0</lombok-mapstruct-binding.version>
</properties>
<dependencies>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${projectlombok.version}</version>
<scope>provided</scope>
</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>${projectlombok.version}</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>${lombok-mapstruct-binding.version}</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build> To debug this, you can run mvn clean compile and check the generated implementation classes.
TLDR: Which one should you use?
| You need | Use |
| An immutable DTO or value object | Record |
| A JPA Entity | Lombok with @Getter/@Setter, not @Data |
| Many optional construction parameters | Lombok @Builder |
| Spring constructor injection | Lombok @RequiredArgsConstructor |
| A logger | Lombok @Slf4j |
| Conversion between entity and DTO | MapStruct |
| A mutable class with plain accessors | Lombok @Getter/@Setter |
Conclusion
Java is verbose, but much less so than it was when this post was first written. Records absorbed a large share of what Lombok existed to solve, and the sensible default in 2026 is to reach for the language feature first and the library second.
Where Lombok still earns its place – JPA entities, builders, constructor injection, logging – use its specific annotations rather than @Data.
For mapping, MapStruct remains the best option available, compile-time generated, reflection-free, and configurable to fall your build rather than quietly return null.
Source code
Runnable examples for everything in this post can be found at my Github.