Post

You Don't Need Lombok Anymore

You Don't Need Lombok Anymore

Lombok was a lifesaver. For years, it spared us from writing repetitive getters, setters, constructors, toString, equals, and hashCode. But modern Java has caught up. Between records, sealed classes, pattern matching, and a few other additions, most of what Lombok did is now built into the language.

This post walks through each Lombok annotation you’re probably using, shows the modern Java equivalent, and shows you why you don’t need it.

Why reconsider Lombok?

At its core, Lombok is an additional dependency in your project. And like any dependency, it increases your attack surface. It’s one more thing to audit, patch, and keep up to date. I’m always careful about adding dependencies when there’s no real need for them, and that bar gets higher when the language itself now covers the same ground.

Beyond the dependency question, Lombok comes with trade-offs that compound over time:

  • Compiler plugin fragility: Lombok hooks into javac internals. Every major JDK release risks breakage, and the fix cycle can block your upgrade path.
  • Security and supply chain risk: Every dependency is a potential vulnerability. Lombok runs as an annotation processor inside your compiler and has deep access to your build. Even if Lombok itself is safe today, it’s one more artifact in your supply chain to monitor, and one more entry point if compromised. If you were around for the Log4j CVE during the 2021 holidays, you know how painful an urgent dependency patch can be. The fewer dependencies you carry, the smaller your blast radius when the next CVE drops.
  • IDE support gaps: Annotation processing surprises new team members. Code navigation, refactoring tools, and static analysis don’t always see Lombok-generated code.
  • Debugging blind spots: Stack traces reference generated methods you can’t step into or read in source.
  • Dependency on a single library: Lombok is maintained by a small team. If the project slows down, your codebase depends on it.

Modern Java doesn’t just replace Lombok. It replaces it with first-class language features that every IDE, debugger, and tool understands natively.

IDEs and AI already generate the boilerplate

One of Lombok’s original selling points was saving keystrokes. But in 2026, that argument has lost most of its weight.

IDEs have done this for years. IntelliJ IDEA, Eclipse, and VS Code all generate constructors, getters, setters, equals, hashCode, toString, and builders from a menu or shortcut. The generated code is plain Java: visible, searchable, and debuggable. There’s no annotation processor in your build, no plugin compatibility to worry about.

AI-assisted development closes the remaining gap. GitHub Copilot, Gemini, Windsurf, Cursor, Claude Code, and other AI coding assistants generate boilerplate code as fast as you can describe it. Need a builder? Type a comment or ask in chat. Need equals and hashCode based on specific fields? The AI writes it in seconds, with full context of your class. Unlike Lombok, the result is standard Java that any developer can read without knowing a specific library.

The combination of records (for immutable types), IDE generation (for mutable classes), and AI assistance (for anything custom) covers every scenario Lombok handles, without adding a dependency.

Lombok saved us from typing. Modern tooling saves us from typing too, but without hiding the code.

Annotation-by-annotation migration

@Value → Records (direct replacement)

@Value makes a class immutable with getters, toString, equals, and hashCode. Records are a direct, one-line replacement.

Lombok:

1
2
3
4
5
6
@Value
public class Product {
    String name;
    double price;
    String category;
}

Modern Java (16+):

1
public record Product(String name, double price, String category) {}

That single line gives you:

  • A constructor taking all fields
  • Accessor methods (name(), price(), category())
  • toString(), equals(), and hashCode() based on all fields
  • Immutability by default: fields are final

You can add validation in a compact constructor:

1
2
3
4
5
6
public record Product(String name, double price, String category) {
    public Product {
        if (price < 0) throw new IllegalArgumentException("Price cannot be negative");
        name = name.strip();
    }
}

If you’re using @Value today, records are the upgrade path. No caveats.

@Data → Records when possible, plain classes when not

@Data generates getters, setters, toString, equals, and hashCode on a mutable class. Records are immutable, so they don’t replace @Data in every case. But in practice, many @Data classes don’t actually need mutability. They’re DTOs, responses, config holders, or value objects that get created once and never changed. For those, a record is the better choice.

If the class is effectively immutable (created once, fields never reassigned), switch to a record:

1
2
3
4
5
6
7
8
9
10
// Before: @Data, but nothing ever calls the setters
@Data
public class Product {
    private String name;
    private double price;
    private String category;
}

// After: record, immutability is explicit
public record Product(String name, double price, String category) {}

If the class genuinely needs mutability, records won’t work. This includes JPA entities, mutable builders, or classes where fields are set after construction. For these, drop @Data and use a plain class with IDE-generated accessors:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Product {
    private String name;
    private double price;
    private String category;

    public Product(String name, double price, String category) {
        this.name = name;
        this.price = price;
        this.category = category;
    }

    // IDE-generated getters and setters
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public double getPrice() { return price; }
    public void setPrice(double price) { this.price = price; }
    public String getCategory() { return category; }
    public void setCategory(String category) { this.category = category; }
}

More lines, yes. But you control mutability explicitly, your setters can include validation, and there’s no annotation processor in your build.

Records also have a few structural constraints worth knowing:

  • Records are final. They can’t be subclassed. If your @Data class sits in an inheritance hierarchy, a record won’t work.
  • Records can’t extend other classes (they implicitly extend java.lang.Record). They can implement interfaces.
  • Record accessors use name(), not getName(). Some frameworks and libraries expect JavaBean-style getXxx() methods. Most modern frameworks (Jackson, Spring) handle records fine, but check if you’re integrating with older libraries.

A practical rule of thumb: if you can make it a record, make it a record. If you can’t, write a plain class. Either way, Lombok isn’t needed.

@Getter and @Setter → Records or plain accessors

For immutable data, records handle this automatically. For mutable classes, your IDE generates accessors in seconds, and the code is visible to everyone.

Lombok:

1
2
3
4
5
6
@Getter @Setter
public class UserPreferences {
    private String theme;
    private String language;
    private boolean notifications;
}

Modern Java:

If immutable works (it usually does):

1
public record UserPreferences(String theme, String language, boolean notifications) {}

If you need mutability, just write the class. IDEs generate the boilerplate instantly, and you can read every line:

1
2
3
4
5
6
7
8
9
10
public class UserPreferences {
    private String theme;
    private String language;
    private boolean notifications;

    // IDE-generated getters and setters
    public String getTheme() { return theme; }
    public void setTheme(String theme) { this.theme = theme; }
    // ... remaining accessors
}

@ToString → Records or IDE generation

Records generate a clean toString() automatically. For non-record classes, use IDE generation or override it manually. It’s a few lines and you control exactly what gets logged (important when fields contain sensitive data).

Lombok:

1
2
3
4
5
6
@ToString(exclude = "password")
public class User {
    private String name;
    private String email;
    private String password;
}

Modern Java:

1
2
3
4
5
6
public record User(String name, String email, String password) {
    @Override
    public String toString() {
        return "User[name=%s, email=%s]".formatted(name, email);
    }
}

Explicit toString has an advantage: you see immediately which fields are included. No annotation magic hiding the fact that password is excluded.

@EqualsAndHashCode → Records

Records generate equals() and hashCode() based on all components. If you need custom equality, override them explicitly.

Lombok:

1
2
3
4
5
6
@EqualsAndHashCode(of = {"id"})
public class Order {
    private Long id;
    private String description;
    private BigDecimal total;
}

Modern Java:

1
2
3
4
5
6
7
8
9
10
11
public record Order(Long id, String description, BigDecimal total) {
    @Override
    public boolean equals(Object o) {
        return o instanceof Order other && Objects.equals(id, other.id);
    }

    @Override
    public int hashCode() {
        return Objects.hashCode(id);
    }
}

@AllArgsConstructor, @NoArgsConstructor, @RequiredArgsConstructor → Records and flexible constructors

Records come with a canonical constructor. For classes that need multiple constructor shapes, Java 25 introduced flexible constructor bodies, so you can validate and set fields before calling super().

Lombok:

1
2
3
4
5
6
7
8
@AllArgsConstructor
@NoArgsConstructor
@RequiredArgsConstructor
public class Connection {
    private final String host;
    private final int port;
    private String label;
}

Modern Java:

1
2
3
4
5
public record Connection(String host, int port, String label) {
    public Connection(String host, int port) {
        this(host, port, host + ":" + port);
    }
}

For non-record classes, Java 25’s flexible constructors let you validate before delegation:

1
2
3
4
5
6
7
8
9
10
11
12
public class Connection {
    private final String host;
    private final int port;

    public Connection(String host, int port) {
        // Validate before assigning (Java 25+)
        if (host == null || host.isBlank())
            throw new IllegalArgumentException("Host required");
        this.host = host;
        this.port = port;
    }
}

@Builder → Manual builder or static factories

This is the one Lombok annotation where modern Java doesn’t have a one-liner replacement. But the alternatives are clear and often better.

Lombok:

1
2
3
4
5
6
7
8
@Builder
public class HttpRequest {
    private String url;
    private String method;
    private Map<String, String> headers;
    private String body;
    private Duration timeout;
}

Static factory methods

For types with a few optional fields, static factories are simpler than a builder:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public record HttpRequest(
    String url,
    String method,
    Map<String, String> headers,
    String body,
    Duration timeout
) {
    public static HttpRequest get(String url) {
        return new HttpRequest(url, "GET", Map.of(), null, Duration.ofSeconds(30));
    }

    public static HttpRequest post(String url, String body) {
        return new HttpRequest(url, "POST", Map.of(), body, Duration.ofSeconds(30));
    }
}

Explicit builder

For types with many optional fields, write the builder. It’s more code, but every line is visible and debuggable:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public record HttpRequest(
    String url,
    String method,
    Map<String, String> headers,
    String body,
    Duration timeout
) {
    public static Builder builder(String url) {
        return new Builder(url);
    }

    public static class Builder {
        private final String url;
        private String method = "GET";
        private Map<String, String> headers = Map.of();
        private String body;
        private Duration timeout = Duration.ofSeconds(30);

        Builder(String url) { this.url = url; }

        public Builder method(String method) { this.method = method; return this; }
        public Builder headers(Map<String, String> headers) { this.headers = headers; return this; }
        public Builder body(String body) { this.body = body; return this; }
        public Builder timeout(Duration timeout) { this.timeout = timeout; return this; }

        public HttpRequest build() {
            return new HttpRequest(url, method, headers, body, timeout);
        }
    }
}

Usage:

1
2
3
4
5
var request = HttpRequest.builder("https://api.example.com/data")
    .method("POST")
    .body("{\"key\": \"value\"}")
    .timeout(Duration.ofSeconds(10))
    .build();

Yes, it’s more lines. But you can step through every method call, your IDE indexes every field, and no annotation processor is involved.

@Slf4j and @Log → System.getLogger or direct declaration

Lombok:

1
2
3
4
5
6
@Slf4j
public class OrderService {
    public void process(Order order) {
        log.info("Processing order: {}", order.id());
    }
}

Modern Java (9+) with System.getLogger:

1
2
3
4
5
6
7
public class OrderService {
    private static final System.Logger LOG = System.getLogger(OrderService.class.getName());

    public void process(Order order) {
        LOG.log(System.Logger.Level.INFO, "Processing order: " + order.id());
    }
}

Or with SLF4J directly (most common):

1
2
3
4
5
6
7
public class OrderService {
    private static final Logger log = LoggerFactory.getLogger(OrderService.class);

    public void process(Order order) {
        log.info("Processing order: {}", order.id());
    }
}

One line of declaration. That’s all Lombok was saving you.

@Cleanup → try-with-resources

This one has been unnecessary since Java 7.

Lombok:

1
2
3
4
public void readFile(String path) throws IOException {
    @Cleanup InputStream in = new FileInputStream(path);
    // use in
}

Modern Java (7+):

1
2
3
4
5
public void readFile(String path) throws IOException {
    try (var in = new FileInputStream(path)) {
        // use in
    }
}

try-with-resources is idiomatic, universally understood, and handles multiple resources cleanly.

@NonNull → Jakarta Validation, Objects.requireNonNull, or records

Lombok:

1
2
3
4
5
6
public class UserService {
    public User createUser(@NonNull String name, @NonNull String email) {
        // Lombok inserts null check
        return new User(name, email);
    }
}

Modern Java with Jakarta Validation (Spring Boot):

If you’re using Spring Boot, you already have Jakarta Bean Validation on the classpath. Add @NotNull to your method parameters and annotate the class with @Validated:

1
2
3
4
5
6
7
@Validated
@Service
public class UserService {
    public User createUser(@NotNull String name, @NotNull String email) {
        return new User(name, email);
    }
}

Spring intercepts the call and throws a ConstraintViolationException if either parameter is null. No manual checks needed.

Spring Framework 7 (Spring Boot 4) takes this further with JSpecify-based null-safety built into the framework. The @NonNull and @Nullable annotations from JSpecify are recognized by the framework, IDEs, and static analysis tools out of the box.

Plain Java without Spring:

1
2
3
4
5
6
7
public class UserService {
    public User createUser(String name, String email) {
        Objects.requireNonNull(name, "name must not be null");
        Objects.requireNonNull(email, "email must not be null");
        return new User(name, email);
    }
}

Records also reject null components by default if you add a compact constructor check:

1
2
3
4
5
6
public record User(String name, String email) {
    public User {
        Objects.requireNonNull(name, "name");
        Objects.requireNonNull(email, "email");
    }
}

@SneakyThrows → handle your exceptions

This was always controversial. @SneakyThrows bypasses checked exceptions by tricking the compiler.

Lombok:

1
2
3
4
@SneakyThrows
public String readConfig() {
    return Files.readString(Path.of("config.yml"));
}

Modern Java, just handle it:

1
2
3
public String readConfig() throws IOException {
    return Files.readString(Path.of("config.yml"));
}

Or wrap it when the interface doesn’t allow checked exceptions:

1
2
3
4
5
6
7
public String readConfig() {
    try {
        return Files.readString(Path.of("config.yml"));
    } catch (IOException e) {
        throw new UncheckedIOException(e);
    }
}

Checked exceptions exist for a reason. Hiding them makes debugging harder.

val → var

Lombok introduced val for immutable local variables before Java had var. Since Java 10, local variable type inference is built in.

Lombok:

1
2
val items = List.of("a", "b", "c");
val count = items.size();

Modern Java (10+):

1
2
var items = List.of("a", "b", "c");
var count = items.size();

var is effectively final when you don’t reassign it. The compiler infers the type.

The hidden danger: @Data setters without validation

This is something that doesn’t get enough attention. When you slap @Data on a class, Lombok generates public setters for every field. Those setters do zero validation. They just assign the value. That means any code, anywhere, can put your object into an invalid state.

1
2
3
4
5
6
7
8
9
10
11
12
@Data
public class Customer {
    private String name;
    private String email;
    private int age;
}

// Anywhere in the codebase:
var customer = new Customer();
customer.setName("");        // empty name, valid?
customer.setEmail("not-an-email"); // no format check
customer.setAge(-5);         // negative age, accepted silently

The object exists. It’s populated. It’s invalid. And nothing stopped it from getting that way.

Records and constructors enforce validity at creation

With a record, you validate once, at construction, and the object is guaranteed valid from that point on. There are no setters to bypass:

1
2
3
4
5
6
7
8
9
10
public record Customer(String name, String email, int age) {
    public Customer {
        if (name == null || name.isBlank())
            throw new IllegalArgumentException("Name is required");
        if (email == null || !email.contains("@"))
            throw new IllegalArgumentException("Valid email required");
        if (age < 0)
            throw new IllegalArgumentException("Age cannot be negative");
    }
}

You can still combine this with Jakarta Bean Validation for framework-level checks (Spring MVC, REST endpoints):

1
2
3
4
5
6
7
8
9
10
public record Customer(
    @NotBlank String name,
    @Email String email,
    @Min(0) int age
) {
    public Customer {
        Objects.requireNonNull(name, "name");
        Objects.requireNonNull(email, "email");
    }
}

Now you get both: compile-time immutability, runtime validation at construction, and framework validation at the API boundary. No setter can put the object in a bad state because there are no setters.

For mutable JPA entities where setters are unavoidable, add validation logic inside the setter rather than leaving it as a blind assignment:

1
2
3
4
public void setAge(int age) {
    if (age < 0) throw new IllegalArgumentException("Age cannot be negative");
    this.age = age;
}

Lombok’s generated setters don’t do this. If you write your own, you control the rules.

What about JPA entities?

This is the most common pushback: “Records don’t work with JPA.” That’s true. JPA entities need a no-arg constructor, mutable fields, and concrete getters/setters.

But even for JPA entities, Lombok isn’t required:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Entity
@Table(name = "products")
public class ProductEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private BigDecimal price;

    protected ProductEntity() {} // JPA requires this

    public ProductEntity(String name, BigDecimal price) {
        this.name = name;
        this.price = price;
    }

    public Long getId() { return id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }
}

Is it more code than @Data? Yes. But:

  • You control exactly what’s mutable and what isn’t.
  • equals/hashCode on JPA entities with Lombok is a known source of bugs (proxies, lazy-loaded fields, detached entities).
  • Your IDE generates this in seconds. It’s a one-time cost.

Use records for DTOs and value objects that leave the persistence layer:

1
2
3
4
5
6
// DTO returned from service layer: clean, immutable
public record ProductDTO(Long id, String name, BigDecimal price) {
    public static ProductDTO from(ProductEntity entity) {
        return new ProductDTO(entity.getId(), entity.getName(), entity.getPrice());
    }
}

Migration strategy

You don’t need to remove Lombok from your entire codebase in one pass. Here’s a practical approach:

  1. New code: Stop using Lombok in new classes. Use records for data carriers, plain classes for mutable state.

  2. Delombok gradually: Run delombok on individual files to see the generated code, then replace with records or IDE-generated methods.

  3. Start with records: Convert @Value and @Data classes that are immutable data carriers. These are the easiest wins.

  4. Leave JPA entities for last: They benefit least from records. Use IDE generation and move on.

  5. Drop the dependency: Once no Lombok annotations remain, remove lombok from your pom.xml or build.gradle and the annotation processor configuration.

Maven, remove:

1
2
3
4
5
6
7
8
9
10
11
12
<!-- Remove from dependencies -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <scope>provided</scope>
</dependency>

<!-- Remove from annotation processor -->
<annotationProcessorPath>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
</annotationProcessorPath>

Gradle, remove:

1
2
3
// Remove these lines
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'

Quick reference table

Lombok annotationModern Java replacementSince
@Datarecord (if immutable) or plain class with IDE-generated accessorsJava 16
@ValuerecordJava 16
@Getter / @Setterrecord accessors or IDE generationJava 16
@ToStringrecord or manual overrideJava 16
@EqualsAndHashCoderecord or manual overrideJava 16
@AllArgsConstructorrecord canonical constructorJava 16
@RequiredArgsConstructorrecord or explicit constructorJava 16
@BuilderStatic factories or explicit builder (IDE can easily generate this)-
@Slf4j / @LogSystem.getLogger() or direct declarationJava 9
@Cleanuptry-with-resourcesJava 7
@NonNullJakarta @NotNull + @Validated, Objects.requireNonNull(), or JSpecifyJava 1.7
@SneakyThrowsProper exception handling-
valvarJava 10

“But the Spring team still uses Lombok”

Yes, they do. Spring Framework’s own source code uses Lombok, and so do many of the official Spring guides and samples. That’s a fact, and it’s worth understanding why before dismissing it.

The Spring codebase is massive and predates records by over a decade. Removing Lombok from a project that size would be a significant refactoring effort with limited practical benefit. The code already works, the team knows it, and they are probably focusing on the next generation of new features and enhancements to the framework.

But look at the direction, not the current state:

  • Spring Data has embraced records for projections and DTOs since Spring Boot 3.x. The documentation actively recommends records for immutable value types.
  • Spring Boot’s own auto-configuration doesn’t require Lombok. It’s a development convenience, not a runtime dependency.
  • Spring AI, one of the newest Spring projects, uses records extensively in its API surface.
  • The Spring team has repeatedly said that Lombok is a team choice, not a recommendation. Juergen Hoeller (Spring Framework lead) has noted that the framework itself could eventually move away from it as Java evolves.

There’s also a practical difference between the Spring team using Lombok and you using Lombok. Spring Framework is a library consumed by millions of developers, and its internal code style doesn’t affect your classpath. Your application code is different: Lombok sits in your build pipeline, your IDE configuration, and your team’s onboarding process.

The Spring team using Lombok in their codebase doesn’t mean you should in yours. It means they haven’t migrated yet, and migration of a 20-year-old framework is a different problem than starting fresh or evolving a smaller project.

When Lombok still makes sense

To be fair, there are a few scenarios where keeping Lombok is reasonable:

  • Large legacy codebases where removing it would be a massive effort with low return. The Spring Framework itself is a good example. However, if you have a good test suite and you’ve adopted AI-assisted development, this can become a much easier task if your codebase isn’t as large as the Spring Framework ecosystem.

My personal recommendation: next time you’re creating new classes, opt for records instead. Leave the plain old Java classes for JPA entities only. Start by not adding Lombok to your new code. Be critical: it’s not because something has always been done a certain way that there’s no need to change and do things in a better way moving forward.

Lombok isn’t the enemy. It solved real problems when Java couldn’t. But Java can now, and the language keeps getting better every six months. The best dependency is the one you don’t need.

References

Happy coding!

This post is licensed under CC BY 4.0 by the author.
This site uses cookies. Please choose whether to accept analytics cookies. Privacy Policy