Java record in details with example

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 MemberDescription
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

AllowedNot 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 RecordLombok @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.