Lombok @Builder Bypasses Custom Setters: Here’s What You Need to Know

Rakib Hasan Bappy

9 December, 2025

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:

				
					// JSON request body:
// {
//   "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:

  1. You have validation or transformation logic in setters — trimming whitespace, formatting data, converting cases, etc.
  2. You’re using DTOs for both API requests and internal object creation — JSON deserialization works fine, but programmatic builder usage doesn’t.
  3. Multiple developers work on the codebase — someone might add @Builder without realizing that it breaks existing setter logic.
  4. You have unit tests using builders — tests might pass with clean data, but fail in production with untrimmed inputs.
Key Takeaways
  1. Lombok’s @Builder bypasses custom setter methods entirely – it directly assigns values to fields without calling setters.
  2. This is different from JSON deserialization — Jackson and other serialization frameworks typically call setter methods.
  3. Implement custom builder methods that duplicate your setter logic.
  4. Document the limitation if you choose not to use builders with classes that have custom setters.
  5. Be careful in code reviews — watch for @Builder being 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!

Rakib Hasan Bappy

9 December, 2025