java

8 Powerful Java Records Patterns for Cleaner Domain Models

Discover 8 powerful Java Records patterns to eliminate boilerplate code and build cleaner, more maintainable domain models. Learn practical techniques for DTOs, value objects, and APIs. #JavaDevelopment

8 Powerful Java Records Patterns for Cleaner Domain Models

Java Records have transformed the way I model domains in my applications since their introduction in Java 16. They provide a crisp, concise way to represent data without the traditional boilerplate associated with Java classes. In this article, I’ll share eight powerful patterns I’ve used with records that have dramatically improved my code quality.

Value Objects Pattern

Value objects represent concepts that are defined by their attributes rather than identity. Money is a perfect example - 10 USD is equal to any other 10 USD regardless of where it’s used.

public record Money(BigDecimal amount, Currency currency) {
    public Money {
        Objects.requireNonNull(amount, "Amount cannot be null");
        Objects.requireNonNull(currency, "Currency cannot be null");
        if (amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Amount cannot be negative");
        }
    }
    
    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 subtract(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("Cannot subtract different currencies");
        }
        return new Money(this.amount.subtract(other.amount), this.currency);
    }
    
    public Money multiply(int multiplier) {
        return new Money(this.amount.multiply(BigDecimal.valueOf(multiplier)), this.currency);
    }
}

The record provides automatic equals(), hashCode(), and toString() implementations that align perfectly with value object semantics. Adding validation in the canonical constructor ensures our money objects always maintain integrity.

DTOs with Static Factories

Data transfer objects can be elegantly modeled with records, which serve as a clean contract between different layers of your application.

public record UserDto(String id, String name, String email) {
    public static UserDto fromEntity(User user) {
        return new UserDto(user.getId(), user.getName(), user.getEmail());
    }
    
    public User toEntity() {
        User user = new User();
        user.setId(this.id);
        user.setName(this.name);
        user.setEmail(this.email);
        return user;
    }
    
    public static List<UserDto> fromEntities(List<User> users) {
        return users.stream()
                .map(UserDto::fromEntity)
                .collect(Collectors.toList());
    }
}

Static factory methods make conversion between domain entities and DTOs explicit and centralized. This pattern has helped me maintain a clean separation between my domain model and external interfaces.

Composite Records

One of my favorite patterns is composing records to build more complex structures. This composition mirrors how we conceptualize domain relationships.

public record Address(String street, String city, String zipCode, String country) {
    public String formatted() {
        return String.format("%s, %s %s, %s", street, city, zipCode, country);
    }
}

public record Customer(String id, String name, Address address, EmailAddress email) {
    public Customer withAddress(Address newAddress) {
        return new Customer(this.id, this.name, newAddress, this.email);
    }
    
    public Customer withEmail(EmailAddress newEmail) {
        return new Customer(this.id, this.name, this.address, newEmail);
    }
    
    public boolean isInternational() {
        return !"USA".equalsIgnoreCase(address.country());
    }
}

The immutable nature of records is a perfect fit for this pattern. When I need to modify a property, I create a new instance with the “with” methods, preserving immutability while providing convenient modification paths.

Pattern Matching Integration

Java’s pattern matching features work beautifully with records, enabling expressive and type-safe code.

public String processRecord(Object obj) {
    return switch (obj) {
        case Customer(String id, String name, Address address, var email) -> 
            "Customer: " + name + " from " + address.city();
        case Order(String id, var items, Customer customer, var status) when status == OrderStatus.PAID -> 
            "Paid Order: " + id + " with " + items.size() + " items";
        case Order(String id, var items, Customer customer, var status) -> 
            "Order: " + id + " (" + status + ")";
        case Payment(Money amount, String reference, var date) -> 
            "Payment: " + amount.amount() + " " + amount.currency() + " ref: " + reference;
        default -> "Unknown record type: " + obj.getClass().getSimpleName();
    };
}

This pattern has transformed how I handle different types of data in my applications. The code becomes more readable and less prone to casting errors.

Validated Records

Validating data at construction time ensures your objects are always in a valid state. Records provide a compact way to implement this pattern.

public record EmailAddress(String value) {
    private static final Pattern EMAIL_PATTERN = 
        Pattern.compile("^[A-Za-z0-9+_.-]+@(.+)$");
    
    public EmailAddress {
        if (value == null || !EMAIL_PATTERN.matcher(value).matches()) {
            throw new IllegalArgumentException("Invalid email address: " + value);
        }
    }
    
    public String domain() {
        return value.substring(value.indexOf('@') + 1);
    }
    
    public String localPart() {
        return value.substring(0, value.indexOf('@'));
    }
    
    public boolean isBusinessEmail() {
        String domain = domain();
        return !domain.endsWith(".com") || domain.contains("business");
    }
}

I’ve found this pattern especially useful for email addresses, phone numbers, postal codes, and other types that need validation. The compact canonical constructor is the perfect place to enforce these rules.

API Response Wrappers

When working with APIs, I often need consistent response formats. Records provide a clean way to standardize responses:

public record ApiResponse<T>(T data, String message, int status, Instant timestamp) {
    public ApiResponse(T data, String message, int status) {
        this(data, message, status, Instant.now());
    }
    
    public static <T> ApiResponse<T> success(T data) {
        return new ApiResponse<>(data, "Success", 200);
    }
    
    public static <T> ApiResponse<T> created(T data) {
        return new ApiResponse<>(data, "Resource created", 201);
    }
    
    public static <T> ApiResponse<T> error(String message, int status) {
        return new ApiResponse<>(null, message, status);
    }
    
    public static <T> ApiResponse<T> notFound(String message) {
        return error(message, 404);
    }
    
    public boolean isSuccess() {
        return status >= 200 && status < 300;
    }
}

This pattern creates a consistent convention for API responses throughout your application while eliminating repetitive code.

Immutable Collection Holders

Working with collections in domain models can be tricky. Records help create immutable wrappers around collections:

public record OrderItems(List<OrderItem> items) {
    public OrderItems {
        items = List.copyOf(items); // Create defensive immutable copy
    }
    
    public OrderItems add(OrderItem item) {
        List<OrderItem> newItems = new ArrayList<>(items);
        newItems.add(item);
        return new OrderItems(newItems);
    }
    
    public OrderItems remove(OrderItem item) {
        List<OrderItem> newItems = new ArrayList<>(items);
        newItems.remove(item);
        return new OrderItems(newItems);
    }
    
    public Money totalPrice() {
        return items.stream()
            .map(OrderItem::price)
            .reduce(new Money(BigDecimal.ZERO, Currency.getInstance("USD")), Money::add);
    }
    
    public int count() {
        return items.size();
    }
    
    public boolean isEmpty() {
        return items.isEmpty();
    }
}

This pattern protects collections from external modification while providing methods for creating new instances with added or removed items. The immutable nature of records ensures consistent state.

Event Sourcing

Event sourcing is a perfect use case for records, which can represent immutable events in your system:

public sealed interface DomainEvent 
    permits UserCreated, UserUpdated, UserDeleted {
    String aggregateId();
    Instant timestamp();
    int version();
}

public record UserCreated(String aggregateId, String name, 
                          EmailAddress email, Instant timestamp,
                          int version) 
    implements DomainEvent {}

public record UserUpdated(String aggregateId, Map<String, Object> changes, 
                          Instant timestamp, int version) 
    implements DomainEvent {
    
    public UserUpdated {
        changes = Map.copyOf(changes); // Ensure immutability
    }
    
    public boolean hasFieldChanged(String fieldName) {
        return changes.containsKey(fieldName);
    }
    
    @SuppressWarnings("unchecked")
    public <T> T getOldValue(String fieldName) {
        return (T) changes.get(fieldName);
    }
}

public record UserDeleted(String aggregateId, String reason,
                         Instant timestamp, int version)
    implements DomainEvent {}

Combined with sealed interfaces, this pattern creates a type-safe event hierarchy. The immutability of records ensures that events, once created, remain unchanged - a critical property for event sourcing systems.

In my projects, I’ve used this pattern to build robust audit logs and event-driven architectures. The clear definition of each event type makes the system more maintainable and easier to reason about.

Additional Domain Modeling Techniques with Records

Beyond these eight patterns, I’ve discovered some additional techniques that work well with records:

Parameter Objects

When methods require many parameters, I group them into a record:

public record SearchCriteria(String query, int page, int pageSize, 
                           List<String> sortFields, boolean ascending) {
    public static SearchCriteria defaultCriteria() {
        return new SearchCriteria("", 0, 20, List.of("createdAt"), false);
    }
}

// Usage
public List<Product> findProducts(SearchCriteria criteria) {
    // Implementation
}

Command Objects

For operations that modify state, command objects encapsulate the intention:

public record CreateUserCommand(String name, String email, String password) {
    public CreateUserCommand {
        Objects.requireNonNull(name, "Name is required");
        Objects.requireNonNull(email, "Email is required");
        Objects.requireNonNull(password, "Password is required");
        
        if (password.length() < 8) {
            throw new IllegalArgumentException("Password must be at least 8 characters");
        }
    }
}

// Handler
public User handleCreateUser(CreateUserCommand command) {
    User user = new User();
    user.setName(command.name());
    user.setEmail(command.email());
    user.setPassword(passwordEncoder.encode(command.password()));
    return userRepository.save(user);
}

Conclusion

Java records have transformed how I approach domain modeling. Their compact syntax, built-in immutability, and seamless integration with newer Java features make them an essential tool in my development toolkit.

By applying these patterns, I’ve seen significant improvements in code clarity and reduced bugs related to mutable state. The validation capabilities ensure that invalid states are caught early, and the composition patterns create clean domain hierarchies.

If you’re working with Java and haven’t fully explored records yet, I encourage you to try these patterns. They’ve changed how I think about modeling domains and have led to more maintainable, expressive code. The beauty of records lies in their simplicity - they do one thing very well: represent immutable data structures with minimal boilerplate.

Keywords: java records, domain modeling with records, java 16 records, java value objects, immutable data structures java, java DTOs with records, java record patterns, java record validation, java composite records, pattern matching with records, immutable collections java, event sourcing with java records, java sealed interfaces with records, java domain-driven design, immutable data modeling, java API response wrappers, java records validation, defensive programming java, java domain patterns, java immutability, java type-safe events, modern java features, java records best practices, java canonical constructor, java record constructors, java record methods, java pattern matching, java record composition, clean code java, boilerplate-free java, java record factories



Similar Posts
Blog Image
How Can You Effortlessly Shield Your Java Applications with Spring Security?

Crafting Digital Fortresses with Spring Security: A Developer's Guide

Blog Image
Take the Headache Out of Environment Switching with Micronaut

Switching App Environments the Smart Way with Micronaut

Blog Image
Unleash the Power of Microservice Magic with Spring Cloud Netflix

From Chaos to Harmony: Mastering Microservices with Service Discovery and Load Balancing

Blog Image
Ready to Rock Your Java App with Cassandra and MongoDB?

Unleash the Power of Cassandra and MongoDB in Java

Blog Image
Why Java Developers Are the Highest Paid in 2024—Learn the Secret!

Java developers command high salaries due to language versatility, enterprise demand, cloud computing growth, and evolving features. Their skills in legacy systems, security, and modern development practices make them valuable across industries.

Blog Image
Unlock Java Superpowers: Spring Data Meets Elasticsearch

Power Up Your Java Applications with Spring Data Elasticsearch Integration