• Home
  • About
    • Thoughts To Pen photo

      Thoughts To Pen

      My thoughts on Computer Programming || Psychology || Personal Finances || & much more...

    • Learn More
    • Twitter
    • Instagram
    • Github
    • StackOverflow
  • Posts
    • All Posts
    • All Tags
  • Projects
  • Portfolio
  • Resources
  • About

Records (Java 17)

20 Nov 2025

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() and hashCode() 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

  1. Immutable: All fields are final by default
  2. Implicitly Final: Records cannot be extended
  3. Accessor Methods: Named after the field (not getX(), but x())
  4. 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.



programmingjavajava17 Share Tweet Msg