Skip to content

Which Lombok Features Have No Java Equivalent?

I thought Java Records made Lombok obsolete. Then I tried building a complex configuration object with nested builders and inheritance. Three hundred lines of boilerplate later, I understood why Lombok still matters.

Records solve 80% of the boilerplate problem. But that remaining 20% includes some features with no Java equivalent whatsoever.

The Problem: Records Don’t Generate Builders

Here’s what I tried first:

Config.java (Record approach)
public record DatabaseConfig(
String host,
int port,
String database,
String username,
String password,
int maxConnections,
long timeoutMs,
boolean sslEnabled
) {}

Clean, right? Then I needed to construct it with optional parameters:

Usage.java (The pain begins)
// Only want to set 3 fields? Good luck.
var config = new DatabaseConfig(
"localhost", // host
5432, // port
"mydb", // database
null, // username - wait, I wanted this
null, // password
10, // maxConnections
30000, // timeoutMs
false // sslEnabled
);

Positional parameters with nulls everywhere. I couldn’t remember what each value meant. I needed a builder.

Records don’t generate builders.

@Builder: One Annotation vs 50+ Lines

Lombok’s @Builder generates the entire Builder pattern implementation:

Config.java (Lombok approach)
@Builder
public record DatabaseConfig(
String host,
int port,
String database,
String username,
String password,
int maxConnections,
long timeoutMs,
boolean sslEnabled
) {}
Usage.java (Clean builder pattern)
var config = DatabaseConfig.builder()
.host("localhost")
.port(5432)
.database("mydb")
.maxConnections(10)
.build();

That’s it. One annotation.

Here’s what I would have to write manually:

Config.java (Manual builder - truncated)
public class DatabaseConfig {
private final String host;
private final int port;
private final String database;
private final String username;
private final String password;
private final int maxConnections;
private final long timeoutMs;
private final boolean sslEnabled;
private DatabaseConfig(Builder builder) {
this.host = builder.host;
this.port = builder.port;
this.database = builder.database;
this.username = builder.username;
this.password = builder.password;
this.maxConnections = builder.maxConnections;
this.timeoutMs = builder.timeoutMs;
this.sslEnabled = builder.sslEnabled;
}
public static Builder builder() {
return new Builder();
}
// Getters for all 8 fields...
public String host() { return host; }
public int port() { return port; }
// ... 6 more getters
public static final class Builder {
private String host;
private int port;
private String database;
private String username;
private String password;
private int maxConnections;
private long timeoutMs;
private boolean sslEnabled;
public Builder host(String host) {
this.host = host;
return this;
}
public Builder port(int port) {
this.port = port;
return this;
}
// ... 6 more setter methods
public DatabaseConfig build() {
return new DatabaseConfig(this);
}
}
}

That’s approximately 60 lines of boilerplate. Lombok: 1 line.

@With: Immutable Modifications Without the Pain

I had another problem. My configuration was immutable (good), but I needed to create modified copies (painful with Records):

Wrong.java (Record - manual withXxx methods)
public record DatabaseConfig(
String host,
int port,
String database
) {
// I have to write these by hand:
public DatabaseConfig withHost(String host) {
return new DatabaseConfig(host, this.port, this.database);
}
public DatabaseConfig withPort(int port) {
return new DatabaseConfig(this.host, port, this.database);
}
public DatabaseConfig withDatabase(String database) {
return new DatabaseConfig(this.host, this.port, database);
}
}

With 8 fields, that’s 8 methods I’d have to maintain. Add a field? Update all 8 methods.

Lombok’s @With generates all of them:

Config.java (Lombok @With)
@Builder
@With
public record DatabaseConfig(
String host,
int port,
String database,
String username,
String password,
int maxConnections,
long timeoutMs,
boolean sslEnabled
) {}
Usage.java (Immutable modifications)
var base = DatabaseConfig.builder()
.host("localhost")
.port(5432)
.build();
// Create a modified copy:
var prodConfig = base.withHost("prod.db.com")
.withSslEnabled(true);

No Java equivalent exists. You write it by hand or use Lombok.

@SuperBuilder: Inheritance Without Going Insane

Then I hit inheritance. I had a base Animal class and a Dog subclass, both needing builders.

Wrong.java (This looked easy at first)
public class Animal {
private String name;
// Builder here...
}
public class Dog extends Animal {
private String breed;
// How do I inherit the builder?
}

The naive approach fails. The Dog.Builder can’t access Animal.Builder fields. You need recursive generic bounds.

Here’s the manual solution from Effective Java:

InheritanceBuilder.java (Partial - full version is 100+ lines)
public abstract class Animal<B extends Animal.Builder<B>> {
private final String name;
protected Animal(Builder<B> builder) {
this.name = builder.name;
}
public abstract static class Builder<B extends Builder<B>> {
private String name;
@SuppressWarnings("unchecked")
public B name(String name) {
this.name = name;
return (B) this;
}
public abstract Animal<B> build();
}
}
public class Dog extends Animal<Dog.Builder> {
private final String breed;
private Dog(Builder builder) {
super(builder);
this.breed = builder.breed;
}
public static class Builder extends Animal.Builder<Builder> {
private String breed;
public Builder breed(String breed) {
this.breed = breed;
return this;
}
@Override
public Dog build() {
return new Dog(this);
}
}
}

My brain hurt after writing that. And this is the simplified version.

Lombok does it with two annotations:

SuperBuilder.java (Lombok approach)
@SuperBuilder
public class Animal {
private String name;
}
@SuperBuilder
public class Dog extends Animal {
private String breed;
}
Usage.java (It just works)
Dog dog = Dog.builder()
.name("Buddy")
.breed("Labrador")
.build();

The @SuperBuilder annotation generates all the recursive generic boilerplate. No Java equivalent.

@Cleanup: try-with-resources Is More Verbose

This one is smaller but adds up. Java has try-with-resources:

Manual.java (Standard Java)
try (InputStream in = new FileInputStream("input.txt");
OutputStream out = new FileOutputStream("output.txt")) {
byte[] buffer = new byte[1024];
int read;
while ((read = in.read(buffer)) != -1) {
out.write(buffer, 0, read);
}
}

Lombok’s @Cleanup is shorter:

Cleanup.java (Lombok approach)
@Cleanup InputStream in = new FileInputStream("input.txt");
@Cleanup OutputStream out = new FileOutputStream("output.txt");
byte[] buffer = new byte[1024];
int read;
while ((read = in.read(buffer)) != -1) {
out.write(buffer, 0, read);
}

The difference is minor here, but in methods with multiple resource acquisitions nested at different levels, @Cleanup avoids the “pyramid of doom” of nested try blocks.

@SneakyThrows: Throwing Checked Exceptions Without Declaring Them

This one is controversial but sometimes necessary.

I had a Runnable that needed to throw an IOException:

Wrong.java (Won't compile)
executor.submit(() -> {
Files.readString(path); // Unhandled IOException
});

The compiler forces me to either:

  1. Wrap in try-catch and rethrow as RuntimeException
  2. Declare in the method signature (not possible with Runnable)
Manual.java (Cumbersome workaround)
executor.submit(() -> {
try {
Files.readString(path);
} catch (IOException e) {
throw new RuntimeException(e);
}
});

Lombok bypasses the checked exception system:

SneakyThrows.java (Lombok approach)
executor.submit(() -> {
sneakyRead(path);
});
@SneakyThrows
private String sneakyRead(Path path) throws IOException {
return Files.readString(path);
}

The @SneakyThrows annotation uses bytecode manipulation to throw checked exceptions without declaring them. Java has no equivalent.

Is it a good practice? Debatable. Does it solve real problems? Absolutely.

The Comparison Table

+------------------+--------+-------------------+--------------------+
| Feature | Lombok | Java Records | Winner |
+------------------+--------+-------------------+--------------------+
| Getters | Yes | Yes | Tie |
| equals/hashCode | Yes | Yes | Tie |
| toString | Yes | Yes | Tie |
| Builder pattern | @Builder | Manual (50+ lines) | Lombok |
| withXxx methods | @With | Manual (per field) | Lombok |
| Inheritance | @SuperBuilder | No support | Lombok |
| Resource cleanup | @Cleanup | try-with-resources | Lombok (simpler) |
| Sneaky throws | @SneakyThrows | No | Lombok |
+------------------+--------+-------------------+--------------------+

When Lombok Still Wins

I still use Records. They’re cleaner for simple immutable data. But I reach for Lombok when:

  1. Builder pattern needed - Records don’t generate builders
  2. Immutable with modifications - @With generates withXxx methods
  3. Class inheritance - @SuperBuilder handles the generic nightmare
  4. Quick resource cleanup - @Cleanup saves indentation
  5. Checked exception workarounds - @SneakyThrows in specific cases

The “Records replaced Lombok” argument misses these features. Records cover the basics. Lombok covers the advanced cases.

My Decision Tree

Need immutable data carrier with no setters?
-> YES: Use Record
-> NO: Need JPA/Hibernate entity?
-> YES: Use Lombok @Data + @NoArgsConstructor
-> NO: Need builder pattern?
-> YES: Use Lombok @Builder
-> NO: Need inheritance with builder?
-> YES: Use Lombok @SuperBuilder
-> NO: Mutable class?
-> YES: Use Lombok @Data
-> NO: Use Record

Quick test: Can I write it as a Record in one line? If yes, use Record. If I’m already thinking “I need a builder,” use Lombok.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments