Java records, introduced as a standard feature in Java 16 (previewed in Java 14), provide a concise way to declare
immutable data‑carrying classes. A single‑line record definition replaces the constructor, accessors,
equals(), hashCode(), and toString() that you would otherwise write by hand.
The Boilerplate Problem
Consider a traditional Java class for an Address – it requires a lot of repetitive code just to hold a few values:
// Traditional class – lots of boilerplate
public class Address {
private final String street;
private final String city;
private final int zip;
public Address(String street, String city, int zip) {
this.street = street;
this.city = city;
this.zip = zip;
}
public String getStreet() { return street; }
public String getCity() { return city; }
public int getZip() { return zip; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Address)) return false;
Address address = (Address) o;
return zip == address.zip &&
Objects.equals(street, address.street) &&
Objects.equals(city, address.city);
}
@Override
public int hashCode() {
return Objects.hash(street, city, zip);
}
@Override
public String toString() {
return "Address{" +
"street='" + street + '\'' +
", city='" + city + '\'' +
", zip=" + zip +
'}';
}
}
The record equivalent is a single line – the compiler does all the work:
public record Address(String street, String city, int zip) {}
What Records Auto‑Generate
From this concise declaration, the compiler automatically generates:
| Generated Member | Description |
|---|---|
| Canonical constructor | Address(String street, String city, int zip) – assigns all components. |
| Accessor methods | street(), city(), zip() – note the no get prefix. |
equals() |
Compares all components for structural equality. |
hashCode() |
Based on all components, consistent with equals(). |
toString() |
Returns a string like Address[street=123 Main St, city=Springfield, zip=12345]. |
Basic Usage Example
public record Address(String street, String city, int zip) {}
Address addr = new Address("1044 Main Street", "Springfield", 12345);
System.out.println(addr.street()); // 1044 Main Street
System.out.println(addr.city()); // Springfield
System.out.println(addr.zip()); // 12345
System.out.println(addr); // Address[street=1044 Main Street, city=Springfield, zip=12345]
Address addr2 = new Address("1044 Main Street", "Springfield", 12345);
System.out.println(addr.equals(addr2)); // true
Compact Constructor – Validation Made Easy
A compact constructor lets you add validation without repeating the parameter list. The compiler automatically inserts the field assignments after your validation code:
public record Address(String street, String city, int zip) {
public Address {
if (street == null || street.isBlank()) {
throw new IllegalArgumentException("street cannot be blank");
}
if (city == null || city.isBlank()) {
throw new IllegalArgumentException("city cannot be blank");
}
if (zip < 10000 || zip > 99999) {
throw new IllegalArgumentException("invalid zip code: " + zip);
}
// No need to write this.street = street; etc.
// The compiler adds those assignments after this block.
}
}
This is much cleaner than writing a full canonical constructor – you only focus on invariants.
Custom Constructors and Overriding Generated Methods
You can also add additional constructors (which must delegate to the canonical constructor) and override generated methods:
public record Person(String name, int age) {
// Additional constructor with default age
public Person(String name) {
this(name, 0); // delegates to canonical constructor
}
// Override toString for a custom format
@Override
public String toString() {
return name + " (" + age + " years)";
}
}
Records Can Have Instance Methods and Implement Interfaces
A record is still a class, so you can add custom instance methods and implement interfaces:
public interface Printable {
void print();
}
public record Point(int x, int y) implements Printable {
// Custom method
public double distanceFromOrigin() {
return Math.sqrt(x*x + y*y);
}
@Override
public void print() {
System.out.println("Point(" + x + ", " + y + ")");
}
}
Point p = new Point(3, 4);
p.print(); // Point(3, 4)
System.out.println(p.distanceFromOrigin()); // 5.0
Records – What You Can and Cannot Do
| Allowed | Not Allowed |
|---|---|
| Implement interfaces | Extend a class (records implicitly extend java.lang.Record) |
| Define static fields and methods | Define instance fields beyond the record components |
| Define instance methods | Be extended by another class (records are implicitly final) |
| Override generated methods | Use native methods |
| Add custom constructors | Have mutable components (components are effectively final) |
Records as DTOs and Value Objects
Records are ideal for Data Transfer Objects (DTOs) – objects whose only purpose is to carry data between layers. They work seamlessly with JSON libraries (Jackson, Gson, etc.) and are a perfect match for value objects:
// API response DTO
public record UserResponse(long id, String name, String email) {}
// Database query result
public record OrderSummary(long orderId, String status, double total) {}
// Event payload
public record UserCreatedEvent(long userId, String email, Instant createdAt) {}
// Value object in a domain model
public record Money(String currency, BigDecimal amount) {}
Most modern serialization frameworks support records natively (Jackson 2.12+, Gson 2.8.9+), making them a drop‑in replacement for traditional DTO classes.
Records vs Lombok @Data
Both reduce boilerplate, but they serve different purposes. Here’s a quick comparison:
| Java Record | Lombok @Data | |
|---|---|---|
| Requires external dependency | No – built into Java 16+ | Yes – Lombok must be installed |
| Immutable by default | Yes – all components are final | No – generates setters (mutable) |
| Supports inheritance | No (records are final) | Yes – works with normal class hierarchies |
| Custom field logic | Limited – only via compact constructor and methods | Full control – you write the class body |
| Performance overhead | Minimal – no reflection or annotation processing at runtime | No runtime overhead – annotations are processed at compile time |
Recommendation: Use records for simple, immutable data carriers on Java 16+. Use Lombok for mutable classes, inheritance, or when you need full control over the class structure.
Best Practices
- Prefer records for DTOs, value objects, and events – they are concise and naturally encourage immutability.
- Use compact constructors for validation – keep them short and focused on invariants.
- Override
toString()if the default format isn't suitable – but be careful to keep it informative. - Add static factory methods for common creation patterns (e.g.,
Person.of(name, age)). - Keep record components small – if a record has many fields (say >10), consider whether it’s a code smell and refactor.
- Combine with pattern matching (Java 21+) – records work beautifully with deconstruction patterns.
Example: Record with Static Factory and Validation
public record Person(String firstName, String lastName, int age) {
public Person {
if (firstName == null || firstName.isBlank()) {
throw new IllegalArgumentException("First name is required");
}
if (lastName == null || lastName.isBlank()) {
throw new IllegalArgumentException("Last name is required");
}
if (age < 0 || age > 150) {
throw new IllegalArgumentException("Invalid age: " + age);
}
}
// Static factory method
public static Person of(String firstName, String lastName, int age) {
return new Person(firstName, lastName, age);
}
// Custom method
public String fullName() {
return firstName + " " + lastName;
}
// Override toString to hide sensitive data if needed
@Override
public String toString() {
return "Person[" + firstName + " " + lastName + ", age=" + age + "]";
}
}
Summary
Java records revolutionise the way we write simple data classes. They eliminate the boilerplate of constructors, accessors,
equals(), hashCode(), and toString() – all with a single line of code.
Use records for DTOs, value objects, and event payloads where immutability is a feature.
The compact constructor gives you a clean place to add validation, and you can still add custom methods and implement interfaces.
Records are implicitly final – they cannot be extended, but that's intentional: they model data, not behaviour.
If you're on Java 16 or later, start using records today – they make your code clearer, safer, and more maintainable.
Happy coding!
No comments :
Post a Comment
Please leave your message queries or suggetions.
Note: Only a member of this blog may post a comment.