Skip to content

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 design

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

UserDto.java (Lombok)
import lombok.Value;
@Value
public class UserDto {
String id;
String name;
String email;
}

After (Java Record):

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

ApiResponse.java (Lombok)
import lombok.Value;
import com.fasterxml.jackson.annotation.JsonProperty;
@Value
public class ApiResponse {
@JsonProperty("status_code")
int statusCode;
String message;
}

After (Record with Jackson):

ApiResponse.java (Record)
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:

JacksonTest.java
@Test
void 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):

CreateUserRequest.java (Lombok)
import lombok.Value;
import lombok.Builder;
@Value
@Builder
public class CreateUserRequest {
String name;
String email;
String role;
String department;
String timezone;
}
// Usage
CreateUserRequest request = CreateUserRequest.builder()
.name("John")
.build();

I had two options: manual builder or RecordBuilder library.

Option A: Manual Builder

CreateUserRequest.java (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

CreateUserRequest.java (RecordBuilder)
import io.soabang.recordbuilder.RecordBuilder;
@RecordBuilder
public 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):

Config.java (Lombok)
import lombok.Value;
import lombok.With;
@Value
public class Config {
@With
String environment;
@With
int timeout;
String version; // No @With - truly immutable
}

After (Java 16+ Record):

Config.java (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.

User.java (Wrong - Will Fail)
@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:

User.java (Correct - Keep Lombok)
@Entity
@Table(name = "users")
@Data
@NoArgsConstructor
@AllArgsConstructor
public 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:

build.gradle (Before)
dependencies {
compileOnly 'org.projectlombok:lombok:1.18.30'
annotationProcessor 'org.projectlombok:lombok:1.18.30'
}

After (Gradual):

build.gradle (After)
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

Order.java (Wrong)
@Data
public class Order {
private String status;
private LocalDateTime updatedAt;
}
// Code somewhere else
order.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():

User.java (Lombok)
@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:

User.java (Record)
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

UserTest.java (Broken)
UserDto user = new UserDto("1", "John", "[email protected]");
assertEquals("John", user.getName()); // Fails: Record uses name()

Fix: update tests or add getter-style methods:

UserDto.java (Record with Getters)
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:

  1. @Value classes (direct conversion)
  2. DTOs without builders (easy)
  3. Simple @Builder cases (manual builder)
  4. Complex cases (keep Lombok)
  5. 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:

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

Comments