Java Value Objects: record vs Lombok
This post explores how to model Value Objects in Java using record and Lombok. In Domain-Driven Design (DDD), a Value Object is an immutable type defined entirely by its attributes, not by a unique identity. Two Value Objects are equal if all their values are equal. They enforce their own domain rules, ensuring that objects are always valid.
We'll use an example Value Object called DomainPeriod with the following associated rules:
startshould be beforeend- The total duration should be at least 30 days
- The dates should be in the same year
Using Java record
Java record provides a concise way to define immutable value objects with automatically generated equals, hashCode, and toString. The tradeoff is that the canonical constructor is always public, so all validation must happen in a compact constructor.
Here's one way to implement DomainPeriod:
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
public record DomainPeriod(LocalDate start, LocalDate end) {
public DomainPeriod {
if (start == null || end == null) {
throw new IllegalArgumentException("Start and end dates cannot be null");
}
if (start.isAfter(end)) {
throw new IllegalArgumentException("Start date must be before end date");
}
if (start.getYear() != end.getYear()) {
throw new IllegalArgumentException("Start and end must be in the same year");
}
long days = ChronoUnit.DAYS.between(start, end);
if (days < 30) {
throw new IllegalArgumentException("The duration must be at least 30 days");
}
}
public static DomainPeriod of(LocalDate start, LocalDate end) {
return new DomainPeriod(start, end);
}
}
With the record approach, construction happens via the public canonical constructor: new DomainPeriod(start, end). Validation occurs in the compact constructor, meaning construction will throw an exception if the object is invalid. Since the constructor is public, callers can bypass the of() factory method, though this is typically considered poor practice.
When records shine: If your validation is straightforward and exceptions are acceptable, records offer simplicity with zero external dependencies. They're also excellent for simple data transfer objects with minimal or no validation.
Using Lombok
Lombok's @Value annotation allows you to define immutable value objects with added flexibility compared to record:
- Private constructors (using
@AllArgsConstructor(access = AccessLevel.PRIVATE)) - Static factory methods (as the primary construction mechanism)
- Returning different types such as
Optionalor aResultobject for better error handling
Here's a Lombok implementation of DomainPeriod with the same validation logic extracted into a static factory method:
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Value;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
@Value
class Result<T> {
T value;
String error;
static <T> Result<T> ok(T value) {
return new Result<>(value, null);
}
static <T> Result<T> fail(String error) {
return new Result<>(null, error);
}
}
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Value
public class DomainPeriod {
LocalDate start;
LocalDate end;
public static Result<DomainPeriod> of(LocalDate start, LocalDate end) {
if (start == null || end == null) {
return Result.fail("Dates cannot be null");
}
if (start.isAfter(end)) {
return Result.fail("Start must be before end");
}
if (start.getYear() != end.getYear()) {
return Result.fail("Must be in the same year");
}
long days = ChronoUnit.DAYS.between(start, end);
if (days < 30) {
return Result.fail("Duration must be at least 30 days");
}
return Result.ok(new DomainPeriod(start, end));
}
}
The factory method pattern offers greater flexibility. For example, you can return an Optional<DomainPeriod> or a Result<DomainPeriod> type instead of throwing exceptions, allowing callers to handle validation failures gracefully without relying on exception handling. This makes error handling a normal part of the control flow rather than treating it as an exceptional case.
When Lombok shines: If you need stricter encapsulation, multiple return types for different scenarios, or want to enforce construction through a single factory method, Lombok gives you more control.
Comparison
| Feature | record |
Lombok @Value |
|---|---|---|
| Boilerplate | Minimal | Minimal |
| Private constructor | Not directly supported | ✓ |
| Static factory method | Helper method (constructor still public) | Primary construction method (constructor is private) |
| Validation integration | Compact constructor (exceptions) | Factory method (flexible return types) |
| Dependency | None (Java 16+) | Lombok |
Decision Guide
Choose record if:
- Your project targets Java 16 or later
- You want zero external dependencies
- Your validation is simple and exceptions are acceptable
- You're building a data transfer object or simple immutable holder
- You need to extend your type hierarchy (sealed records with Java 17+)
Choose Lombok @Value if:
- You need strict encapsulation with a private constructor
- You want to enforce construction through a factory method
- You need flexible return types for error handling (
Result,Optional, etc.) - Your codebase already uses Lombok
- You need more complex construction logic or multiple factory methods
Best Practices
Always unit test your Value Objects to ensure domain rules are enforced. Here's how to test both approaches:
// Testing the record version (exception-based)
@Test
void recordShouldThrowOnInvalidDateOrder() {
assertThrows(IllegalArgumentException.class, () ->
new DomainPeriod(LocalDate.of(2024, 12, 31), LocalDate.of(2024, 1, 1))
);
}
@Test
void recordShouldCreateValidPeriod() {
var period = DomainPeriod.of(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 15));
assertEquals(LocalDate.of(2024, 1, 1), period.start());
}
// Testing the Lombok version (Result-based)
@Test
void lombokShouldFailOnInvalidDateOrder() {
var result = DomainPeriod.of(LocalDate.of(2024, 12, 31), LocalDate.of(2024, 1, 1));
assertNotNull(result.error());
assertTrue(result.error().contains("Start must be before end"));
}
@Test
void lombokShouldCreateValidPeriod() {
var result = DomainPeriod.of(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 2, 15));
assertNull(result.error());
assertEquals(LocalDate.of(2024, 1, 1), result.value().start());
}
Neither approach is universally "better", the choice depends on your specific needs. A pragmatic team might even use both: record for simple, validation-light value objects, and Lombok for complex domain entities where strict encapsulation matters. The key is being intentional about your choice and consistent within your codebase.