Records, finalized in Java 16 and available in Java 17 through JEP 395, are a revolutionary feature for creating immutable data carrier classes. They eliminate the boilerplate code traditionally required for simple data classes, making your code cleaner, more maintainable, and less error-prone.
The Problem: Boilerplate Overload
Before records, creating a simple data class required extensive boilerplate:
public final class Person {
private final String name;
private final int age;
private final String email;
public Person(String name, int age, String email) {
this.name = name;
this.age = age;
this.email = email;
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
public String getEmail() {
return email;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof Person)) return false;
Person other = (Person) obj;
return age == other.age &&
Objects.equals(name, other.name) &&
Objects.equals(email, other.email);
}
@Override
public int hashCode() {
return Objects.hash(name, age, email);
}
@Override
public String toString() {
return "Person[name=" + name + ", age=" + age + ", email=" + email + "]";
}
}
That’s over 40 lines of code for a simple data class!
The Solution: Records
With records, the same class becomes:
public record Person(String name, int age, String email) {}
Just one line! The compiler automatically generates:
- A canonical constructor
- Accessor methods (
name(),age(),email()) equals()andhashCode()methods- A
toString()method
How Records Work
Basic Syntax
public record Point(int x, int y) {}
// Usage
Point p = new Point(10, 20);
System.out.println(p.x()); // 10
System.out.println(p.y()); // 20
System.out.println(p); // Point[x=10, y=20]
Key Characteristics
- Immutable: All fields are
finalby default - Implicitly Final: Records cannot be extended
- Accessor Methods: Named after the field (not
getX(), butx()) - Value-Based: Equality based on field values, not identity
Custom Constructors
Compact Constructor
Add validation or normalization without repeating parameters:
public record Email(String address) {
public Email {
if (address == null || !address.contains("@")) {
throw new IllegalArgumentException("Invalid email address");
}
// Normalize to lowercase
address = address.toLowerCase();
}
}
// Usage
Email email = new Email("USER@EXAMPLE.COM");
System.out.println(email.address()); // user@example.com
Canonical Constructor
Override the full constructor when needed:
public record Range(int start, int end) {
public Range(int start, int end) {
if (start > end) {
throw new IllegalArgumentException("Start must be <= end");
}
this.start = start;
this.end = end;
}
}
Alternative Constructors
Add convenience constructors:
public record Person(String name, int age, String email) {
// Compact constructor for validation
public Person {
if (age < 0) {
throw new IllegalArgumentException("Age cannot be negative");
}
}
// Alternative constructor with default email
public Person(String name, int age) {
this(name, age, "no-email@example.com");
}
}
Adding Custom Methods
Records can have instance and static methods:
public record Rectangle(double width, double height) {
public double area() {
return width * height;
}
public double perimeter() {
return 2 * (width + height);
}
public boolean isSquare() {
return width == height;
}
public static Rectangle square(double side) {
return new Rectangle(side, side);
}
}
// Usage
Rectangle rect = new Rectangle(10, 20);
System.out.println("Area: " + rect.area()); // 200.0
System.out.println("Perimeter: " + rect.perimeter()); // 60.0
Rectangle sq = Rectangle.square(15);
System.out.println("Is square: " + sq.isSquare()); // true
Real-World Examples
1. Data Transfer Objects (DTOs)
public record UserDTO(
Long id,
String username,
String email,
LocalDateTime createdAt,
boolean active
) {
public static UserDTO fromEntity(User user) {
return new UserDTO(
user.getId(),
user.getUsername(),
user.getEmail(),
user.getCreatedAt(),
user.isActive()
);
}
}
public record CreateUserRequest(String username, String email, String password) {
public CreateUserRequest {
if (username == null || username.isBlank()) {
throw new IllegalArgumentException("Username is required");
}
if (email == null || !email.matches("^[A-Za-z0-9+_.-]+@(.+)$")) {
throw new IllegalArgumentException("Valid email is required");
}
if (password == null || password.length() < 8) {
throw new IllegalArgumentException("Password must be at least 8 characters");
}
}
}
2. Configuration Classes
public record DatabaseConfig(
String host,
int port,
String database,
String username,
String password,
int maxConnections,
int connectionTimeout
) {
public DatabaseConfig {
if (port < 1 || port > 65535) {
throw new IllegalArgumentException("Invalid port number");
}
if (maxConnections < 1) {
throw new IllegalArgumentException("Max connections must be positive");
}
}
// Default configuration
public static DatabaseConfig defaults() {
return new DatabaseConfig(
"localhost",
5432,
"myapp",
"user",
"password",
10,
30000
);
}
public String jdbcUrl() {
return "jdbc:postgresql://%s:%d/%s".formatted(host, port, database);
}
}
3. API Responses
public record ApiResponse<T>(
boolean success,
T data,
String message,
LocalDateTime timestamp
) {
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(true, data, "Success", LocalDateTime.now());
}
public static <T> ApiResponse<T> error(String message) {
return new ApiResponse<>(false, null, message, LocalDateTime.now());
}
}
// Usage
ApiResponse<User> response = ApiResponse.success(user);
ApiResponse<Void> errorResponse = ApiResponse.error("User not found");
4. Value Objects
public record Money(BigDecimal amount, Currency currency) {
public Money {
if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("Amount must be non-negative");
}
if (currency == null) {
throw new IllegalArgumentException("Currency is required");
}
}
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("Cannot add different currencies");
}
return new Money(this.amount.add(other.amount), this.currency);
}
public Money multiply(BigDecimal factor) {
return new Money(this.amount.multiply(factor), this.currency);
}
public static Money usd(double amount) {
return new Money(BigDecimal.valueOf(amount), Currency.getInstance("USD"));
}
}
// Usage
Money price = Money.usd(99.99);
Money tax = price.multiply(BigDecimal.valueOf(0.08));
Money total = price.add(tax);
5. Coordinates and Geometric Types
public record Coordinate(double latitude, double longitude) {
public Coordinate {
if (latitude < -90 || latitude > 90) {
throw new IllegalArgumentException("Latitude must be between -90 and 90");
}
if (longitude < -180 || longitude > 180) {
throw new IllegalArgumentException("Longitude must be between -180 and 180");
}
}
public double distanceTo(Coordinate other) {
// Haversine formula
double R = 6371; // Earth's radius in km
double dLat = Math.toRadians(other.latitude - this.latitude);
double dLon = Math.toRadians(other.longitude - this.longitude);
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(Math.toRadians(this.latitude)) *
Math.cos(Math.toRadians(other.latitude)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
}
Records with Interfaces
Records can implement interfaces:
public interface Identifiable {
Long id();
}
public record Product(Long id, String name, BigDecimal price) implements Identifiable {
// id() method is automatically provided
}
public record Customer(Long id, String name, String email) implements Identifiable {
// id() method is automatically provided
}
Records in Collections
Records work perfectly with collections due to proper equals() and hashCode():
Set<Person> people = new HashSet<>();
people.add(new Person("Alice", 30, "alice@example.com"));
people.add(new Person("Bob", 25, "bob@example.com"));
people.add(new Person("Alice", 30, "alice@example.com")); // Duplicate, won't be added
Map<Person, String> roles = new HashMap<>();
roles.put(new Person("Alice", 30, "alice@example.com"), "Admin");
roles.put(new Person("Bob", 25, "bob@example.com"), "User");
Best Practices
1. Use for Data Carriers Only
Records are perfect for DTOs, value objects, and configuration classes, but not for entities with complex behavior.
2. Keep Them Simple
If you find yourself adding too many methods, consider whether a regular class would be better.
3. Validate in Compact Constructor
public record Age(int value) {
public Age {
if (value < 0 || value > 150) {
throw new IllegalArgumentException("Invalid age");
}
}
}
4. Use Static Factory Methods
public record User(String name, String email) {
public static User guest() {
return new User("Guest", "guest@example.com");
}
}
Conclusion
Records are a powerful addition to Java that dramatically reduce boilerplate for data classes. They promote immutability, provide sensible defaults for common methods, and make code more readable and maintainable. Whether you’re building DTOs, value objects, or configuration classes, records offer a clean, concise solution that feels natural in modern Java development.