Introduction to Java Records
Records, introduced in Java 14 as a preview feature and made standard in Java 16, provide a compact syntax for declaring classes that are transparent holders for immutable data. They automatically generate boilerplate code like constructors, accessors, equals(), hashCode(), and toString().
1. Basic Record Syntax and Features
Simple Record Declaration
// Basic record - replaces 50+ lines of boilerplate
public record Person(String name, int age, String email) {
// All boilerplate is automatically generated!
}
// Equivalent traditional class would require:
// - Fields (private final)
// - Constructor
// - Getters (name(), age(), email())
// - equals(), hashCode(), toString()
Record Components and Automatic Generation
public record Point(int x, int y) {
// Automatically generated:
// 1. private final int x;
// 2. private final int y;
// 3. Canonical constructor: Point(int x, int y)
// 4. Accessor methods: x(), y() (NOT getX(), getY())
// 5. equals(), hashCode(), toString()
}
// Usage
public class RecordDemo {
public static void main(String[] args) {
// Creation
Point p1 = new Point(10, 20);
Point p2 = new Point(10, 20);
// Automatic accessors (no 'get' prefix)
System.out.println("X: " + p1.x()); // 10
System.out.println("Y: " + p1.y()); // 20
// Automatic equals and hashCode
System.out.println("Points equal: " + p1.equals(p2)); // true
System.out.println("Hash codes: " + p1.hashCode() + " = " + p2.hashCode());
// Automatic toString
System.out.println("Point: " + p1); // Point[x=10, y=20]
}
}
2. Record Validation and Customization
Custom Constructors
public record Student(String id, String name, double gpa) {
// Compact constructor - for validation only
public Student {
// Validation - runs before field assignment
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("ID cannot be null or blank");
}
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Name cannot be null or blank");
}
if (gpa < 0.0 || gpa > 4.0) {
throw new IllegalArgumentException("GPA must be between 0.0 and 4.0");
}
// Implicit field assignment happens automatically after this
}
// Custom canonical constructor
public Student(String id, String name, double gpa) {
// Explicit field assignment required when using canonical constructor
this.id = id != null ? id.trim() : null;
this.name = name != null ? name.trim() : null;
this.gpa = Math.round(gpa * 100.0) / 100.0; // Round to 2 decimal places
}
// Additional constructor (must delegate to canonical constructor)
public Student(String id, String name) {
this(id, name, 0.0); // Delegate to canonical constructor
}
}
// Usage with validation
public class StudentDemo {
public static void main(String[] args) {
try {
Student s1 = new Student("S001", "Alice", 3.8);
System.out.println(s1);
Student s2 = new Student("", "Bob", 2.5); // Throws exception
} catch (IllegalArgumentException e) {
System.out.println("Error: " + e.getMessage());
}
}
}
Custom Methods in Records
public record BankAccount(String accountNumber, String ownerName, double balance) {
// Custom instance method
public BankAccount deposit(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Deposit amount must be positive");
}
return new BankAccount(accountNumber, ownerName, balance + amount);
}
// Custom instance method
public BankAccount withdraw(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Withdrawal amount must be positive");
}
if (amount > balance) {
throw new IllegalArgumentException("Insufficient funds");
}
return new BankAccount(accountNumber, ownerName, balance - amount);
}
// Static method
public static BankAccount createNew(String ownerName) {
String accountNumber = "ACC" + System.currentTimeMillis();
return new BankAccount(accountNumber, ownerName, 0.0);
}
// Custom toString (override the auto-generated one)
@Override
public String toString() {
return String.format("Account[%s, Owner: %s, Balance: $%.2f]",
accountNumber, ownerName, balance);
}
// Custom accessor
@Override
public String ownerName() {
return ownerName != null ? ownerName.toUpperCase() : "UNKNOWN";
}
}
// Usage with custom methods
public class BankAccountDemo {
public static void main(String[] args) {
BankAccount account = BankAccount.createNew("John Doe");
System.out.println("Initial: " + account);
account = account.deposit(1000.0);
System.out.println("After deposit: " + account);
account = account.withdraw(250.0);
System.out.println("After withdrawal: " + account);
System.out.println("Owner: " + account.ownerName()); // JOHN DOE
}
}
3. Advanced Record Patterns
Records with Complex Data
import java.time.LocalDate;
import java.util.List;
import java.util.Arrays;
public record Order(
String orderId,
Customer customer,
List<OrderItem> items,
LocalDate orderDate,
OrderStatus status
) {
// Compact constructor for validation
public Order {
if (orderId == null || orderId.isBlank()) {
throw new IllegalArgumentException("Order ID cannot be null or blank");
}
if (customer == null) {
throw new IllegalArgumentException("Customer cannot be null");
}
if (items == null || items.isEmpty()) {
throw new IllegalArgumentException("Order must have at least one item");
}
// Defensive copy for mutable components
items = List.copyOf(items);
}
// Business logic methods
public double totalAmount() {
return items.stream()
.mapToDouble(OrderItem::totalPrice)
.sum();
}
public boolean isOverdue() {
return status == OrderStatus.PENDING &&
orderDate.isBefore(LocalDate.now().minusDays(7));
}
public Order withStatus(OrderStatus newStatus) {
return new Order(orderId, customer, items, orderDate, newStatus);
}
}
// Supporting records
public record Customer(String customerId, String name, String email) {}
public record OrderItem(String productId, String productName, int quantity, double unitPrice) {
public double totalPrice() {
return quantity * unitPrice;
}
}
public enum OrderStatus {
PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED
}
// Usage
public class OrderDemo {
public static void main(String[] args) {
Customer customer = new Customer("C001", "Alice Johnson", "[email protected]");
List<OrderItem> items = Arrays.asList(
new OrderItem("P001", "Laptop", 1, 999.99),
new OrderItem("P002", "Mouse", 2, 29.99)
);
Order order = new Order(
"O001",
customer,
items,
LocalDate.now().minusDays(10),
OrderStatus.PENDING
);
System.out.println("Order: " + order);
System.out.println("Total: $" + order.totalAmount());
System.out.println("Is overdue: " + order.isOverdue());
// Update status
Order updated = order.withStatus(OrderStatus.CONFIRMED);
System.out.println("Updated: " + updated);
}
}
Nested Records and Builder Pattern
// Complex nested record structure
public record Employee(
String employeeId,
PersonalInfo personalInfo,
EmploymentInfo employmentInfo,
List<Skill> skills
) {
// Builder for complex record creation
public static class Builder {
private String employeeId;
private PersonalInfo personalInfo;
private EmploymentInfo employmentInfo;
private List<Skill> skills = new ArrayList<>();
public Builder employeeId(String employeeId) {
this.employeeId = employeeId;
return this;
}
public Builder personalInfo(PersonalInfo personalInfo) {
this.personalInfo = personalInfo;
return this;
}
public Builder employmentInfo(EmploymentInfo employmentInfo) {
this.employmentInfo = employmentInfo;
return this;
}
public Builder skill(Skill skill) {
this.skills.add(skill);
return this;
}
public Employee build() {
return new Employee(employeeId, personalInfo, employmentInfo, skills);
}
}
// With-style methods for updates
public Employee withPersonalInfo(PersonalInfo newInfo) {
return new Employee(employeeId, newInfo, employmentInfo, skills);
}
public Employee withSkills(List<Skill> newSkills) {
return new Employee(employeeId, personalInfo, employmentInfo, newSkills);
}
}
// Supporting records
public record PersonalInfo(
String firstName,
String lastName,
LocalDate birthDate,
Address address
) {
public String fullName() {
return firstName + " " + lastName;
}
}
public record Address(
String street,
String city,
String state,
String zipCode
) {
@Override
public String toString() {
return String.format("%s, %s, %s %s", street, city, state, zipCode);
}
}
public record EmploymentInfo(
String department,
String position,
LocalDate hireDate,
double salary
) {}
public record Skill(String name, int proficiency, String category) {}
// Usage with builder
public class EmployeeDemo {
public static void main(String[] args) {
Address address = new Address("123 Main St", "New York", "NY", "10001");
PersonalInfo personalInfo = new PersonalInfo(
"John", "Doe", LocalDate.of(1990, 5, 15), address
);
EmploymentInfo employmentInfo = new EmploymentInfo(
"Engineering", "Senior Developer", LocalDate.now(), 85000.0
);
Employee employee = new Employee.Builder()
.employeeId("E001")
.personalInfo(personalInfo)
.employmentInfo(employmentInfo)
.skill(new Skill("Java", 5, "Programming"))
.skill(new Skill("Spring", 4, "Framework"))
.build();
System.out.println("Employee: " + employee.personalInfo().fullName());
System.out.println("Address: " + employee.personalInfo().address());
System.out.println("Skills: " + employee.skills().size());
}
}
4. Records with Collections and Defensive Copying
Handling Mutable Components
import java.util.*;
public record ShoppingCart(
String cartId,
String customerId,
List<CartItem> items,
Map<String, String> metadata
) {
// Compact constructor for defensive copying
public ShoppingCart {
// Validate inputs
Objects.requireNonNull(cartId, "Cart ID cannot be null");
Objects.requireNonNull(customerId, "Customer ID cannot be null");
// Defensive copies for mutable components
items = items != null ? List.copyOf(items) : List.of();
metadata = metadata != null ? Map.copyOf(metadata) : Map.of();
}
// Safe modification methods that return new instances
public ShoppingCart addItem(CartItem newItem) {
List<CartItem> newItems = new ArrayList<>(this.items);
newItems.add(newItem);
return new ShoppingCart(cartId, customerId, newItems, metadata);
}
public ShoppingCart removeItem(String productId) {
List<CartItem> newItems = this.items.stream()
.filter(item -> !item.productId().equals(productId))
.toList();
return new ShoppingCart(cartId, customerId, newItems, metadata);
}
public ShoppingCart updateQuantity(String productId, int newQuantity) {
List<CartItem> newItems = this.items.stream()
.map(item -> item.productId().equals(productId)
? new CartItem(productId, item.productName(), newQuantity, item.unitPrice())
: item)
.toList();
return new ShoppingCart(cartId, customerId, newItems, metadata);
}
public ShoppingCart withMetadata(String key, String value) {
Map<String, String> newMetadata = new HashMap<>(this.metadata);
newMetadata.put(key, value);
return new ShoppingCart(cartId, customerId, items, newMetadata);
}
// Business methods
public double calculateTotal() {
return items.stream()
.mapToDouble(CartItem::totalPrice)
.sum();
}
public int totalItems() {
return items.stream()
.mapToInt(CartItem::quantity)
.sum();
}
}
public record CartItem(
String productId,
String productName,
int quantity,
double unitPrice
) {
public double totalPrice() {
return quantity * unitPrice;
}
}
// Usage with safe modifications
public class ShoppingCartDemo {
public static void main(String[] args) {
ShoppingCart cart = new ShoppingCart("CART001", "CUST001", new ArrayList<>(), new HashMap<>());
// Add items immutably
cart = cart.addItem(new CartItem("P001", "Laptop", 1, 999.99));
cart = cart.addItem(new CartItem("P002", "Mouse", 2, 29.99));
cart = cart.withMetadata("discountCode", "SAVE10");
System.out.println("Cart: " + cart);
System.out.println("Total: $" + cart.calculateTotal());
System.out.println("Total items: " + cart.totalItems());
// Update quantity
cart = cart.updateQuantity("P002", 3);
System.out.println("After update - Total: $" + cart.calculateTotal());
// Demonstrate immutability
List<CartItem> originalItems = cart.items();
try {
originalItems.add(new CartItem("P003", "Keyboard", 1, 79.99)); // Will fail
} catch (UnsupportedOperationException e) {
System.out.println("Cannot modify immutable list: " + e.getMessage());
}
}
}
5. Records with Serialization and JSON
JSON Serialization with Jackson
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.LocalDateTime;
import java.util.List;
// Records work beautifully with JSON serialization
public record ApiResponse<T>(
boolean success,
String message,
T data,
@JsonProperty("timestamp") LocalDateTime responseTime,
List<String> errors
) {
public static <T> ApiResponse<T> success(T data, String message) {
return new ApiResponse<>(true, message, data, LocalDateTime.now(), List.of());
}
public static <T> ApiResponse<T> error(String message, List<String> errors) {
return new ApiResponse<>(false, message, null, LocalDateTime.now(), errors);
}
public static <T> ApiResponse<T> error(String message) {
return error(message, List.of(message));
}
}
public record User(
@JsonProperty("user_id") String userId,
@JsonProperty("username") String username,
@JsonProperty("email") String email,
@JsonProperty("created_at") LocalDateTime createdAt,
@JsonProperty("is_active") boolean isActive
) {
public static User create(String username, String email) {
return new User(
java.util.UUID.randomUUID().toString(),
username,
email,
LocalDateTime.now(),
true
);
}
}
// JSON Serialization Demo
public class JsonRecordDemo {
private static final ObjectMapper mapper = new ObjectMapper();
static {
// Configure Jackson for Java 8+ features
mapper.findAndRegisterModules();
}
public static void main(String[] args) throws Exception {
// Create records
User user = User.create("john_doe", "[email protected]");
ApiResponse<User> response = ApiResponse.success(user, "User created successfully");
// Serialize to JSON
String json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(response);
System.out.println("Serialized JSON:");
System.out.println(json);
// Deserialize from JSON
String jsonInput = """
{
"success": true,
"message": "User found",
"data": {
"user_id": "12345",
"username": "alice",
"email": "[email protected]",
"created_at": "2024-01-15T10:30:00",
"is_active": true
},
"timestamp": "2024-01-15T10:30:00",
"errors": []
}
""";
ApiResponse<User> deserialized = mapper.readValue(jsonInput,
mapper.getTypeFactory().constructParametricType(ApiResponse.class, User.class));
System.out.println("\nDeserialized record:");
System.out.println("Success: " + deserialized.success());
System.out.println("User: " + deserialized.data().username());
}
}
Database DTO Records
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDateTime;
// Records as Data Transfer Objects (DTOs)
public record Product(
long id,
String sku,
String name,
String description,
double price,
int stockQuantity,
String category,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
// Factory method from ResultSet
public static Product fromResultSet(ResultSet rs) throws SQLException {
return new Product(
rs.getLong("id"),
rs.getString("sku"),
rs.getString("name"),
rs.getString("description"),
rs.getDouble("price"),
rs.getInt("stock_quantity"),
rs.getString("category"),
rs.getTimestamp("created_at").toLocalDateTime(),
rs.getTimestamp("updated_at").toLocalDateTime()
);
}
// Validation method
public boolean isValid() {
return sku != null && !sku.isBlank() &&
name != null && !name.isBlank() &&
price >= 0 &&
stockQuantity >= 0;
}
// Business methods
public boolean isInStock() {
return stockQuantity > 0;
}
public boolean needsRestock() {
return stockQuantity < 10;
}
public Product withPrice(double newPrice) {
return new Product(id, sku, name, description, newPrice, stockQuantity,
category, createdAt, updatedAt);
}
public Product withStock(int newStock) {
return new Product(id, sku, name, description, price, newStock,
category, createdAt, updatedAt);
}
}
// Usage in data layer
public class ProductRepository {
public Product findById(long id) {
// Simulate database query
String sql = "SELECT * FROM products WHERE id = ?";
try (var connection = getConnection();
var stmt = connection.prepareStatement(sql)) {
stmt.setLong(1, id);
ResultSet rs = stmt.executeQuery();
if (rs.next()) {
return Product.fromResultSet(rs);
}
return null;
} catch (SQLException e) {
throw new RuntimeException("Database error", e);
}
}
public List<Product> findOutOfStock() {
// Implementation would query database
return List.of(); // Simplified
}
private Connection getConnection() throws SQLException {
// Return database connection
return null; // Simplified
}
}
6. Pattern Matching with Records
Instanceof Pattern Matching
// Records work great with pattern matching
public sealed interface Shape
permits Circle, Rectangle, Triangle {
double area();
}
public record Circle(double radius) implements Shape {
@Override
public double area() {
return Math.PI * radius * radius;
}
}
public record Rectangle(double width, double height) implements Shape {
@Override
public double area() {
return width * height;
}
}
public record Triangle(double base, double height) implements Shape {
@Override
public double area() {
return 0.5 * base * height;
}
}
// Pattern matching demo
public class PatternMatchingDemo {
public static String describeShape(Shape shape) {
// Traditional instanceof
if (shape instanceof Circle c) {
return String.format("Circle with radius %.2f (area: %.2f)",
c.radius(), c.area());
}
// Enhanced pattern matching (Java 16+)
if (shape instanceof Rectangle(double w, double h)) {
return String.format("Rectangle %.2f x %.2f (area: %.2f)",
w, h, shape.area());
}
if (shape instanceof Triangle t) {
return String.format("Triangle base %.2f, height %.2f (area: %.2f)",
t.base(), t.height(), t.area());
}
return "Unknown shape";
}
public static double processShape(Shape shape) {
return switch (shape) {
case Circle c -> c.area() * 1.1; // 10% larger
case Rectangle r -> r.width() * r.height();
case Triangle t -> t.area() * 0.9; // 10% smaller
};
}
public static void main(String[] args) {
List<Shape> shapes = List.of(
new Circle(5.0),
new Rectangle(4.0, 6.0),
new Triangle(3.0, 4.0)
);
for (Shape shape : shapes) {
System.out.println(describeShape(shape));
System.out.println("Processed value: " + processShape(shape));
System.out.println();
}
}
}
Record Patterns (Java 19+ Preview)
// Advanced record patterns (Java 19+)
public record Name(String first, String last) {}
public record Address(String street, String city, String zip) {}
public record Person(Name name, Address address, int age) {}
public class AdvancedPatternMatching {
public static String oldWay(Person person) {
if (person != null &&
person.name() != null &&
person.address() != null) {
return person.name().first() + " from " + person.address().city();
}
return "Unknown";
}
// Record patterns (Java 19+)
public static String newWay(Person person) {
if (person instanceof Person(Name(String first, String last),
Address(String street, String city, String zip),
int age)) {
return first + " " + last + " from " + city + " (age " + age + ")";
}
return "Unknown";
}
// Nested record patterns
public static void processPerson(Object obj) {
switch (obj) {
case Person(Name(String first, _), Address(_, String city, _), int age)
when age >= 18 ->
System.out.println(first + " is an adult from " + city);
case Person(Name(String first, _), _, int age) when age < 18 ->
System.out.println(first + " is a minor");
default ->
System.out.println("Unknown object");
}
}
public static void main(String[] args) {
Person person = new Person(
new Name("John", "Doe"),
new Address("123 Main St", "New York", "10001"),
25
);
System.out.println("Old way: " + oldWay(person));
System.out.println("New way: " + newWay(person));
processPerson(person);
}
}
7. Best Practices and Common Patterns
Validation Patterns
public record ValidatedRecord<T>(
T value,
List<String> errors
) {
public ValidatedRecord {
Objects.requireNonNull(value, "Value cannot be null");
errors = errors != null ? List.copyOf(errors) : List.of();
}
public boolean isValid() {
return errors.isEmpty();
}
public static <T> ValidatedRecord<T> valid(T value) {
return new ValidatedRecord<>(value, List.of());
}
public static <T> ValidatedRecord<T> invalid(T value, String... errorMessages) {
return new ValidatedRecord<>(value, Arrays.asList(errorMessages));
}
public T orElseThrow() {
if (!isValid()) {
throw new ValidationException("Record is invalid: " + errors);
}
return value;
}
}
class ValidationException extends RuntimeException {
public ValidationException(String message) {
super(message);
}
}
Factory Methods and Utilities
public record Range(int start, int end) {
// Validation in compact constructor
public Range {
if (start > end) {
throw new IllegalArgumentException("Start cannot be greater than end");
}
}
// Factory methods
public static Range of(int start, int end) {
return new Range(start, end);
}
public static Range ofLength(int start, int length) {
return new Range(start, start + length - 1);
}
public static Range parse(String rangeStr) {
String[] parts = rangeStr.split("\\.\\.");
if (parts.length != 2) {
throw new IllegalArgumentException("Invalid range format: " + rangeStr);
}
return new Range(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]));
}
// Utility methods
public boolean contains(int value) {
return value >= start && value <= end;
}
public int length() {
return end - start + 1;
}
public List<Integer> asList() {
return IntStream.rangeClosed(start, end)
.boxed()
.toList();
}
public Range intersection(Range other) {
int newStart = Math.max(this.start, other.start);
int newEnd = Math.min(this.end, other.end);
return newStart <= newEnd ? new Range(newStart, newEnd) : null;
}
}
Records in Collections and Streams
public record StudentGrade(String studentId, String course, int grade)
implements Comparable<StudentGrade> {
@Override
public int compareTo(StudentGrade other) {
return Integer.compare(this.grade, other.grade);
}
public boolean isPassing() {
return grade >= 60;
}
public String gradeLetter() {
if (grade >= 90) return "A";
if (grade >= 80) return "B";
if (grade >= 70) return "C";
if (grade >= 60) return "D";
return "F";
}
}
// Usage in streams and collections
public class RecordCollectionsDemo {
public static void main(String[] args) {
List<StudentGrade> grades = List.of(
new StudentGrade("S001", "Math", 85),
new StudentGrade("S002", "Math", 92),
new StudentGrade("S003", "Math", 78),
new StudentGrade("S001", "Science", 88),
new StudentGrade("S002", "Science", 95),
new StudentGrade("S003", "Science", 72)
);
// Stream operations on records
double average = grades.stream()
.mapToInt(StudentGrade::grade)
.average()
.orElse(0.0);
System.out.println("Average grade: " + average);
// Grouping by student
Map<String, List<StudentGrade>> byStudent = grades.stream()
.collect(Collectors.groupingBy(StudentGrade::studentId));
// Finding top performers
List<StudentGrade> topPerformers = grades.stream()
.filter(StudentGrade::isPassing)
.sorted(Comparator.reverseOrder())
.limit(3)
.toList();
// Statistical analysis
IntSummaryStatistics stats = grades.stream()
.mapToInt(StudentGrade::grade)
.summaryStatistics();
System.out.println("Stats: " + stats);
System.out.println("Top performers: " + topPerformers);
}
}
8. Limitations and When Not to Use Records
Records vs Traditional Classes
// Use records for:
// - Data carriers
// - DTOs
// - Value objects
// - Simple immutable data
// Use traditional classes for:
// - Mutable objects
// - Objects with complex lifecycle
// - Objects requiring inheritance beyond interfaces
// - Objects with significant behavior beyond data access
// Example where records are NOT appropriate:
public class TraditionalClass {
private String name;
private int counter;
public TraditionalClass(String name) {
this.name = name;
this.counter = 0;
}
// Mutable state
public void increment() {
counter++;
}
// Complex lifecycle
public void initialize() {
// Complex initialization logic
}
// Inheritance hierarchy
// ... other complex behaviors
}
// Record limitations:
// - Cannot extend other classes
// - All fields are final
// - Limited customization of generated methods
// - Cannot add instance fields
Migration Strategy
// Before: Traditional JavaBean
public 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;
}
// Getters, equals, hashCode, toString...
// ~50 lines of boilerplate
}
// After: Record (90% reduction in code)
public record Person(String name, int age, String email) {
// Add validation if needed
public Person {
if (age < 0) throw new IllegalArgumentException("Age cannot be negative");
}
// Add business methods if needed
public boolean isAdult() {
return age >= 18;
}
}
Summary
Key Benefits of Records:
- Concise Syntax: Drastically reduces boilerplate code
- Immutability: All components are automatically
final - Thread Safety: Natural fit for concurrent programming
- Value-based Semantics: Proper
equals(),hashCode(),toString() - Pattern Matching: Excellent fit with modern Java features
Best Practices:
- Use records for data carriers and value objects
- Add validation in compact constructors
- Use defensive copying for mutable components
- Implement business logic as methods within records
- Use static factory methods for complex creation logic
- Prefer records over traditional classes for immutable data
Common Use Cases:
- DTOs: Data transfer between layers
- Configuration: Immutable configuration objects
- API Responses: Structured API return types
- Database Entities: Read-only view of database rows
- Event Objects: Immutable event representations
- Value Objects: Domain-driven design value objects
Records represent a significant step forward in making Java more concise and expressive while maintaining type safety and performance characteristics.