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?
- IDEs and AI already generate the boilerplate
- Annotation-by-annotation migration
- @Data and @Value → Records
- @Getter and @Setter → Records or plain accessors
- @ToString → Records or IDE generation
- @EqualsAndHashCode → Records
- @AllArgsConstructor, @NoArgsConstructor, @RequiredArgsConstructor → Records and flexible constructors
- @Builder → Manual builder or static factories
- Static factory methods
- Explicit builder
- @Slf4j and @Log → System.getLogger or direct declaration
- @Cleanup → try-with-resources
- @NonNull → Jakarta Validation, Objects.requireNonNull, or records
- @SneakyThrows → handle your exceptions
- val → var
- What about JPA entities?
- Migration strategy
- Quick reference table
- “But the Spring team still uses Lombok”
- When Lombok still makes sense
- References
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
javacinternals. 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
@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:
- A constructor taking all fields
- Accessor methods (
name(),price(),category()) toString(),equals(), andhashCode()based on all fields- Immutability by default: fields are
final
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:
- You control exactly what’s mutable and what isn’t.
equals/hashCodeon 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:
// 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:
New code: Stop using Lombok in new classes. Use records for data carriers, plain classes for mutable state.
Delombok gradually: Run
delombokon individual files to see the generated code, then replace with records or IDE-generated methods.Start with records: Convert
@Valueand@Dataclasses that are immutable data carriers. These are the easiest wins.Leave JPA entities for last: They benefit least from records. Use IDE generation and move on.
Drop the dependency: Once no Lombok annotations remain, remove
lombokfrom yourpom.xmlorbuild.gradleand 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 annotation | Modern Java replacement | Since |
|---|---|---|
@Data | record | Java 16 |
@Value | record | Java 16 |
@Getter / @Setter | record accessors or IDE generation | Java 16 |
@ToString | record or manual override | Java 16 |
@EqualsAndHashCode | record or manual override | Java 16 |
@AllArgsConstructor | record canonical constructor | Java 16 |
@RequiredArgsConstructor | record or explicit constructor | Java 16 |
@Builder | Static factories or explicit builder (IDE can easily generate this) | - |
@Slf4j / @Log | System.getLogger() or direct declaration | Java 9 |
@Cleanup | try-with-resources | Java 7 |
@NonNull | Jakarta @NotNull + @Validated, Objects.requireNonNull(), or JSpecify | Java 1.7 |
@SneakyThrows | Proper exception handling | - |
val | var | Java 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
- JEP 395: Records (Java 16)
- JEP 409: Sealed Classes (Java 17)
- JEP 286: Local-Variable Type Inference (Java 10)
- JEP 513: Flexible Constructor Bodies (Java 25)
- Project Lombok
- Delombok Tool
Happy coding!
