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.