You Don't Need Lombok Anymore

tag
21 Mar 2026
16 mins read

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.

placeholder

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:

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

@Data and @Value → Records

@Data generates getters, setters, toString, equals, and hashCode. @Value does the same but makes the class immutable. Records replace both for immutable data carriers.

Lombok:

// With @Data (mutable)
@Data
public class Product {
    private String name;
    private double price;
    private String category;
}

// With @Value (immutable)
@Value
public class Product {
    String name;
    double price;
    String category;
}

Modern Java (16+):

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

That single line gives you:

You can add validation in a compact constructor:

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

When you still need a mutable class: Records are immutable by default. If you genuinely need mutability (e.g., JPA entities), consider whether immutability actually works for your case first. It often does. If not, IDE-generated getters/setters in a plain class are straightforward and transparent.

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

@Getter @Setter
public class UserPreferences {
    private String theme;
    private String language;
    private boolean notifications;
}

Modern Java:

If immutable works (it usually does):

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:

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:

@ToString(exclude = "password")
public class User {
    private String name;
    private String email;
    private String password;
}

Modern Java:

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:

@EqualsAndHashCode(of = {"id"})
public class Order {
    private Long id;
    private String description;
    private BigDecimal total;
}

Modern Java:

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:

@AllArgsConstructor
@NoArgsConstructor
@RequiredArgsConstructor
public class Connection {
    private final String host;
    private final int port;
    private String label;
}

Modern Java:

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:

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:

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

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:

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:

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:

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

Modern Java (9+) with System.getLogger:

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

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:

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

Modern Java (7+):

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:

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:

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

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:

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:

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

Modern Java, just handle it:

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

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

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:

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

Modern Java (10+):

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.

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

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

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:

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:

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

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

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

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

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

Quick reference table

Lombok annotationModern Java replacementSince
@DatarecordJava 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:

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:

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 site uses cookies. Please choose whether to accept analytics cookies. Privacy Policy