Java Records and Sealed Classes for Domain Modeling
Java records sealed classes together form the foundation of modern domain modeling in Java. Records provide immutable data carriers with automatic equals, hashCode, and toString. Sealed classes restrict which types can extend a base class, creating closed type hierarchies. Combined with pattern matching, they enable algebraic data types — a powerful modeling technique previously limited to functional languages like Kotlin, Scala, and Haskell.
This guide shows how to use records and sealed classes to build expressive, type-safe domain models that eliminate entire categories of bugs. You will learn patterns for modeling business events, command results, value objects, and state machines — all enforced by the compiler rather than runtime checks.
Records as Value Objects
Records are ideal for value objects — domain concepts defined by their attributes rather than identity. An email address, money amount, or date range are all value objects that benefit from records’ built-in immutability and structural equality.
// Value objects with validation
public record EmailAddress(String value) {
public EmailAddress {
if (value == null || !value.matches("^[\w.-]+@[\w.-]+\.[a-zA-Z]{2,}$")) {
throw new IllegalArgumentException("Invalid email: " + value);
}
value = value.toLowerCase().trim();
}
}
public record Money(BigDecimal amount, Currency currency) {
public Money {
Objects.requireNonNull(amount, "Amount required");
Objects.requireNonNull(currency, "Currency required");
if (amount.scale() > currency.getDefaultFractionDigits()) {
throw new IllegalArgumentException(
"Too many decimal places for " + currency);
}
}
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new CurrencyMismatchException(this.currency, other.currency);
}
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 static Money usd(String amount) {
return new Money(new BigDecimal(amount), Currency.getInstance("USD"));
}
}
public record DateRange(LocalDate start, LocalDate end) {
public DateRange {
if (end.isBefore(start)) {
throw new IllegalArgumentException("End before start");
}
}
public boolean contains(LocalDate date) {
return !date.isBefore(start) && !date.isAfter(end);
}
public long days() {
return ChronoUnit.DAYS.between(start, end);
}
}Sealed Classes for Algebraic Data Types
Sealed classes create closed type hierarchies where the compiler knows every possible subtype. This enables exhaustive pattern matching — the compiler verifies you handle every case, catching missing logic at compile time instead of runtime.
// Sealed command result — every outcome is explicit
public sealed interface PaymentResult {
record Success(String transactionId, Money amount, Instant processedAt)
implements PaymentResult {}
record Declined(String reason, String errorCode)
implements PaymentResult {}
record RequiresAuthentication(String redirectUrl, String challengeId)
implements PaymentResult {}
record NetworkError(Exception cause, int retryAfterSeconds)
implements PaymentResult {}
}
// Exhaustive pattern matching (Java 21+)
public String handlePaymentResult(PaymentResult result) {
return switch (result) {
case PaymentResult.Success s ->
"Payment %s processed: %s".formatted(s.transactionId(), s.amount());
case PaymentResult.Declined d ->
"Payment declined: %s (code: %s)".formatted(d.reason(), d.errorCode());
case PaymentResult.RequiresAuthentication auth ->
"Redirect to: " + auth.redirectUrl();
case PaymentResult.NetworkError err ->
"Retry after %d seconds".formatted(err.retryAfterSeconds());
// No default needed — compiler ensures all cases are handled
};
}Modeling Domain Events
// Sealed hierarchy for order domain events
public sealed interface OrderEvent {
OrderId orderId();
Instant occurredAt();
record OrderPlaced(OrderId orderId, CustomerId customerId,
List<LineItem> items, Money total,
Instant occurredAt) implements OrderEvent {}
record OrderConfirmed(OrderId orderId, Instant estimatedDelivery,
Instant occurredAt) implements OrderEvent {}
record OrderShipped(OrderId orderId, String trackingNumber,
String carrier, Instant occurredAt) implements OrderEvent {}
record OrderDelivered(OrderId orderId, Instant deliveredAt,
String signedBy, Instant occurredAt) implements OrderEvent {}
record OrderCancelled(OrderId orderId, String reason,
Money refundAmount, Instant occurredAt) implements OrderEvent {}
}
// Event sourcing with sealed events
public class OrderAggregate {
private OrderStatus status = OrderStatus.PENDING;
private final List<OrderEvent> changes = new ArrayList<>();
public void apply(OrderEvent event) {
// Compiler enforces handling every event type
switch (event) {
case OrderEvent.OrderPlaced e -> this.status = OrderStatus.PLACED;
case OrderEvent.OrderConfirmed e -> this.status = OrderStatus.CONFIRMED;
case OrderEvent.OrderShipped e -> this.status = OrderStatus.SHIPPED;
case OrderEvent.OrderDelivered e -> this.status = OrderStatus.DELIVERED;
case OrderEvent.OrderCancelled e -> this.status = OrderStatus.CANCELLED;
}
changes.add(event);
}
}State Machines with Sealed Types
Moreover, sealed classes excel at modeling state machines where transitions between states are strictly controlled. The compiler prevents invalid state transitions at compile time.
// Type-safe state machine for document workflow
public sealed interface DocumentState {
record Draft(DocumentId id, String content, AuthorId author)
implements DocumentState {
public InReview submitForReview(ReviewerId reviewer) {
return new InReview(id, content, author, reviewer, Instant.now());
}
}
record InReview(DocumentId id, String content, AuthorId author,
ReviewerId reviewer, Instant submittedAt)
implements DocumentState {
public Approved approve(String comments) {
return new Approved(id, content, author, reviewer,
comments, Instant.now());
}
public Draft reject(String reason) {
return new Draft(id, content + "\n// Rejected: " + reason, author);
}
}
record Approved(DocumentId id, String content, AuthorId author,
ReviewerId reviewer, String comments, Instant approvedAt)
implements DocumentState {
public Published publish() {
return new Published(id, content, author, Instant.now());
}
}
record Published(DocumentId id, String content, AuthorId author,
Instant publishedAt) implements DocumentState {
public Archived archive() {
return new Archived(id, content, author, publishedAt, Instant.now());
}
}
record Archived(DocumentId id, String content, AuthorId author,
Instant publishedAt, Instant archivedAt)
implements DocumentState {}
}
// Usage — invalid transitions are compile errors
var draft = new DocumentState.Draft(docId, "Hello", authorId);
var review = draft.submitForReview(reviewerId);
var approved = review.approve("Looks good");
// approved.reject() — COMPILE ERROR: reject() not available on Approved
var published = approved.publish();Records with Spring Boot
// Records as DTOs in Spring Boot REST APIs
public record CreateOrderRequest(
@NotNull CustomerId customerId,
@NotEmpty List<@Valid LineItemRequest> items,
@NotNull ShippingAddress shippingAddress
) {}
public record LineItemRequest(
@NotNull ProductId productId,
@Positive int quantity
) {}
// Spring Data projections with records
public interface OrderRepository extends JpaRepository<OrderEntity, Long> {
@Query("SELECT new com.app.OrderSummary(o.id, o.status, o.total) " +
"FROM OrderEntity o WHERE o.customerId = :customerId")
List<OrderSummary> findSummariesByCustomer(CustomerId customerId);
}
public record OrderSummary(Long id, OrderStatus status, Money total) {}When NOT to Use Records and Sealed Classes
Records are not suitable for JPA entities because they are immutable and final — JPA requires mutable proxies for lazy loading. Additionally, records cannot extend other classes, so deep inheritance hierarchies (which you should avoid anyway) are not possible with records. If your domain object has mutable state that changes over its lifecycle, a traditional class with private setters is more appropriate.
Furthermore, sealed classes add compile-time constraints that can slow down rapid prototyping. If your domain model is still evolving and new subtypes are added frequently, unsealed interfaces provide more flexibility. Consequently, use sealed classes when the type hierarchy is stable and well-understood, not during early exploration phases.
Key Takeaways
Java records sealed classes together bring algebraic data types to Java, enabling domain models that are validated by the compiler rather than runtime checks. Use records for value objects and DTOs, sealed classes for closed type hierarchies and state machines, and pattern matching for exhaustive business logic. The result is domain code that is more concise, safer, and easier to maintain than traditional class hierarchies with abstract methods.
For more Java topics, explore our guide on Java 21 pattern matching and Spring Boot production best practices. The JEP 395 Records specification and JEP 409 Sealed Classes specification provide the definitive language references.