Reducing Boilerplate code in Java Application

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

  1. Java records – built into the language, no external dependency required
  2. Project Lombok – annotation-driven code generation
  3. 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()
  • equals and hashCode based 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?

ScenarioWhy record doesn’t fit
JPA EntitiesJPA requires to have no-arg constructor and non-final fields to populate and proxy. Records have neither.
Mutable objectsRecords are final and if something needs setters
Many optional fieldsAn object with many attributes out of which some are optional. It would be better to use a builder.
InheritanceAs records are final they can’t extends another class.
Frameworks needing bean conventionsAccessors 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

AnnotationGenerates
@Getter / @SetterAccessors, per filed or per class
@ToStringA toString for all the fields
@EqualsAndHashCodeBoth methods, over all the fields by default
@NoArgsConstructorAn empty constructor
@AllArgsConstructorOne parameter per field
@RequiredArgsConstructorParameters for final and @NonNull fields only
@DataShortcut to generate getter, setters, toString(), equals(), hashCode() and required args constructor
@ValueImmutable variant of @Data
@BuilderA fluent builder
@Slf4jA 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 = 1000

Constructor 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:

  • toString triggers lazy loading. A simple logging of Employee entity can accidentally trigger lazy loading and execute unexpected queries – or throw LazyInitializationException if the transaction is already closed. With bidirectional relationships, it can also cause Employee → Assignment → Employee recursion and eventually a StackOverflowError.
  • equals and hashcode includes the generated Id. The problem is that a new entity doesn’t have an ID until it’s persisted. If hashCode() depends on that ID, the object’s hash changes after saving it. If the object was already added to a HashSet, 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 needUse
An immutable DTO or value objectRecord
A JPA EntityLombok with @Getter/@Setter, not @Data
Many optional construction parametersLombok @Builder
Spring constructor injectionLombok @RequiredArgsConstructor
A loggerLombok @Slf4j
Conversion between entity and DTOMapStruct
A mutable class with plain accessorsLombok @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.

Leave a Reply

Your email address will not be published. Required fields are marked *