Java Records: Simplifying Data Classes in Modern Java

Java Records: Simplifying Data Classes in Modern Java

Java 14 introduced a powerful new feature called Records that has since become a staple in modern Java development. Records provide a concise way to declare classes that are primarily used to store and transport immutable data. In this article, we’ll explore what Java Records are, how they work, and when to use them in your projects.

What Are Java Records?

Java Records are a special kind of class declaration designed specifically for classes whose primary purpose is to store data. Records automatically provide:

  • Private, final fields for each component in the record declaration
  • A canonical constructor that initializes all fields
  • Public accessor methods for each field (but not traditional getters – more on that later)
  • Sensible implementations of equals(), hashCode(), and toString()

Before Records, creating simple data-carrying classes required a lot of boilerplate code. Let’s compare the traditional approach with the new Records syntax.

Traditional Data Class vs. Java Record

Traditional Data Class

public class Person {
    private final String name;
    private final int age;
    
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    public String getName() {
        return name;
    }
    
    public int getAge() {
        return age;
    }
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Person person = (Person) o;
        return age == person.age && Objects.equals(name, person.name);
    }
    
    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }
    
    @Override
    public String toString() {
        return "Person{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
}

Equivalent Java Record

public record Person(String name, int age) {}

Yes, that’s it! Just one line of code to achieve what previously required dozens of lines. The Java compiler automatically generates all the necessary methods based on the record’s components.

Key Features of Java Records

1. Immutability

Records are implicitly immutable. All fields (components) are automatically declared as private and final. This immutability makes records particularly useful for:

  • Data transfer objects (DTOs)
  • Value objects in Domain-Driven Design
  • Immutable data structures
  • API responses

2. Component Accessors

For each component in the record declaration, a public accessor method is automatically generated. However, unlike traditional JavaBean getters, these methods are named exactly the same as the component itself:

Person person = new Person("John", 30);

// Accessing components
String name = person.name();  // Not getName()
int age = person.age();       // Not getAge()

3. Canonical Constructor

Records automatically get a constructor that takes all components as parameters in the order they’re declared:

// Automatically generated constructor
Person person = new Person("Alice", 25);

4. Customizing Record Behavior

While records provide a lot of functionality automatically, you can still customize their behavior when needed:

public record Person(String name, int age) {
    // Custom constructor with validation
    public Person {
        if (age < 0) {
            throw new IllegalArgumentException("Age cannot be negative");
        }
        if (name == null || name.isEmpty()) {
            throw new IllegalArgumentException("Name cannot be empty");
        }
    }
    
    // Custom method
    public boolean isAdult() {
        return age >= 18;
    }
    
    // Override automatic method
    @Override
    public String toString() {
        return name + " (" + age + " years old)";
    }
}

Notice the compact constructor syntax public Person { ... } without parameters. This is a special syntax for records that allows you to validate or transform the components before they’re assigned to the fields.

Limitations of Records

While records are powerful, they do have some limitations you should be aware of:

  • Cannot extend other classes: Records implicitly extend java.lang.Record, and Java doesn’t support multiple inheritance
  • Cannot be extended: Records are implicitly final
  • Cannot declare instance fields: All state must be in the record components
  • Cannot be abstract: Records are concrete classes

However, records can implement interfaces, which provides a way to achieve polymorphism:

public interface Printable {
    void print();
}

public record Person(String name, int age) implements Printable {
    @Override
    public void print() {
        System.out.println(name + " is " + age + " years old");
    }
}

When to Use Records

Records are ideal for:

  • Data Transfer Objects (DTOs): Classes that carry data between different parts of your application
  • Value Objects: Objects that are defined by their values rather than identity
  • Immutable Data Models: When you need immutable representations of data
  • API Responses: Structuring responses from API calls
  • Multi-value Returns: When you need to return multiple values from a method

Multi-value Returns Example

Records provide an elegant solution for methods that need to return multiple values:

public record MinMax(int min, int max) {}

public MinMax findMinMax(List<Integer> numbers) {
    int min = Collections.min(numbers);
    int max = Collections.max(numbers);
    return new MinMax(min, max);
}

// Usage
MinMax result = findMinMax(List.of(3, 1, 5, 7, 2));
System.out.println("Min: " + result.min() + ", Max: " + result.max());

Patterns for Using Records Effectively

Nested Records

Records can be composed of other records, creating hierarchical data structures:

public record Address(String street, String city, String zipCode) {}

public record Employee(String name, int id, Address address) {}

// Usage
Address address = new Address("123 Main St", "Anytown", "12345");
Employee employee = new Employee("Jane Doe", 1001, address);

Records with Collections

When using collections in records, consider making them unmodifiable to maintain immutability:

public record Team(String name, List<String> members) {
    public Team {
        // Defensive copy to ensure immutability
        members = List.copyOf(members);
    }
}

Records with Optional Components

For optional fields, you can use Java’s Optional:

public record User(String username, Optional<String> bio) {
    // Convenient static factory methods
    public static User of(String username) {
        return new User(username, Optional.empty());
    }
    
    public static User of(String username, String bio) {
        return new User(username, Optional.of(bio));
    }
}

Records in Java Frameworks

Spring Boot and Records

Spring Boot works well with records. You can use them as:

// As request/response DTOs
public record UserRequest(String name, String email) {}

@RestController
@RequestMapping("/api/users")
public class UserController {
    @PostMapping
    public ResponseEntity<User> createUser(@RequestBody UserRequest request) {
        // Process the request
        return ResponseEntity.ok(new User(UUID.randomUUID().toString(), 
                                         request.name(), 
                                         request.email()));
    }
}

// As JPA entities (with limitations)
public record UserProjection(String name, String email) {}

Note: Records cannot be used directly as JPA entities because they’re final and don’t have no-arg constructors, but they can be used as projections or DTOs.

Jackson and Records

Jackson supports serializing and deserializing records out of the box since version 2.12:

public record Product(String name, double price) {}

// Serialization
ObjectMapper mapper = new ObjectMapper();
Product product = new Product("Laptop", 999.99);
String json = mapper.writeValueAsString(product);
// {"name":"Laptop","price":999.99}

// Deserialization
Product readProduct = mapper.readValue(json, Product.class);

Best Practices for Using Records

  • Keep records focused on data: Records are meant to be simple carriers of data. If you find yourself adding a lot of methods, consider if a traditional class would be more appropriate.
  • Use validation in compact constructors: Add validation in the compact constructor to ensure your record’s invariants are maintained.
  • Make collections unmodifiable: When a record includes collections, ensure they’re immutable by using methods like List.copyOf().
  • Consider companion classes: For complex operations related to your record, consider creating companion classes with static methods.
  • Use meaningful component names: Since component names become the accessor method names, choose them carefully.

Performance Considerations

Records are generally as efficient as regular classes. The Java compiler optimizes them in similar ways. However, there are a few things to consider:

  • The automatic generation of equals(), hashCode(), and toString() is comparable to what you would write manually.
  • Records don’t introduce any runtime overhead compared to equivalent manual implementations.
  • For very high-performance scenarios, you might want to customize the implementations of these methods.

Java Records vs. Lombok

Before records, many developers used Lombok’s @Data or @Value annotations to reduce boilerplate. Here’s how they compare:

Lombok

import lombok.Value;

@Value
public class Person {
    String name;
    int age;
}

Java Record

public record Person(String name, int age) {}

While they look similar, there are some key differences:

  • Standard vs. Library: Records are part of the Java language, while Lombok is a third-party library
  • Compilation: Records are processed directly by the compiler, Lombok uses annotation processing
  • Accessor naming: Records use component names (e.g., name()), Lombok generates JavaBean-style getters (e.g., getName())
  • Flexibility: Lombok offers more options for customization

Conclusion

Java Records represent a significant improvement in the language’s ability to express data aggregates concisely. They eliminate boilerplate code while providing strong guarantees about immutability and proper implementations of key methods.

Records are particularly valuable for:

  • Reducing code verbosity and maintenance overhead
  • Expressing data structures clearly and directly
  • Ensuring correct implementation of equals, hashCode, and toString
  • Creating immutable data carriers with minimal effort

By understanding when and how to use records effectively, you can make your Java code more concise, readable, and less error-prone.

Example: Complete Use Case

Let’s finish with a complete example of using records in a real-world scenario – a simple order processing system:

import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;

// Basic value records
public record Product(String id, String name, double price) {}
public record Customer(String id, String name, String email) {}
public record OrderItem(Product product, int quantity) {
    public OrderItem {
        if (quantity <= 0) {
            throw new IllegalArgumentException("Quantity must be positive");
        }
    }
    
    public double getSubtotal() {
        return product.price() * quantity;
    }
}

// Composite record
public record Order(String id, Customer customer, List<OrderItem> items, 
                    LocalDateTime createdAt, OrderStatus status) {
    
    public Order(Customer customer, List<OrderItem> items) {
        this(UUID.randomUUID().toString(), 
             customer, 
             List.copyOf(items), // Make defensive copy
             LocalDateTime.now(), 
             OrderStatus.CREATED);
    }
    
    public double getTotalPrice() {
        return items.stream()
                   .mapToDouble(OrderItem::getSubtotal)
                   .sum();
    }
    
    public Order markAsPaid() {
        return new Order(id, customer, items, createdAt, OrderStatus.PAID);
    }
    
    public Order markAsShipped() {
        if (status != OrderStatus.PAID) {
            throw new IllegalStateException("Order must be paid before shipping");
        }
        return new Order(id, customer, items, createdAt, OrderStatus.SHIPPED);
    }
}

enum OrderStatus {
    CREATED, PAID, SHIPPED, DELIVERED, CANCELLED
}

// Usage example
class OrderService {
    public static void main(String[] args) {
        Product laptop = new Product("P1", "MacBook Pro", 1999.99);
        Product phone = new Product("P2", "iPhone", 999.99);
        
        Customer customer = new Customer("C1", "John Doe", "john@example.com");
        
        OrderItem item1 = new OrderItem(laptop, 1);
        OrderItem item2 = new OrderItem(phone, 2);
        
        Order order = new Order(customer, List.of(item1, item2));
        
        System.out.println("Order created: " + order.id());
        System.out.println("Total price: $" + order.getTotalPrice());
        
        Order paidOrder = order.markAsPaid();
        Order shippedOrder = paidOrder.markAsShipped();
        
        System.out.println("Order status: " + shippedOrder.status());
    }
}

This example demonstrates how records can be used to build a clean, functional domain model with minimal boilerplate, while still providing validation, business logic, and immutability guarantees.

Records have quickly become an essential feature in modern Java development, simplifying code and helping developers focus on business logic rather than repetitive boilerplate. Whether you're building microservices, data processing pipelines, or traditional applications, Java Records deserve a place in your toolkit.

Leave a comment