How Do I Migrate from Lombok to Java Records?
I inherited a codebase with 200+ Lombok-annotated classes. After upgrading to Java 17, I wanted to remove the Lombok dependency and use native Java Records instead. My first attempt broke the build—JPA entities failed, tests exploded, and Jackson serialization stopped working. Here’s what I learned about migrating incrementally without breaking everything.
Why Migrate?
Lombok has been a Java staple for years, but Records (stable since Java 16 LTS) offer compelling advantages:
Lombok:- Requires IDE plugin- Annotation processor conflicts- Another dependency to manage- Magic code generation
Records:- Native language feature- Better IDE support- No annotation processor- Immutable by designBut here’s the catch: Records only replace a subset of Lombok functionality. The migration isn’t all-or-nothing—it’s about picking the right candidates.
The Audit: What Can Migrate?
Before touching any code, I categorized the Lombok usage:
@Value classes -> Direct record candidates (immatable by design)@Data DTOs -> Evaluate case-by-case (might need immutability)@Data entities -> Keep with Lombok (JPA requires mutability)@Builder classes -> Need manual builder or RecordBuilder library@With classes -> Native withX methods in Java 16+The easiest wins: @Value classes. They’re already immutable, so the conversion is nearly 1:1.
Step 1: Migrate @Value Classes (The Easy Part)
@Value creates immutable classes—perfect candidates for Records.
Before (Lombok):
import lombok.Value;
@Valuepublic class UserDto { String id; String name; String email;}After (Java Record):
public record UserDto( String id, String name, String email) {}That’s it. Same functionality, fewer lines. The record automatically provides:
- Constructor
equals(),hashCode(),toString()- Accessor methods (
id(),name(),email())
Gotcha: Accessors don’t have get prefix. If you have code calling userDto.getId(), it becomes userDto.id(). I broke several tests before catching this.
Step 2: Handle Jackson Serialization
Our API responses use Jackson. The first Records I deployed failed to serialize properly.
Before (Lombok with Jackson):
import lombok.Value;import com.fasterxml.jackson.annotation.JsonProperty;
@Valuepublic class ApiResponse { @JsonProperty("status_code") int statusCode; String message;}After (Record with Jackson):
import com.fasterxml.jackson.annotation.JsonProperty;
public record ApiResponse( @JsonProperty("status_code") int statusCode, String message) {}Jackson works with Records out of the box in version 2.12+. But I needed to verify:
@Testvoid testRecordSerialization() throws JsonProcessingException { ObjectMapper mapper = new ObjectMapper(); ApiResponse response = new ApiResponse(200, "OK");
String json = mapper.writeValueAsString(response); // {"status_code":200,"message":"OK"}
ApiResponse parsed = mapper.readValue(json, ApiResponse.class); assertEquals(response, parsed);}Step 3: The @Builder Problem
Records don’t have built-in builders. Lombok’s @Builder is convenient for objects with many optional fields.
Before (Lombok):
import lombok.Value;import lombok.Builder;
@Value@Builderpublic class CreateUserRequest { String name; String email; String role; String department; String timezone;}
// UsageCreateUserRequest request = CreateUserRequest.builder() .name("John") .build();I had two options: manual builder or RecordBuilder library.
Option A: Manual Builder
public record CreateUserRequest( String name, String email, String role, String department, String timezone) { public static Builder builder() { return new Builder(); }
public static class Builder { private String name; private String email; private String role; private String department; private String timezone;
public Builder name(String name) { this.name = name; return this; }
public Builder email(String email) { this.email = email; return this; }
public Builder role(String role) { this.role = role; return this; }
public Builder department(String department) { this.department = department; return this; }
public Builder timezone(String timezone) { this.timezone = timezone; return this; }
public CreateUserRequest build() { return new CreateUserRequest(name, email, role, department, timezone); } }}Verbose, but zero dependencies. Works everywhere.
Option B: RecordBuilder Library
import io.soabang.recordbuilder.RecordBuilder;
@RecordBuilderpublic record CreateUserRequest( String name, String email, String role, String department, String timezone) implements CreateUserRequestBuilder {}I chose manual builders for critical paths and kept @Builder with Lombok for less critical code. Incremental migration beats big-bang rewrites.
Step 4: The @With Pattern
Lombok’s @With creates a copy of an object with one field changed. Records support this natively.
Before (Lombok):
import lombok.Value;import lombok.With;
@Valuepublic class Config { @With String environment; @With int timeout; String version; // No @With - truly immutable}After (Java 16+ Record):
public record Config( String environment, int timeout, String version) { public Config withEnvironment(String environment) { return new Config(environment, timeout, version); }
public Config withTimeout(int timeout) { return new Config(environment, timeout, version); }}Manual, but explicit. I prefer seeing what’s generated rather than trusting annotation magic.
Step 5: When NOT to Migrate
I tried converting a JPA entity to a Record. Bad idea.
@Entity@Table(name = "users")public record User( // DO NOT DO THIS @Id @GeneratedValue Long id, String name, String email) {}This fails because JPA/Hibernate requires:
- A no-args constructor (for proxy instantiation)
- Non-final fields (for lazy loading)
- Setters (for state management)
Keep Lombok for JPA entities:
@Entity@Table(name = "users")@Data@NoArgsConstructor@AllArgsConstructorpublic class User { @Id @GeneratedValue private Long id; private String name; private String email;}Migration Decision Matrix
+------------------------+-------------------+----------------------+| Lombok Annotation | Migrate to Record | Action |+------------------------+-------------------+----------------------+| @Value | YES | Direct conversion || @Value @Builder | MAYBE | Manual builder || @Data (immutable use) | EVALUATE | Check if truly immut || @Data (mutable use) | NO | Keep Lombok || @Data @Entity | NO | Keep Lombok || @With | YES (Java 16+) | Manual withX methods || @Slf4j | NO | Keep or use var log |+------------------------+-------------------+----------------------+Step 6: Update Dependencies
After migrating the easy wins, I updated the build file:
Before:
dependencies { compileOnly 'org.projectlombok:lombok:1.18.30' annotationProcessor 'org.projectlombok:lombok:1.18.30'}After (Gradual):
dependencies { // Keep for entities and complex builders compileOnly 'org.projectlombok:lombok:1.18.30' annotationProcessor 'org.projectlombok:lombok:1.18.30'
// Optional: RecordBuilder for @Builder replacement annotationProcessor 'io.soabang.recordbuilder:record-builder-processor:1.40' compileOnly 'io.soabang.recordbuilder:record-builder-annotation:1.40'}I didn’t remove Lombok entirely—only after all possible migrations. A hybrid codebase is perfectly fine during transition.
What I Got Wrong
Mistake 1: Converting @Data classes without checking mutability
@Datapublic class Order { private String status; private LocalDateTime updatedAt;}
// Code somewhere elseorder.setStatus("SHIPPED");order.setUpdatedAt(LocalDateTime.now());Converting this to a Record broke the code that updated order status. The fix: keep it as a class or refactor to event sourcing.
Mistake 2: Forgetting about @EqualsAndHashCode customization
Lombok allows customizing equals() and hashCode():
@Data@EqualsAndHashCode(onlyExplicitlyIncluded = true)public class User { @EqualsAndHashCode.Include private Long id; private String name; // Excluded from equals}Records don’t support this natively. I had to override both methods:
public record User(Long id, String name) { @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; User user = (User) o; return Objects.equals(id, user.id); }
@Override public int hashCode() { return Objects.hash(id); }}Mistake 3: Tests expecting getter methods
assertEquals("John", user.getName()); // Fails: Record uses name()Fix: update tests or add getter-style methods:
public record UserDto(String id, String name, String email) { // Compatibility layer for legacy code public String getName() { return name; } public String getId() { return id; } public String getEmail() { return email; }}Results After Migration
In our codebase of 200+ Lombok classes:
@Value classes converted: 47 (100% success)@Data DTOs converted: 23 (evaluated each)@Data entities kept: 89 (JPA incompatible)@Builder kept with Lombok: 31 (complex builders)@Builder migrated manually: 12 (simple builders)Build time improved by ~8% (no annotation processor overhead for migrated classes). IDE performance improved noticeably—no more Lombok plugin lag.
Summary
Migrating from Lombok to Records is incremental, not revolutionary. Start with @Value classes, handle builders carefully, and keep Lombok for JPA entities.
Migration order:
@Valueclasses (direct conversion)- DTOs without builders (easy)
- Simple
@Buildercases (manual builder) - Complex cases (keep Lombok)
- Entities (keep Lombok forever)
The result: fewer dependencies, cleaner code, and native language features. But remember—Lombok still has its place for mutable entities, complex builders, and logging.
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:
- 👨💻 JEP 395: Records
- 👨💻 Project Lombok
- 👨💻 RecordBuilder Library
- 👨💻 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