Introduction
Java 16 introduced records as a stable feature, revolutionizing how we create data carrier classes. If you’ve ever written a class just to hold data with getters, equals, hashCode, and toString methods, records are about to make your life much easier.
This guide explores everything you need to know about Java records, from basic usage to advanced patterns.
All code examples in this guide are available in the accompanying GitHub repository: https://github.com/theautonomy/java-record-example.git |
Basic Record Syntax
Your First Record
Here’s the simplest possible record:
public record Person(String name, int age) {
}
That’s it! This single line gives you a complete immutable class. Compare this to the traditional approach:
// Traditional class - verbose!
public final class Person {
private final String name;
private final int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String name() {
return name;
}
public int age() {
return age;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof Person other)) return false;
return Objects.equals(name, other.name) && age == other.age;
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
@Override
public String toString() {
return "Person[name=" + name + ", age=" + age + "]";
}
}
The record version gives you all of this automatically!
Using Records
Creating and using record instances is straightforward:
// Create a record instance
Person person = new Person("Alice", 30);
// Access fields using accessor methods
String name = person.name(); // Note: not getName()
int age = person.age();
// toString() is automatic
System.out.println(person);
// Output: Person[name=Alice, age=30]
// equals() works as expected
Person person2 = new Person("Alice", 30);
System.out.println(person.equals(person2)); // true
Record accessor methods don’t use the JavaBean naming convention. Use person.name() instead of person.getName() .
|
Record Features
Immutability by Default
Records are inherently immutable. Once created, you cannot change their values:
public record Point(int x, int y) {
}
Point point = new Point(10, 20);
// point.x = 30; // Compilation error - fields are final
To "modify" a record, create a new instance:
Point point1 = new Point(10, 20);
Point point2 = new Point(point1.x() + 5, point1.y()); // New instance
Custom Constructors
You can add validation or transformation logic using a compact constructor:
public record Person(String name, int age) {
// Compact constructor - no parameter list
public Person {
// Validate before assignment
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Name cannot be blank");
}
if (age < 0 || age > 150) {
throw new IllegalArgumentException("Invalid age: " + age);
}
// Normalize data
name = name.trim();
}
}
You can also define additional constructors:
public record Person(String name, int age) {
// Compact constructor for validation
public Person {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Name cannot be blank");
}
name = name.trim();
}
// Additional constructor with default age
public Person(String name) {
this(name, 0);
}
}
// Usage
Person adult = new Person("Bob", 25);
Person infant = new Person("Charlie"); // age defaults to 0
Custom Methods
Records can have instance and static methods:
public record Rectangle(double width, double height) {
// Instance method
public double area() {
return width * height;
}
public double perimeter() {
return 2 * (width + height);
}
public boolean isSquare() {
return width == height;
}
// Static factory method
public static Rectangle square(double side) {
return new Rectangle(side, side);
}
}
// Usage
Rectangle rect = new Rectangle(10, 5);
System.out.println("Area: " + rect.area());
Rectangle square = Rectangle.square(7);
System.out.println("Is square: " + square.isSquare()); // true
Overriding Generated Methods
You can override the default implementations:
public record Temperature(double celsius) {
@Override
public String toString() {
return String.format("%.1f°C (%.1f°F)", celsius, toFahrenheit());
}
public double toFahrenheit() {
return celsius * 9/5 + 32;
}
}
Temperature temp = new Temperature(25);
System.out.println(temp); // Output: 25.0°C (77.0°F)
Practical Use Cases
DTOs and Value Objects
Records are perfect for Data Transfer Objects:
public record UserDTO(
Long id,
String username,
String email,
LocalDateTime createdAt
) {}
public record LoginRequest(String username, String password) {
public LoginRequest {
if (username == null || password == null) {
throw new IllegalArgumentException("Credentials cannot be null");
}
}
}
public record ApiResponse<T>(
boolean success,
T data,
String message,
int statusCode
) {
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(true, data, "Success", 200);
}
public static <T> ApiResponse<T> error(String message, int statusCode) {
return new ApiResponse<>(false, null, message, statusCode);
}
}
Configuration Objects
Records work great for configuration:
public record DatabaseConfig(
String url,
String username,
String password,
int maxConnections,
Duration connectionTimeout
) {
public DatabaseConfig {
if (maxConnections <= 0) {
throw new IllegalArgumentException("Max connections must be positive");
}
if (connectionTimeout.isNegative()) {
throw new IllegalArgumentException("Timeout cannot be negative");
}
}
public static DatabaseConfig defaults(String url) {
return new DatabaseConfig(
url,
"root",
"",
10,
Duration.ofSeconds(30)
);
}
}
Query Results
Perfect for representing database query results:
public record CustomerSummary(
Long id,
String name,
int orderCount,
BigDecimal totalSpent,
LocalDate lastOrderDate
) {
public boolean isActiveCustomer() {
return lastOrderDate.isAfter(LocalDate.now().minusMonths(6));
}
}
public record SalesReport(
YearMonth period,
BigDecimal revenue,
int orderCount,
BigDecimal averageOrderValue
) {
public SalesReport {
if (orderCount > 0) {
BigDecimal calculated = revenue.divide(
BigDecimal.valueOf(orderCount),
2,
RoundingMode.HALF_UP
);
if (!averageOrderValue.equals(calculated)) {
throw new IllegalArgumentException("Inconsistent data");
}
}
}
}
Pattern Matching
Records work beautifully with pattern matching (Java 16+):
sealed interface Result permits Success, Failure {}
record Success(String data) implements Result {}
record Failure(String error, int code) implements Result {}
public class ResultProcessor {
public static String process(Result result) {
return switch (result) {
case Success(String data) -> "Success: " + data;
case Failure(String error, int code) ->
"Error " + code + ": " + error;
};
}
}
// Usage
Result success = new Success("Data loaded");
Result failure = new Failure("Not found", 404);
System.out.println(ResultProcessor.process(success));
System.out.println(ResultProcessor.process(failure));
Advanced Patterns
Records with Collections
Be careful with mutable collections in records:
// Problematic - collection is mutable
public record ShoppingCart(List<Item> items) {}
// Better - defensive copy
public record ShoppingCart(List<Item> items) {
public ShoppingCart {
items = List.copyOf(items); // Immutable copy
}
}
// Or use immutable collections from the start
public record ShoppingCart(List<Item> items) {
public ShoppingCart(Item... items) {
this(List.of(items));
}
}
Builder Pattern with Records
For records with many optional fields:
public record User(
String username,
String email,
String firstName,
String lastName,
String phone,
LocalDate birthDate
) {
public static Builder builder() {
return new Builder();
}
public static class Builder {
private String username;
private String email;
private String firstName;
private String lastName;
private String phone;
private LocalDate birthDate;
public Builder username(String username) {
this.username = username;
return this;
}
public Builder email(String email) {
this.email = email;
return this;
}
public Builder firstName(String firstName) {
this.firstName = firstName;
return this;
}
public Builder lastName(String lastName) {
this.lastName = lastName;
return this;
}
public Builder phone(String phone) {
this.phone = phone;
return this;
}
public Builder birthDate(LocalDate birthDate) {
this.birthDate = birthDate;
return this;
}
public User build() {
return new User(username, email, firstName, lastName, phone, birthDate);
}
}
}
// Usage
User user = User.builder()
.username("jdoe")
.email("jdoe@example.com")
.firstName("John")
.lastName("Doe")
.build();
Nested Records
Records can contain other records:
public record Address(String street, String city, String zipCode) {}
public record Contact(String phone, String email) {}
public record Employee(
Long id,
String name,
Address address,
Contact contact,
BigDecimal salary
) {
// Method to update just the address
public Employee withAddress(Address newAddress) {
return new Employee(id, name, newAddress, contact, salary);
}
// Method to update just the contact
public Employee withContact(Contact newContact) {
return new Employee(id, name, address, newContact, salary);
}
}
// Usage
Address address = new Address("123 Main St", "Springfield", "12345");
Contact contact = new Contact("555-1234", "john@example.com");
Employee emp = new Employee(1L, "John Doe", address, contact, new BigDecimal("75000"));
// Update address
Address newAddress = new Address("456 Oak Ave", "Springfield", "12345");
Employee updatedEmp = emp.withAddress(newAddress);
Records with Interfaces
Records can implement interfaces:
public interface Identifiable {
Long id();
}
public record Customer(Long id, String name, String email) implements Identifiable {}
public record Product(Long id, String name, BigDecimal price) implements Identifiable {}
// Generic method using the interface
public <T extends Identifiable> T findById(List<T> items, Long id) {
return items.stream()
.filter(item -> item.id().equals(id))
.findFirst()
.orElseThrow();
}
Common Pitfalls
Don’t Use Records for Entities
Records are immutable and not suitable for JPA entities:
// Bad - don't do this
@Entity
public record Customer(Long id, String name) {} // Won't work with JPA
// Good - use regular class for entities
@Entity
public class Customer {
@Id
private Long id;
private String name;
// getters, setters, etc.
}
Avoid Mutable Fields
Don’t include mutable objects without defensive copying:
// Bad - Date is mutable
public record Event(String name, Date timestamp) {}
// Good - use immutable types
public record Event(String name, Instant timestamp) {}
// Or make defensive copy
public record Event(String name, Date timestamp) {
public Event {
timestamp = new Date(timestamp.getTime());
}
}
Serialization Considerations
Records implement Serializable
differently:
public record Person(String name, int age) implements Serializable {
// No need for serialVersionUID
// Serialization uses the canonical constructor
}
When deserializing, validation in compact constructors will be executed, which may cause issues with legacy data. |
Testing with Records
Records make testing cleaner:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class PersonTest {
@Test
void testRecordEquality() {
Person p1 = new Person("Alice", 30);
Person p2 = new Person("Alice", 30);
assertEquals(p1, p2);
assertEquals(p1.hashCode(), p2.hashCode());
}
@Test
void testValidation() {
assertThrows(IllegalArgumentException.class,
() -> new Person("", 30));
assertThrows(IllegalArgumentException.class,
() -> new Person("Bob", -5));
}
@Test
void testToString() {
Person person = new Person("Charlie", 25);
String expected = "Person[name=Charlie, age=25]";
assertEquals(expected, person.toString());
}
}
Best Practices
Use Records for Value Objects
// Perfect use cases
public record Money(BigDecimal amount, Currency currency) {}
public record Coordinate(double latitude, double longitude) {}
public record Range(int start, int end) {}
Add Validation in Compact Constructor
public record Email(String address) {
public Email {
if (!address.matches("^[A-Za-z0-9+_.-]+@(.+)$")) {
throw new IllegalArgumentException("Invalid email format");
}
address = address.toLowerCase();
}
}
Provide Factory Methods
public record DateRange(LocalDate start, LocalDate end) {
public DateRange {
if (end.isBefore(start)) {
throw new IllegalArgumentException("End date must be after start date");
}
}
public static DateRange ofDays(LocalDate start, int days) {
return new DateRange(start, start.plusDays(days));
}
public static DateRange currentMonth() {
LocalDate now = LocalDate.now();
return new DateRange(
now.withDayOfMonth(1),
now.withDayOfMonth(now.lengthOfMonth())
);
}
}
Document Non-Obvious Behavior
/**
* Represents a normalized phone number.
*
* @param number The phone number, automatically normalized to digits only
*/
public record PhoneNumber(String number) {
public PhoneNumber {
// Remove all non-digit characters
number = number.replaceAll("[^0-9]", "");
if (number.length() < 10) {
throw new IllegalArgumentException("Phone number too short");
}
}
public String formatted() {
return String.format("(%s) %s-%s",
number.substring(0, 3),
number.substring(3, 6),
number.substring(6));
}
}
Conclusion
Java records provide a concise, immutable way to create data carrier classes. They reduce boilerplate while maintaining type safety and clarity. Key takeaways:
-
Use records for immutable data carriers
-
Add validation in compact constructors
-
Leverage automatic equals/hashCode/toString
-
Avoid using records for mutable entities
-
Combine with sealed classes and pattern matching for powerful abstractions
Records are not a silver bullet—use regular classes when you need mutability or inheritance. But for DTOs, value objects, and configuration, records are the modern Java way.
About the Author
Wei Li is a passionate software developer and technical writer specializing in Java and Spring Framework. With years of experience in building enterprise applications, Wei Li enjoys sharing knowledge through detailed tutorials and practical guides. For questions or feedback, feel free to reach out at weili.mail@gmail.com. |