Records were introduced as a preview feature in Java 14, refined in Java 15, and finalized as a permanent language feature in Java 16. By the time Java 17 LTS shipped, records had become a core part of the language that every Java developer should master. They are not experimental. They are production-ready and here to stay.
If you are new to records or need a refresher on the fundamentals — what the compiler generates, the canonical constructor, accessor methods, and basic syntax — read our comprehensive Java Record tutorial first. This post assumes you know the basics and focuses on practical patterns, real-world usage, and advanced techniques that make records shine in production Java 17 codebases.
We will cover records as POJO/DTO replacements, compact constructors for validation, custom methods, records with collections and streams, serialization with Jackson, local records, and a detailed comparison with classes and Lombok. By the end, you will know exactly when and how to use records in your day-to-day work.
The most common use case for records is replacing POJOs, DTOs, and value objects. In enterprise Java, these classes are everywhere — carrying data between layers, representing API responses, holding configuration values, and wrapping query results. Before records, every one of these classes required dozens of lines of boilerplate code.
Consider a typical POJO used to represent a user in a REST API. Here is the traditional approach:
// Traditional POJO -- 55+ lines of boilerplate
public class UserDTO {
private final String id;
private final String username;
private final String email;
private final LocalDate joinDate;
public UserDTO(String id, String username, String email, LocalDate joinDate) {
this.id = id;
this.username = username;
this.email = email;
this.joinDate = joinDate;
}
public String getId() { return id; }
public String getUsername() { return username; }
public String getEmail() { return email; }
public LocalDate getJoinDate() { return joinDate; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
UserDTO userDTO = (UserDTO) o;
return Objects.equals(id, userDTO.id) &&
Objects.equals(username, userDTO.username) &&
Objects.equals(email, userDTO.email) &&
Objects.equals(joinDate, userDTO.joinDate);
}
@Override
public int hashCode() {
return Objects.hash(id, username, email, joinDate);
}
@Override
public String toString() {
return "UserDTO{id='" + id + "', username='" + username +
"', email='" + email + "', joinDate=" + joinDate + "}";
}
}
With a record, the entire class becomes:
// Record -- 1 line, same behavior
public record UserDTO(String id, String username, String email, LocalDate joinDate) { }
That single line gives you the constructor, accessor methods (id(), username(), email(), joinDate()), properly implemented equals() and hashCode(), and a readable toString(). The fields are private and final. The class is implicitly final. You go from 55+ lines to 1 line with zero loss of functionality.
In Domain-Driven Design, value objects are defined by their attribute values rather than identity. Records are a natural fit because they implement value-based equality by default:
// Value objects using records
public record Money(BigDecimal amount, Currency currency) {
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(int quantity) {
return new Money(this.amount.multiply(BigDecimal.valueOf(quantity)), this.currency);
}
}
public record Address(String street, String city, String state, String zipCode, String country) { }
public record DateRange(LocalDate start, LocalDate end) {
public long days() {
return ChronoUnit.DAYS.between(start, end);
}
public boolean contains(LocalDate date) {
return !date.isBefore(start) && !date.isAfter(end);
}
public boolean overlaps(DateRange other) {
return !this.end.isBefore(other.start) && !other.end.isBefore(this.start);
}
}
Records work exceptionally well for REST API request and response objects. They make your API contracts explicit and concise:
// API DTOs
public record CreateOrderRequest(
String customerId,
List items,
String shippingAddress
) { }
public record OrderItemRequest(String productId, int quantity) { }
public record OrderResponse(
String orderId,
String status,
BigDecimal totalAmount,
LocalDateTime createdAt,
List items
) { }
public record OrderItemResponse(
String productName,
int quantity,
BigDecimal unitPrice,
BigDecimal subtotal
) { }
// Usage in a Spring controller
@PostMapping("/orders")
public ResponseEntity createOrder(@RequestBody CreateOrderRequest request) {
Order order = orderService.create(request);
return ResponseEntity.ok(toResponse(order));
}
Notice how the record declarations read like documentation. You can look at CreateOrderRequest and immediately understand what the API expects. No scrolling through getters and setters to figure out the fields.
Before records, Java developers often resorted to Map.Entry, arrays, or generic Pair classes to return multiple values. Records eliminate this anti-pattern:
// Before: returning multiple values was awkward public Map.Entry> loadUserWithPermissions(String userId) { // type-unsafe, hard to read return Map.entry(user, permissions); } // After: a record makes the return type explicit public record UserWithPermissions(User user, List permissions) { } public UserWithPermissions loadUserWithPermissions(String userId) { User user = userRepo.findById(userId); List permissions = permissionService.getFor(userId); return new UserWithPermissions(user, permissions); } // Even better -- the method signature documents itself // No guessing what the Map.Entry key or value represents
One of the most powerful features of records is the compact constructor. Unlike a regular constructor, a compact constructor does not declare parameters and does not assign fields — the compiler handles those automatically. You just write the validation and normalization logic. Think of it as a pre-assignment hook.
Production code must validate inputs. The compact constructor is the perfect place to enforce invariants:
public record EmailAddress(String value) {
// Compact constructor -- no parentheses, no parameter list
public EmailAddress {
Objects.requireNonNull(value, "Email cannot be null");
if (!value.matches("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")) {
throw new IllegalArgumentException("Invalid email: " + value);
}
}
}
public record Age(int value) {
public Age {
if (value < 0 || value > 150) {
throw new IllegalArgumentException("Age must be between 0 and 150, got: " + value);
}
}
}
public record Percentage(double value) {
public Percentage {
if (value < 0.0 || value > 100.0) {
throw new IllegalArgumentException("Percentage must be 0-100, got: " + value);
}
}
}
With a compact constructor, it is impossible to create an EmailAddress with an invalid format. The validation runs before the fields are assigned. This is a major advantage over traditional classes where you might forget to add validation, or worse, where someone creates the object through reflection bypassing the constructor.
Compact constructors can also normalize data. In a compact constructor, you can reassign the parameter variables and the compiler uses the normalized values for field assignment:
public record Username(String value) {
public Username {
Objects.requireNonNull(value, "Username cannot be null");
// Normalize: trim whitespace and convert to lowercase
value = value.trim().toLowerCase();
if (value.length() < 3 || value.length() > 30) {
throw new IllegalArgumentException(
"Username must be 3-30 characters, got: " + value.length());
}
}
}
public record PhoneNumber(String countryCode, String number) {
public PhoneNumber {
// Strip all non-digit characters
countryCode = countryCode.replaceAll("[^0-9]", "");
number = number.replaceAll("[^0-9]", "");
// Validate after normalization
if (countryCode.isEmpty()) {
throw new IllegalArgumentException("Country code is required");
}
if (number.length() < 7 || number.length() > 15) {
throw new IllegalArgumentException("Invalid phone number length");
}
}
public String formatted() {
return "+" + countryCode + " " + number;
}
}
// Usage
var username = new Username(" JohnDoe ");
System.out.println(username.value()); // "johndoe" -- trimmed and lowercased
var phone = new PhoneNumber("+1", "(555) 123-4567");
System.out.println(phone.formatted()); // "+1 5551234567"
Records are immutable, but if a component is a mutable collection, someone can modify the list after creating the record. Compact constructors should make defensive copies:
public record Team(String name, Listmembers) { public Team { Objects.requireNonNull(name, "Team name cannot be null"); Objects.requireNonNull(members, "Members list cannot be null"); // Defensive copy -- make the list unmodifiable members = List.copyOf(members); } } // Now the record is truly immutable List originalList = new ArrayList<>(List.of("Alice", "Bob")); Team team = new Team("Backend", originalList); originalList.add("Charlie"); // modifying the original list System.out.println(team.members()); // [Alice, Bob] -- record is unaffected team.members().add("Dave"); // throws UnsupportedOperationException
Rule of thumb: If any record component is a mutable type (List, Map, Set, Date, arrays), always make a defensive copy in the compact constructor using List.copyOf(), Map.copyOf(), or Set.copyOf(). This guarantees true immutability.
Records are not limited to accessor methods. You can add any instance method, static method, or static factory method to a record. The only restrictions are: you cannot add mutable instance fields (no non-final fields beyond the record components), and you cannot declare non-static fields at all.
Adding behavior to records keeps related logic close to the data it operates on:
public record Product(String id, String name, BigDecimal price, int stockQuantity) {
public boolean isInStock() {
return stockQuantity > 0;
}
public boolean isLowStock(int threshold) {
return stockQuantity > 0 && stockQuantity <= threshold;
}
public BigDecimal calculateTotal(int quantity) {
if (quantity > stockQuantity) {
throw new IllegalArgumentException(
"Requested " + quantity + " but only " + stockQuantity + " in stock");
}
return price.multiply(BigDecimal.valueOf(quantity));
}
// Records are immutable -- "modifying" returns a new record
public Product withPrice(BigDecimal newPrice) {
return new Product(id, name, newPrice, stockQuantity);
}
public Product withStockReduced(int sold) {
return new Product(id, name, price, stockQuantity - sold);
}
}
Notice the withPrice() and withStockReduced() methods. Since records are immutable, you cannot change a field — you create a new record with the modified value. This “wither” pattern is common in immutable designs and keeps the record’s data integrity intact.
Static factory methods provide named constructors that make object creation clearer and can encapsulate complex initialization:
public record Temperature(double value, String unit) {
public Temperature {
if (!unit.equals("C") && !unit.equals("F") && !unit.equals("K")) {
throw new IllegalArgumentException("Unit must be C, F, or K");
}
if (unit.equals("K") && value < 0) {
throw new IllegalArgumentException("Kelvin cannot be negative");
}
}
// Named factory methods -- much clearer than new Temperature(100, "C")
public static Temperature celsius(double value) {
return new Temperature(value, "C");
}
public static Temperature fahrenheit(double value) {
return new Temperature(value, "F");
}
public static Temperature kelvin(double value) {
return new Temperature(value, "K");
}
// Conversion methods
public Temperature toCelsius() {
return switch (unit) {
case "C" -> this;
case "F" -> celsius((value - 32) * 5.0 / 9.0);
case "K" -> celsius(value - 273.15);
default -> throw new IllegalStateException("Unknown unit: " + unit);
};
}
public Temperature toFahrenheit() {
double inCelsius = toCelsius().value();
return fahrenheit(inCelsius * 9.0 / 5.0 + 32);
}
}
// Usage
Temperature boiling = Temperature.celsius(100);
Temperature inFahrenheit = boiling.toFahrenheit();
System.out.println(inFahrenheit); // Temperature[value=212.0, unit=F]
Records can implement interfaces, which makes them work seamlessly with polymorphic designs:
public interface Printable {
String toPrintFormat();
}
public interface Discountable {
BigDecimal applyDiscount(BigDecimal discountPercent);
}
public record Invoice(
String invoiceNumber,
String customerName,
BigDecimal amount,
LocalDate dueDate
) implements Printable, Discountable, Comparable {
@Override
public String toPrintFormat() {
return String.format("Invoice #%s | %s | $%s | Due: %s",
invoiceNumber, customerName, amount, dueDate);
}
@Override
public BigDecimal applyDiscount(BigDecimal discountPercent) {
BigDecimal factor = BigDecimal.ONE.subtract(
discountPercent.divide(BigDecimal.valueOf(100)));
return amount.multiply(factor);
}
@Override
public int compareTo(Invoice other) {
return this.dueDate.compareTo(other.dueDate);
}
}
Records have properly implemented equals() and hashCode() by default, which makes them ideal for use as map keys, in sets, and for deduplication. This is a significant advantage over regular classes where forgetting to implement these methods leads to subtle bugs.
public record Coordinate(double latitude, double longitude) { }
public record CacheKey(String endpoint, Map params) {
public CacheKey {
params = Map.copyOf(params); // defensive copy for immutability
}
}
// Records work perfectly as map keys because equals/hashCode are correct
Map landmarks = new HashMap<>();
landmarks.put(new Coordinate(48.8566, 2.3522), "Eiffel Tower");
landmarks.put(new Coordinate(40.7484, -73.9857), "Empire State Building");
// This works because records compare by value
String name = landmarks.get(new Coordinate(48.8566, 2.3522));
System.out.println(name); // "Eiffel Tower"
// Cache keys with the same data are equal
Map cache = new HashMap<>();
var key1 = new CacheKey("/api/users", Map.of("page", "1"));
cache.put(key1, "{cached response}");
var key2 = new CacheKey("/api/users", Map.of("page", "1"));
System.out.println(cache.get(key2)); // "{cached response}" -- key2 equals key1
public record Tag(String name) {
public Tag {
name = name.trim().toLowerCase();
}
}
// Automatic deduplication
List allTags = List.of(
new Tag("Java"), new Tag("java"), new Tag(" JAVA "), new Tag("Python"), new Tag("java")
);
Set uniqueTags = new HashSet<>(allTags);
System.out.println(uniqueTags); // [Tag[name=python], Tag[name=java]] -- only 2 unique tags
public record Employee(String name, String department, double salary) { }
List employees = List.of(
new Employee("Alice", "Engineering", 120000),
new Employee("Bob", "Marketing", 90000),
new Employee("Charlie", "Engineering", 115000),
new Employee("Diana", "Marketing", 95000),
new Employee("Eve", "Engineering", 130000)
);
// Sort by salary descending
List bySalary = employees.stream()
.sorted(Comparator.comparingDouble(Employee::salary).reversed())
.toList();
// Sort by department, then by salary within department
List byDeptAndSalary = employees.stream()
.sorted(Comparator.comparing(Employee::department)
.thenComparing(Comparator.comparingDouble(Employee::salary).reversed()))
.toList();
// Accessor method references work naturally with Comparator
Records and streams are a powerful combination. Records provide clean data containers, and streams provide the processing pipeline. The accessor method references (Employee::salary, Employee::department) work seamlessly with stream operations.
public record Transaction(
String id,
String customerId,
BigDecimal amount,
String category,
LocalDateTime timestamp
) {
public boolean isLargeTransaction() {
return amount.compareTo(BigDecimal.valueOf(10000)) > 0;
}
public boolean isRecent(int daysBack) {
return timestamp.isAfter(LocalDateTime.now().minusDays(daysBack));
}
}
// Sample data
List transactions = loadTransactions();
// Find total spending by category
Map totalByCategory = transactions.stream()
.collect(Collectors.groupingBy(
Transaction::category,
Collectors.reducing(BigDecimal.ZERO, Transaction::amount, BigDecimal::add)
));
// Find the highest-spending customer
Optional> topSpender = transactions.stream()
.collect(Collectors.groupingBy(
Transaction::customerId,
Collectors.reducing(BigDecimal.ZERO, Transaction::amount, BigDecimal::add)
))
.entrySet().stream()
.max(Map.Entry.comparingByValue());
// Get large transactions from the last 30 days
List recentLarge = transactions.stream()
.filter(Transaction::isLargeTransaction)
.filter(t -> t.isRecent(30))
.sorted(Comparator.comparing(Transaction::amount).reversed())
.toList();
Streams are excellent for mapping between different record types — for example, converting domain entities to API responses:
// Domain entity
public record OrderEntity(
Long id, Long customerId, BigDecimal total,
String status, LocalDateTime createdAt
) { }
// API response
public record OrderSummary(
String orderId, BigDecimal total, String status, String createdDate
) {
public static OrderSummary fromEntity(OrderEntity entity) {
return new OrderSummary(
"ORD-" + entity.id(),
entity.total(),
entity.status(),
entity.createdAt().format(DateTimeFormatter.ISO_LOCAL_DATE)
);
}
}
// Convert a list of entities to API responses
List summaries = orderEntities.stream()
.map(OrderSummary::fromEntity)
.toList();
// Group orders by status and count them
record StatusCount(String status, long count) { }
List statusCounts = orderEntities.stream()
.collect(Collectors.groupingBy(OrderEntity::status, Collectors.counting()))
.entrySet().stream()
.map(e -> new StatusCount(e.getKey(), e.getValue()))
.sorted(Comparator.comparingLong(StatusCount::count).reversed())
.toList();
Records make grouped data structures much cleaner. Instead of dealing with Map<String, Map<String, List<...>>>, you can create a record to represent the grouping key:
public record SaleRecord(
String region, String productCategory, int quarter, BigDecimal revenue
) { }
// Multi-level grouping key as a record
public record RegionCategory(String region, String category) { }
List sales = loadSalesData();
// Group by region AND category using a record as the key
Map revenueByRegionCategory = sales.stream()
.collect(Collectors.groupingBy(
s -> new RegionCategory(s.region(), s.productCategory()),
Collectors.reducing(BigDecimal.ZERO, SaleRecord::revenue, BigDecimal::add)
));
// The record key makes the map easy to query
BigDecimal usElectronics = revenueByRegionCategory.get(
new RegionCategory("US", "Electronics")
);
// Statistics per group
record SalesStats(RegionCategory group, long count, BigDecimal totalRevenue) { }
List stats = sales.stream()
.collect(Collectors.groupingBy(
s -> new RegionCategory(s.region(), s.productCategory())))
.entrySet().stream()
.map(e -> new SalesStats(
e.getKey(),
e.getValue().size(),
e.getValue().stream()
.map(SaleRecord::revenue)
.reduce(BigDecimal.ZERO, BigDecimal::add)
))
.toList();
Serialization is where records meet the real world. Most Java applications need to convert records to JSON (for REST APIs) or serialize them for caching, messaging, and persistence. Both Jackson and Gson support records, but there are important details to know.
Jackson has supported records since version 2.12. Serialization works out of the box. Deserialization uses the canonical constructor automatically:
public record ApiResponse( boolean success, String message, T data, Instant timestamp ) { public static ApiResponse ok(T data) { return new ApiResponse<>(true, "OK", data, Instant.now()); } public static ApiResponse error(String message) { return new ApiResponse<>(false, message, null, Instant.now()); } } // Jackson serialization -- works out of the box ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); record UserProfile(String name, String email, int age) { } var profile = new UserProfile("John", "john@example.com", 30); String json = mapper.writeValueAsString(profile); // {"name":"John","email":"john@example.com","age":30} // Deserialization -- Jackson calls the canonical constructor UserProfile deserialized = mapper.readValue(json, UserProfile.class); System.out.println(deserialized.name()); // "John"
You can use Jackson annotations on record components:
public record GitHubRepo(
@JsonProperty("full_name") String fullName,
@JsonProperty("stargazers_count") int stars,
@JsonProperty("open_issues_count") int openIssues,
@JsonProperty("html_url") String url
) { }
// Deserialization maps snake_case JSON keys to camelCase record components
String githubJson = """
{
"full_name": "openjdk/jdk",
"stargazers_count": 17500,
"open_issues_count": 324,
"html_url": "https://github.com/openjdk/jdk"
}
""";
GitHubRepo repo = mapper.readValue(githubJson, GitHubRepo.class);
System.out.println(repo.fullName()); // "openjdk/jdk"
Gson support for records was added in version 2.10. Earlier versions require a custom TypeAdapter. With Gson 2.10+, records work similarly to regular classes:
// Requires Gson 2.10+
Gson gson = new GsonBuilder()
.setPrettyPrinting()
.create();
record Config(String host, int port, boolean ssl) { }
var config = new Config("api.example.com", 443, true);
String json = gson.toJson(config);
// {"host":"api.example.com","port":443,"ssl":true}
Config parsed = gson.fromJson(json, Config.class);
System.out.println(parsed.host()); // "api.example.com"
Key things to remember when serializing records:
| Topic | Details |
|---|---|
| Java Serialization | Records implement java.io.Serializable if declared. Deserialization always uses the canonical constructor, which is safer than regular class deserialization (no bypass of validation) |
| Jackson version | Use Jackson 2.12+ for record support. Older versions do not recognize records |
| Gson version | Use Gson 2.10+ for native record support |
| Compact constructor validation | Validation in compact constructors runs during deserialization, so invalid JSON will throw exceptions at parse time — this is a feature, not a bug |
| Nested records | Both Jackson and Gson handle nested records automatically |
| Generic records | Jackson handles TypeReference for generic records; Gson uses TypeToken |
Java allows you to define records inside a method. These are called local records. They are useful for intermediate data transformations where creating a top-level class would be overkill. Think of them as named tuples scoped to a single method.
public class ReportGenerator {
public String generateSalesReport(List transactions) {
// Local record -- only exists inside this method
record DailySummary(LocalDate date, long transactionCount, BigDecimal totalRevenue) { }
List dailySummaries = transactions.stream()
.collect(Collectors.groupingBy(
t -> t.timestamp().toLocalDate()))
.entrySet().stream()
.map(e -> new DailySummary(
e.getKey(),
e.getValue().size(),
e.getValue().stream()
.map(Transaction::amount)
.reduce(BigDecimal.ZERO, BigDecimal::add)
))
.sorted(Comparator.comparing(DailySummary::date))
.toList();
// Use the local record to build the report
StringBuilder report = new StringBuilder("Daily Sales Report\n");
for (var summary : dailySummaries) {
report.append(String.format("%s: %d transactions, $%s revenue%n",
summary.date(), summary.transactionCount(), summary.totalRevenue()));
}
return report.toString();
}
}
Local records are also excellent for multi-field sorting and grouping keys inside stream pipelines:
public Map> getTopProductsByCategory(List sales) { // Local record for the intermediate aggregation record ProductRevenue(String product, BigDecimal revenue) { } return sales.stream() .collect(Collectors.groupingBy(SaleRecord::productCategory)) .entrySet().stream() .collect(Collectors.toMap( Map.Entry::getKey, entry -> entry.getValue().stream() .collect(Collectors.groupingBy( SaleRecord::productName, Collectors.reducing(BigDecimal.ZERO, SaleRecord::revenue, BigDecimal::add))) .entrySet().stream() .map(e -> new ProductRevenue(e.getKey(), e.getValue())) .sorted(Comparator.comparing(ProductRevenue::revenue).reversed()) .limit(5) .map(ProductRevenue::product) .toList() )); }
Local records keep your code clean by scoping temporary data structures to where they are used. They do not pollute the class namespace and make refactoring easier — if the method goes away, so does the record.
One of the most common questions developers ask is: “Should I use a record, a regular class, or Lombok?” The answer depends on your use case. Here is a detailed comparison:
| Feature | Record | Regular Class | Lombok @Data | Lombok @Value |
|---|---|---|---|---|
| Boilerplate | None | Full manual | None (generated) | None (generated) |
| Immutability | Built-in (always) | Manual (final fields) | Not immutable | Immutable |
| Inheritance | Cannot extend classes | Full inheritance | Full inheritance | Cannot extend (final) |
| Interfaces | Can implement | Can implement | Can implement | Can implement |
| Mutable fields | Not allowed | Allowed | Allowed (setters) | Not allowed |
| Custom methods | Yes | Yes | Yes | Yes |
| equals/hashCode | All fields (auto) | Manual or IDE | All fields (auto) | All fields (auto) |
| toString | All fields (auto) | Manual or IDE | All fields (auto) | All fields (auto) |
| Compact constructor | Yes | No | No | No |
| Deconstruction | Yes (Java 21+) | No | No | No |
| External dependency | None (JDK built-in) | None | Lombok library | Lombok library |
| IDE support | Full (Java 17+) | Full | Requires plugin | Requires plugin |
| Serialization safety | Canonical constructor always used | Can bypass constructor | Can bypass constructor | Can bypass constructor |
Use records when:
equals()/hashCode() without thinking about itUse regular classes when:
equals()/hashCode() (e.g., only compare by ID)Use Lombok when:
@Builder)Java 21 introduced record patterns (finalized), which allow you to deconstruct records directly in instanceof checks and switch expressions. This is one of the most exciting features on the Java roadmap, and records were designed with this in mind.
Here is a preview of what record patterns look like:
// Java 21 Record Patterns (preview of what's coming if you're on Java 17)
// Traditional approach
public String describeShape(Object shape) {
if (shape instanceof Circle c) {
return "Circle with radius " + c.radius();
} else if (shape instanceof Rectangle r) {
return "Rectangle " + r.width() + "x" + r.height();
}
return "Unknown shape";
}
// With record patterns (Java 21) -- deconstruction in instanceof
public String describeShape(Object shape) {
if (shape instanceof Circle(double radius)) {
return "Circle with radius " + radius; // radius extracted directly
} else if (shape instanceof Rectangle(double width, double height)) {
return "Rectangle " + width + "x" + height; // both fields extracted
}
return "Unknown shape";
}
// Record patterns in switch expressions (Java 21)
sealed interface Shape permits Circle, Rectangle, Triangle { }
record Circle(double radius) implements Shape { }
record Rectangle(double width, double height) implements Shape { }
record Triangle(double a, double b, double c) implements Shape { }
public double area(Shape shape) {
return switch (shape) {
case Circle(var r) -> Math.PI * r * r;
case Rectangle(var w, var h) -> w * h;
case Triangle(var a, var b, var c) -> {
double s = (a + b + c) / 2;
yield Math.sqrt(s * (s - a) * (s - b) * (s - c));
}
};
}
// Nested record patterns -- deconstruct deeply
record Point(double x, double y) { }
record Line(Point start, Point end) { }
public double lineLength(Line line) {
// Deconstruct Line AND its nested Point records in one step
if (line instanceof Line(Point(var x1, var y1), Point(var x2, var y2))) {
return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
}
return 0;
}
If you are currently on Java 17, record patterns give you a compelling reason to plan your upgrade to Java 21. The combination of sealed interfaces + records + record patterns enables type-safe, exhaustive pattern matching that eliminates entire categories of bugs.
After working with records extensively in production Java 17 applications, here are the guidelines that matter most:
// Good -- validated at construction time
public record UserId(String value) {
public UserId {
Objects.requireNonNull(value, "UserId cannot be null");
if (value.isBlank()) {
throw new IllegalArgumentException("UserId cannot be blank");
}
}
}
// Bad -- no validation, allows invalid state
public record UserId(String value) { }
// Good -- truly immutable public record Config(String name, ListallowedOrigins) { public Config { allowedOrigins = List.copyOf(allowedOrigins); } } // Bad -- external code can modify the list public record Config(String name, List allowedOrigins) { }
public record Settings(int maxRetries, Duration timeout, boolean verbose) {
public Settings withMaxRetries(int maxRetries) {
return new Settings(maxRetries, this.timeout, this.verbose);
}
public Settings withTimeout(Duration timeout) {
return new Settings(this.maxRetries, timeout, this.verbose);
}
public Settings withVerbose(boolean verbose) {
return new Settings(this.maxRetries, this.timeout, verbose);
}
}
// Usage -- reads like a builder
Settings defaults = new Settings(3, Duration.ofSeconds(30), false);
Settings custom = defaults.withMaxRetries(5).withVerbose(true);
Records are powerful, but they are not the right tool for every situation:
java.lang.Record. If you need a class hierarchy, use regular classes or sealed classes.| Practice | Why |
|---|---|
| Validate in compact constructors | Prevents invalid objects from existing |
| Defensive copies for collections/arrays | Ensures true immutability |
| Use “with” methods for modifications | Maintains immutability while enabling fluent APIs |
| Prefer records for DTOs and value objects | Eliminates boilerplate, guarantees correct equality |
| Use local records for intermediate data | Keeps temporary types scoped and clean |
| Keep component count under 7 | Readability — too many fields need a different pattern |
| Use static factory methods | Named constructors improve API clarity |
| Implement interfaces for polymorphism | Records work well with sealed hierarchies |
| Do not use records for JPA entities | JPA requires no-arg constructors and mutability |
| Use Jackson 2.12+ or Gson 2.10+ | Older versions do not support records natively |