DEV Community

Cover image for Java Records: How Immutability Transforms Modern Code Design
Aarav Joshi
Aarav Joshi

Posted on

Java Records: How Immutability Transforms Modern Code Design

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

Java Records have emerged as a powerful feature in modern Java development, bringing a concise syntax for creating immutable data structures. Since their introduction, records have transformed how developers handle data in Java applications. I've found that their utility extends far beyond their original design intent, offering elegant solutions to common programming challenges.

The Foundation of Records in Java

Records were introduced as a preview feature in JDK 14 and became officially standardized in JDK 16. At their core, records provide a compact declaration for classes that are primarily data carriers. A record declaration automatically provides:

  • Private, final fields for each component
  • A canonical constructor
  • Accessor methods for each component
  • Implementations of equals(), hashCode(), and toString()

Here's a simple example:

public record Person(String firstName, String lastName, int age) {}
Enter fullscreen mode Exit fullscreen mode

This concise declaration creates an immutable class with all the necessary methods that would otherwise require dozens of lines of boilerplate code.

Records as Domain Value Objects

One of the most practical applications for records is representing domain value objects. These are immutable objects that model concepts within your domain that are defined by their values rather than identity.

When I build financial applications, I frequently use records to represent monetary values:

public record Money(BigDecimal amount, Currency currency) {
    // Constructor validation
    public Money {
        Objects.requireNonNull(currency, "Currency cannot be null");
        amount = amount != null ? amount : BigDecimal.ZERO;
    }

    // Methods that maintain immutability
    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(double factor) {
        return new Money(this.amount.multiply(
            BigDecimal.valueOf(factor).setScale(amount.scale(), RoundingMode.HALF_UP)), 
            this.currency);
    }
}
Enter fullscreen mode Exit fullscreen mode

The record ensures the Money concept remains immutable, making it thread-safe and predictable. This approach eliminates entire classes of bugs related to unexpected state changes.

Streamlining Database Query Results

When working with databases, particularly for read operations, records offer a clean way to represent query results without the overhead of full ORM entities:

public record ProductSummary(Long id, String name, BigDecimal price, int inventory) {}

// In repository class
public List<ProductSummary> findProductsWithLowInventory(int threshold) {
    return jdbcTemplate.query(
        "SELECT id, name, price, inventory FROM products WHERE inventory < ?",
        (rs, rowNum) -> new ProductSummary(
            rs.getLong("id"),
            rs.getString("name"),
            rs.getBigDecimal("price"),
            rs.getInt("inventory")
        ),
        threshold
    );
}
Enter fullscreen mode Exit fullscreen mode

This pattern is especially useful for reports and read-only data where you don't need the full entity with all its relationships and behavior.

Creating Clean API Responses

For RESTful services, records make excellent response objects. They're serializable by default with frameworks like Spring Boot and Jackson:

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    // Constructor injection...

    @GetMapping("/{id}")
    public OrderResponse getOrder(@PathVariable Long id) {
        Order order = orderService.findById(id);
        return new OrderResponse(
            order.getId(),
            order.getCustomerName(),
            order.getItems().stream()
                .map(item -> new OrderItemResponse(
                    item.getProductId(),
                    item.getProductName(),
                    item.getQuantity(),
                    item.getUnitPrice()))
                .collect(Collectors.toList()),
            order.getTotalAmount()
        );
    }
}

public record OrderResponse(
    Long id,
    String customerName,
    List<OrderItemResponse> items,
    BigDecimal totalAmount
) {}

public record OrderItemResponse(
    Long productId,
    String productName,
    int quantity,
    BigDecimal unitPrice
) {}
Enter fullscreen mode Exit fullscreen mode

This approach creates a clear separation between internal domain models and API contracts, making your API more stable and easier to document.

Simplifying Multi-Return Values

Java has traditionally struggled with returning multiple values from methods. Records provide an elegant solution:

public record QueryResult<T>(List<T> data, long totalCount, int page, int pageSize) {}

// Usage
public QueryResult<Customer> findCustomers(String searchTerm, int page, int pageSize) {
    long total = customerRepository.countByNameContaining(searchTerm);
    List<Customer> customers = customerRepository.findByNameContaining(
        searchTerm, PageRequest.of(page, pageSize));
    return new QueryResult<>(customers, total, page, pageSize);
}
Enter fullscreen mode Exit fullscreen mode

This pattern is much cleaner than creating custom container classes for each return type or using generic tuples.

Efficient Cache Keys

Records make excellent cache keys due to their built-in equals() and hashCode() implementations:

public record CacheKey(String region, String id, String locale) {}

// In a caching service
private final Map<CacheKey, String> contentCache = new ConcurrentHashMap<>();

public String getContent(String region, String id, String locale) {
    CacheKey key = new CacheKey(region, id, locale);
    return contentCache.computeIfAbsent(key, k -> contentService.fetchContent(region, id, locale));
}
Enter fullscreen mode Exit fullscreen mode

The immutability of records ensures that cache keys won't change unexpectedly, avoiding difficult-to-detect bugs.

Advanced Record Techniques

Records can be extended with more sophisticated behaviors while maintaining their data-oriented nature:

public record Rectangle(double width, double height) {
    // Compact constructor with validation
    public Rectangle {
        if (width <= 0 || height <= 0) {
            throw new IllegalArgumentException("Dimensions must be positive");
        }
    }

    // Derived properties
    public double area() {
        return width * height;
    }

    public double perimeter() {
        return 2 * (width + height);
    }

    // Factory methods
    public static Rectangle square(double side) {
        return new Rectangle(side, side);
    }

    // Transformation methods
    public Rectangle scale(double factor) {
        return new Rectangle(width * factor, height * factor);
    }
}
Enter fullscreen mode Exit fullscreen mode

This example shows how records can include validation, derived properties, factory methods, and transformation operations while preserving immutability.

Records in Collections and Streams

Records integrate beautifully with Java's functional features and collections:

public record Employee(String id, String name, String department, double salary) {}

// Group employees by department
Map<String, List<Employee>> byDepartment = employees.stream()
    .collect(Collectors.groupingBy(Employee::department));

// Find average salary per department
Map<String, Double> avgSalaryByDept = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::department,
        Collectors.averagingDouble(Employee::salary)
    ));

// Deconstruct records in streams
double totalSalary = employees.stream()
    .filter(e -> e.department().equals("Engineering"))
    .mapToDouble(Employee::salary)
    .sum();
Enter fullscreen mode Exit fullscreen mode

The accessor methods generated by records work seamlessly with method references in streams, making for clean, readable code.

Records with Generics

Records support generic type parameters, which makes them versatile for creating type-safe data containers:

public record Pair<A, B>(A first, B second) {
    public <C> Pair<C, B> mapFirst(Function<A, C> mapper) {
        return new Pair<>(mapper.apply(first), second);
    }

    public <C> Pair<A, C> mapSecond(Function<B, C> mapper) {
        return new Pair<>(first, mapper.apply(second));
    }

    public static <A, B> Pair<A, B> of(A first, B second) {
        return new Pair<>(first, second);
    }
}
Enter fullscreen mode Exit fullscreen mode

This pattern is useful for type-safe data transformations and for representing relationships between different types.

Using Records with Frameworks

Many frameworks have added specific support for records:

// Spring Data JPA projection as a record
public record CustomerSummary(String name, String email, int orderCount) {
    // Spring will automatically map this from a query
}

// With Spring Data repository
public interface CustomerRepository extends JpaRepository<Customer, Long> {
    @Query("SELECT c.name as name, c.email as email, COUNT(o) as orderCount " +
           "FROM Customer c LEFT JOIN c.orders o GROUP BY c.id")
    List<CustomerSummary> findCustomerSummaries();
}
Enter fullscreen mode Exit fullscreen mode

Spring Boot, Hibernate, Jackson, and many other frameworks now recognize records and handle them appropriately.

Performance Considerations

Records are not just about convenience—they can also improve performance. Since records are immutable, they can be safely shared across threads without synchronization. Their compact representation can reduce memory overhead compared to traditional POJOs with getters and setters.

For memory-intensive applications, I've found records particularly valuable:

// Before: Traditional POJO approach
List<UserActivity> activities = new ArrayList<>();
for (RawLogEntry log : rawLogs) {
    UserActivity activity = new UserActivity();
    activity.setUserId(log.getUserId());
    activity.setTimestamp(log.getTimestamp());
    activity.setAction(log.getAction());
    activity.setResourceId(log.getResourceId());
    activities.add(activity);
}

// After: Record-based approach
List<UserActivity> activities = rawLogs.stream()
    .map(log -> new UserActivity(
        log.getUserId(),
        log.getTimestamp(),
        log.getAction(),
        log.getResourceId()))
    .collect(Collectors.toList());
Enter fullscreen mode Exit fullscreen mode

The record version is not only more concise but also creates less pressure on garbage collection due to reduced object mutations.

Record Limitations and Workarounds

Records do have limitations. They cannot extend other classes (though they can implement interfaces), and all fields are final. When you need more flexibility, consider these approaches:

// Using composition instead of inheritance
public record EnhancedProduct(Product base, Map<String, String> additionalAttributes) {
    // Delegate methods to base product
    public String name() {
        return base.name();
    }

    public BigDecimal price() {
        return base.price();
    }

    // Add new functionality
    public String getAttribute(String key) {
        return additionalAttributes.getOrDefault(key, null);
    }
}
Enter fullscreen mode Exit fullscreen mode

For situations where you need mutable state, consider using a record to represent immutable snapshots of the state:

public class MutableCounter {
    private int value;

    // Regular mutable methods
    public void increment() {
        value++;
    }

    // Create immutable snapshot
    public CounterSnapshot snapshot() {
        return new CounterSnapshot(value);
    }
}

public record CounterSnapshot(int value) {}
Enter fullscreen mode Exit fullscreen mode

This pattern preserves immutability for data exchange while allowing internal mutability where needed.

Conclusion

Java Records have significantly changed how I approach data handling in Java. They've reduced boilerplate, improved code readability, and helped create more maintainable applications. Their integration with modern Java features like pattern matching makes them even more powerful.

I've found that records are not just simplifications but encourage better design by promoting immutability and clear separation of data and behavior. As Java continues to evolve, records will likely become even more central to idiomatic Java programming.

For developers looking to modernize Java codebases, records offer an excellent opportunity to reduce code volume while increasing code quality. Their focused design on representing immutable data makes them the perfect tool for many common programming tasks.


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)