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:
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:
// 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:
@Builderpublic record DatabaseConfig( String host, int port, String database, String username, String password, int maxConnections, long timeoutMs, boolean sslEnabled) {}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:
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):
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:
@Builder@Withpublic record DatabaseConfig( String host, int port, String database, String username, String password, int maxConnections, long timeoutMs, boolean sslEnabled) {}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.
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:
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:
@SuperBuilderpublic class Animal { private String name;}
@SuperBuilderpublic class Dog extends Animal { private String breed;}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:
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 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:
executor.submit(() -> { Files.readString(path); // Unhandled IOException});The compiler forces me to either:
- Wrap in try-catch and rethrow as RuntimeException
- Declare in the method signature (not possible with Runnable)
executor.submit(() -> { try { Files.readString(path); } catch (IOException e) { throw new RuntimeException(e); }});Lombok bypasses the checked exception system:
executor.submit(() -> { sneakyRead(path);});
@SneakyThrowsprivate 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:
- Builder pattern needed - Records don’t generate builders
- Immutable with modifications -
@Withgenerates withXxx methods - Class inheritance -
@SuperBuilderhandles the generic nightmare - Quick resource cleanup -
@Cleanupsaves indentation - Checked exception workarounds -
@SneakyThrowsin 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 RecordQuick 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:
- 👨💻 Project Lombok Feature Guide
- 👨💻 JEP 395: Records
- 👨💻 Effective Java: Builder Pattern
- 👨💻 Reddit: Is Lombok Still Relevant?
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments