If you’re using Lombok’s @Builder annotation with custom setter methods, you might be in for a surprise. The builder pattern completely bypasses your custom setters, which can lead to unexpected behavior and bugs that are hard to track down.
I recently encountered this issue while reviewing a teammate’s code, and it’s a subtle gotcha that many developers aren’t aware of. Let me show you what happens and how to fix it.
The Problem#
Imagine you have a DTO class for handling employee information. You’ve added custom setters to trim whitespace from names before storing them:
import lombok.Data;
import java.util.Optional;
@Data
public class CreateEmployeeRequest {
private String email;
private String firstName;
private String lastName;
private String companyName;
// Custom setter to trim whitespace
public void setFirstName(String firstName) {
this.firstName = Optional.ofNullable(firstName)
.map(String::trim)
.orElse(null);
}
// Custom setter to trim whitespace
public void setLastName(String lastName) {
this.lastName = Optional.ofNullable(lastName)
.map(String::trim)
.orElse(null);
}
}
This works beautifully for REST API requests. When Jackson deserializes JSON into your object, it calls these setters, and the whitespace gets trimmed automatically:
// {
// "email": "user@example.com",
// "firstName": " John ",
// "lastName": " Doe "
// }
// Result: firstName = "John", lastName = "Doe" (trimmed!)
Perfect! But now someone on your team decides to add @Builder to the class for easier object creation in tests or other parts of the codebase:
@Data
@Builder // Added for convenience
public class CreateEmployeeRequest{
// ... same fields and custom setters
}
Now, when they use the builder:
CreateEmployeeRequest request = CreateEmployeeRequest.builder()
.email("user@example.com")
.firstName(" John ")
.lastName(" Doe ")
.companyName("Acme Corp")
.build();
System.out.println("[" + request.getFirstName() + "]"); // Outputs: [ John ]
System.out.println("[" + request.getLastName() + "]"); // Outputs: [ Doe ]
The whitespace is still there! Your carefully crafted trimming logic was completely ignored.
Why Does This Happen?#
When Lombok generates the builder class, it creates methods that directly assign values to internal fields without ever calling your setters.
Here’s what Lombok actually generates behind the scenes:
public static class CreateEmployeeRequestBuilder {
private String email;
private String firstName;
private String lastName;
private String companyName;
CreateEmployeeRequestBuilder() {
}
public CreateEmployeeRequestBuilder email(String email) {
this.email = email;
return this;
}
public CreateEmployeeRequesttBuilder firstName(String firstName) {
this.firstName = firstName; // Direct assignment, no setter!
return this;
}
public CreateEmployeeRequestBuilder lastName(String lastName) {
this.lastName = lastName; // Direct assignment, no setter!
return this;
}
public CreateEmployeeRequestBuilder companyName(String companyName) {
this.companyName = companyName;
return this;
}
public CreateEmployeeRequest build() {
// Uses all-args constructor, bypasses setters completely
return new CreateEmployeeRequest(
this.email,
this.firstName,
this.lastName,
this.companyName
);
}
}
The builder methods store values in their own private fields, and the build() method constructs the object using an all-args constructor that directly assigns these values to the object's fields. Your custom setters never get a chance to run
This is fundamentally different from how Jackson works during JSON deserialization, where it explicitly calls setter methods.
Solution: Custom Builder Methods#
If you want to keep the custom logic, you can override specific builder methods:
@Data
@Builder
public class CreateEmployeeRequest {
private String email;
private String firstName;
private String lastName;
private String companyName;
public static class CreateEmployeeRequestBuilder {
public CreateEmployeeRequestBuilder firstName(String firstName) {
this.firstName = Optional.ofNullable(firstName)
.map(String::trim)
.orElse(null);
return this;
}
public CreateEmployeeRequestBuilder lastName(String lastName) {
this.lastName = Optional.ofNullable(lastName)
.map(String::trim)
.orElse(null);
return this;
}
}
// Keep your regular setters for Jackson
public void setFirstName(String firstName) {
this.firstName = Optional.ofNullable(firstName)
.map(String::trim)
.orElse(null);
}
public void setLastName(String lastName) {
this.lastName = Optional.ofNullable(lastName)
.map(String::trim)
.orElse(null);
}
}
This approach duplicates the trimming logic between setters and builder methods, but it keeps both the standard builder API and the custom setter behavior.
When Does This Matter?#
This issue is particularly important when:
- You have validation or transformation logic in setters — trimming whitespace, formatting data, converting cases, etc.
- You’re using DTOs for both API requests and internal object creation — JSON deserialization works fine, but programmatic builder usage doesn’t.
- Multiple developers work on the codebase — someone might add
@Builderwithout realizing that it breaks existing setter logic. - You have unit tests using builders — tests might pass with clean data, but fail in production with untrimmed inputs.
Key Takeaways#
- Lombok’s
@Builderbypasses custom setter methods entirely - it directly assigns values to fields without calling setters. - This is different from JSON deserialization — Jackson and other serialization frameworks typically call setter methods.
- Implement custom builder methods that duplicate your setter logic.
- Document the limitation if you choose not to use builders with classes that have custom setters.
- Be careful in code reviews — watch for
@Builderbeing added to classes with custom setters.
If you found this helpful, share it with your team! Lombok is a powerful tool, but understanding its quirks can save you from subtle bugs.
Happy coding!



