In the article Java multiparadigm: records, sealed classes, and pattern matching we saw how Java gained records, sealed classes, pattern matching, and switch expressions. Here the focus shifts: what to do with those pieces in the architecture and when to let data drive design.
Data-Oriented Programming: the complete picture
Brian Goetz sums up the core of the paradigm in one sentence.
"The central idea of data-oriented programming in Java is: model the data as data, and model the behavior separately."
β Brian Goetz, Data-Oriented Programming in Java
The division is clear. On one side, the shape of what exists (structure, variants). On the other, what we do with that shape (functions and services that consume it). The data model becomes the compass. First the definition of the types, then the code that processes them. In many domains this separation reduces coupling and makes the data flow explicit. In systems with multiple consumers for the same event (notification, audit, metrics, read projection), concentrating structure in the types and processing in functions that receive those types avoids having every new use case require changing the domain. A short example shows this division in action.
public sealed interface Result
permits Success, Failure {}
public record Success(String value) implements Result {}
public record Failure(String error) implements Result {}
// Behavior lives outside the data
public String interpret(Result r) {
return switch (r) {
case Success(var v) -> "OK: " + v;
case Failure(var e) -> "Error: " + e;
};
}The data defines the structure. The switch defines what we do with it. Adding a new consumer (log, metric, notification) means writing another switch over the same type, without changing Result, Success, or Failure.
Worth clarifying what "data" means here. In DOP, "data" includes the value of variables and the shape of the type itself. A sealed interface with three permitted records carries the information "there are exactly these three possibilities." The switch that deconstructs that type uses that information to guarantee exhaustiveness. The shape of the type is therefore part of the data that drives design. The diagram below makes the division of roles explicit. On one side, modeling (records and sealed classes) defines what exists. On the other, processing (pattern matching and switch expressions) defines how it is consumed. Both converge to what type theory calls Algebraic Data Types (ADTs), as developed in the previous article. Records represent the combination of components (product types) and sealed interfaces represent the choice among alternatives (sum types). Together, they form types whose structure and variants are known and that can be deconstructed safely and exhaustively.
graph TD
A[Data-Oriented Programming] --> B[Records]
A --> C[Sealed Classes]
A --> D[Pattern Matching]
A --> E[Switch Expressions]
B --> F[Data Modeling]
C --> F
D --> G[Data Processing]
E --> G
F --> H[Algebraic Data Types]
G --> H
style A fill:#1a1a1a,stroke:#f5a623,color:#f5a623
style H fill:#1a1a1a,stroke:#f5a623,color:#f5a623The term "Data-Oriented Programming" has been around for years in other communities. In Clojure and Lisp, immutable data and functions that transform it form the core of the style. Yehonathan Sharvit systematized the topic in the book "Data-Oriented Programming" (Manning, 2022). Goetz gave the term a precise contour in Java and showed how records, sealed classes, and pattern matching realize the paradigm in the language. Those coming from Clojure or Scala recognize the same idea. Those coming only from Java get a name and constructs to apply it natively.
The paradigm pays dividends where there are multiple consumers for the same data or where data crosses boundaries. In event-driven architectures, an event is published once and read by several subscribers, each with an interpretation (notification, projection, metrics, audit). With events modeled as data (records in a sealed interface), each subscriber is a switch that decomposes the event. The contract of the bus is the structure of the type. The same holds for REST or GraphQL APIs. Payload as data; validation, transformation, and persistence as behavior. A contract as a record allows deriving OpenAPI or GraphQL schema from the declaration. Handlers are functions that receive a DTO and return a DTO or an effect. Data drives both the code and the contract the system exposes.
Consider a fuller example. A domain event processing system where different event types need to be routed, validated, and transformed.
public sealed interface DomainEvent
permits OrderCreated, OrderPaid, OrderCancelled, OrderShipped {
String orderId();
Instant occurredAt();
}
public record OrderCreated(
String orderId, String clientId,
List<Item> items, Instant occurredAt
) implements DomainEvent {}
public record OrderPaid(
String orderId, String transactionId,
BigDecimal amount, Instant occurredAt
) implements DomainEvent {}
public record OrderCancelled(
String orderId, String reason,
Instant occurredAt
) implements DomainEvent {}
public record OrderShipped(
String orderId, String trackingCode,
Instant estimatedDelivery, Instant occurredAt
) implements DomainEvent {}
public record Item(String productId, int quantity, BigDecimal price) {}public class NotificationService {
public Notification generate(DomainEvent event) {
return switch (event) {
case OrderCreated(var id, var client, var items, var when) ->
new Notification(
client,
"Order %s confirmed with %d items".formatted(id, items.size()),
Priority.NORMAL
);
case OrderPaid(var id, var txId, var amount, var occurred) ->
new Notification(
findClient(id),
"Payment of %s confirmed (tx: %s)".formatted(amount, txId),
Priority.HIGH
);
case OrderCancelled(var id, var reason, var occurred) ->
new Notification(
findClient(id),
"Order %s cancelled: %s".formatted(id, reason),
Priority.HIGH
);
case OrderShipped(var id, var tracking, var estimated, var occurred) ->
new Notification(
findClient(id),
"Order %s shipped! Tracking: %s. ETA: %s"
.formatted(id, tracking, estimated),
Priority.NORMAL
);
};
}
}This code has properties that would have been impossible in Java before these features. The modeling is algebraic. The compiler knows all variants. Deconstruction is safe and components are extracted directly from the patterns. Processing is exhaustive. Adding a new event type forces updating all switches. Each event carries exactly what it declares, with no hidden state. That transparency brings a collateral benefit for those writing tests. Creating an event for a test scenario boils down to instantiating the record with the desired values, with no mocks or elaborate object construction.
Compare that with the classic OO approach, where each event type would have a polymorphic generate() method. The OO approach distributes behavior across the classes, which is ideal when the behavior belongs to the type. The DOP approach centralizes behavior at the point of use, which is ideal when the same data is processed in different ways by different consumers (notification, audit, metrics, read projection, and so on).
The transparency trap
Every powerful tool carries the risk of misuse, and records are no exception. The specification itself warns about the precise meaning of "immutable."
"A record class is a transparent carrier for a fixed set of values, which are shallowly immutable."
β JEP 395: Records
"Shallowly immutable" is the key. The record freezes the references; the referenced objects remain mutable if they are mutable. A component that is a List<Item> can be modified externally after the record is created. Immutability remains only shallow. The example below illustrates the risk.
public record Order(String id, List<Item> items) {}
var items = new ArrayList<>(List.of(new Item("A", 1, BigDecimal.TEN)));
var order = new Order("001", items);
// The record is "immutable", but...
items.add(new Item("B", 2, BigDecimal.ONE)); // the original list was mutated
order.items().clear(); // the accessor returns the mutable referenceThe solution is defensive. Copying collections in the canonical constructor and returning unmodifiable copies fixes the problem.
public record Order(String id, List<Item> items) {
public Order {
items = List.copyOf(items); // immutable copy
}
}The subtler trap is architectural. When teams adopt records without assessing where each paradigm applies, they start replacing rich domain entities with transparent records. The result approaches what Martin Fowler described as "Anemic Domain Model."
"The basic symptom of an Anemic Domain Model is that at first sight it looks like the real thing. There are objects, many with names from the domain, and those objects are connected with the rich relationships and structure that true domain models have. The catch comes when you look at the behavior and you realize that there is hardly any behavior on these objects, making them little more than bags of getters and setters."
β Martin Fowler, AnemicDomainModel
Records can accelerate that erosion, because they make creating anemic types extremely easy. In practice, teams that adopted records indiscriminately began to move business rules into external services, creating an involuntary procedural style. Functions that receive data, transform it, and return new data, with none of the structures carrying responsibility. That can be powerful in event-oriented systems, where the separation between data and processing is a conscious architectural choice. But when applied at the heart of the domain, where complex invariants need to be protected over the object's entire lifetime, the absence of encapsulated behavior becomes a silent problem that only surfaces when the system grows.
Identity, encapsulation, and the limits of the record
To understand where records stop working, one must understand what identity means in a system. Eric Evans, in the book that shaped Domain-Driven Design, put the distinction plainly.
"Many objects are not defined fundamentally by their attributes, but by a thread of continuity and identity."
β Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software
A BankAccount illustrates the point. Two accounts can have the same holder, the same balance, and the same opening date. They are still different accounts. The identity of the account is something that persists over time, independent of the values it holds at any moment. The balance changes, the holder may change, but the account remains the same account.
Records define equality by structure. Two records with the same components are equal. That semantics is perfect for values and data in transit, but produces the wrong result for entities.
// Record: equality by structure
public record BankAccountRecord(String holder, BigDecimal balance) {}
var account1 = new BankAccountRecord("Ann", new BigDecimal("1000"));
var account2 = new BankAccountRecord("Ann", new BigDecimal("1000"));
account1.equals(account2); // true β but they are different accounts!The equals returns true because the components are equal. In the real world, these are two distinct accounts that happen to have the same values at that instant. The entity's identity is lost.
Beyond identity, there is encapsulation. JEP 395 is explicit about what records give up.
"Record classes give up a freedom that classes normally have: the ability to decouple the API of a class from its internal representation."
β JEP 395: Records
That transparency is a virtue when the type exists to carry data. But when the type needs to protect business rules, exposure becomes a risk. Compare the two approaches for BankAccount.
// Class with encapsulation: rules live inside the type
public class BankAccount {
private final String id;
private final String holder;
private BigDecimal balance;
private final List<Transaction> history;
public BankAccount(String id, String holder, BigDecimal initialBalance) {
this.id = id;
this.holder = holder;
this.balance = initialBalance;
this.history = new ArrayList<>();
}
public void debit(BigDecimal amount) {
if (amount.compareTo(balance) > 0) {
throw new InsufficientBalanceException(id, balance, amount);
}
balance = balance.subtract(amount);
history.add(new Transaction(TransactionType.DEBIT, amount, Instant.now()));
}
public void credit(BigDecimal amount) {
balance = balance.add(amount);
history.add(new Transaction(TransactionType.CREDIT, amount, Instant.now()));
}
public BigDecimal getBalance() {
return balance;
}
@Override
public boolean equals(Object o) {
return o instanceof BankAccount b && id.equals(b.id);
}
@Override
public int hashCode() {
return id.hashCode();
}
}The balance only changes through debit() and credit(). The invariant "balance never negative without authorization" is protected by the type itself. The equals compares by id, preserving the entity's identity. Any consumer that receives a BankAccount operates under those guarantees without needing to know them.
Now the same attempt with a record.
// Record: everything exposed, no guarantees
public record BankAccountRecord(
String id, String holder,
BigDecimal balance, List<Transaction> history
) {}
var account = new BankAccountRecord(
"001", "Ann", new BigDecimal("1000"), new ArrayList<>()
);
// Any code can "debit" without validation
var newAccount = new BankAccountRecord(
account.id(), account.holder(),
account.balance().subtract(new BigDecimal("5000")), // negative balance, no barrier
account.history()
);The record exposes all components via accessors. Any code can reconstruct a new instance with a negative balance, without going through any rule. The invariant the class protected disappears. Goetz points to this zone precisely.
"OO is at its best when it defines and defends boundaries: boundaries of maintenance, boundaries of versioning, boundaries of encapsulation, boundaries of compilation, boundaries of compatibility, boundaries of security, etc."
β Brian Goetz, Data-Oriented Programming in Java
Where those boundaries need to exist, the record works against the design. The transparency that makes it ideal for data in transit is the same that removes the protective barriers when applied in the domain.
The diagram below maps that separation. Entities and aggregates live in the core, protected by encapsulation. Records inhabit the boundaries, where transparency is desirable.
graph LR
subgraph External Boundary
A[REST API] --> B[Request DTO record]
Z[Response DTO record] --> A
end
subgraph Application Layer
B --> C[Command record]
C --> D[Application Service]
D --> E[Domain Event record]
D --> Z
end
subgraph Domain Core
D --> F[Entity Aggregate class]
F --> G[Value Object record]
end
subgraph Output Boundary
E --> H[Message Broker]
D --> I[Repository]
end
style F fill:#1a1a1a,stroke:#f5a623,color:#f5a623
style G fill:#1a1a1a,stroke:#f5a623,color:#f5a623The cost of evolving without copy
Records in Java are immutable. To produce a new value from an existing one, you must reconstruct the entire record, passing all components manually.
public record Address(
String street, String number, String complement,
String neighborhood, String city, String state, String zip
) {}
var original = new Address(
"Main St", "42", "Apt 3",
"Downtown", "New York", "NY", "10001"
);
// To change only the complement, all other components are repeated
var updated = new Address(
original.street(), original.number(), "Apt 7",
original.neighborhood(), original.city(), original.state(), original.zip()
);In Kotlin, data classes offer the copy() method, which solves this problem concisely.
data class Address(
val street: String, val number: String, val complement: String,
val neighborhood: String, val city: String, val state: String, val zip: String
)
val updated = original.copy(complement = "Apt 7")The official Kotlin documentation describes the purpose directly.
"Use the copy() function to copy an object, allowing you to change some of its properties while keeping the rest unchanged."
β Kotlin Documentation, Data Classes
Java chose not to include copy() in records. The discussion on the OpenJDK amber-spec-experts list addressed the topic, and the decision was deliberate. With few components, manual reconstruction is acceptable. With many, the cost scales and the code becomes verbose. That is a point the engineer must consider when modeling records with many components. If the type will often be "evolved" into new values with small variations, the absence of copy() is a drawback.
Value Objects: where the record fits perfectly
If entities depend on identity and encapsulation, value objects live at the opposite pole. The identity of a value object is defined entirely by its components. Two Money objects with the same value and the same currency represent, by definition, the same concept.
public record Money(BigDecimal amount, String currency) {
public Money {
if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0)
throw new IllegalArgumentException("Amount cannot be null or negative");
if (currency == null || currency.isBlank())
throw new IllegalArgumentException("Currency cannot be empty");
}
public Money add(Money other) {
if (!this.currency.equals(other.currency))
throw new IllegalArgumentException(
"Cannot add different currencies: %s and %s"
.formatted(this.currency, other.currency));
return new Money(this.amount.add(other.amount), this.currency);
}
}public record Coordinate(double latitude, double longitude) {
public Coordinate {
if (latitude < -90 || latitude > 90)
throw new IllegalArgumentException("Latitude out of valid range");
if (longitude < -180 || longitude > 180)
throw new IllegalArgumentException("Longitude out of valid range");
}
}In these types, the structural equality of records does exactly what one expects. new Money(new BigDecimal("100"), "USD") is equal to another Money with the same values, because they represent the same thing. Validation in the canonical constructor ensures no invalid instance exists. The record carries the data and the validity guarantee, without needing to hide anything.
State change versus state evolution
A way to fix the decision between class and record is to distinguish state change from state evolution.
In state change, the same object is modified over time. The reference stays; the internal content changes. The bank account receives debit and credit through methods that update its balance and history in the object itself. The lifecycle includes loading, modifying, and persisting the same instance.
In state evolution, the object is not modified. A new value is produced from the previous one. A domain event does not "change." It occurs and is represented by an immutable record. An API response does not "update" a DTO. Each response is a new value.
graph TD
subgraph "State change (class)"
E1[BankAccount id=001
balance: 1000] -->|debit 200| E2[BankAccount id=001
balance: 800]
E2 -->|credit 500| E3[BankAccount id=001
balance: 1300]
end
subgraph "State evolution (record)"
R1[OrderCreated] --> R2[OrderPaid]
R2 --> R3[OrderShipped]
end
style E1 fill:#1a1a1a,stroke:#f5a623,color:#f5a623
style E2 fill:#1a1a1a,stroke:#f5a623,color:#f5a623
style E3 fill:#1a1a1a,stroke:#f5a623,color:#f5a623In state change, the same identity runs through time. In state evolution, each instance is an immutable fact that follows the previous one. If the type represents something that "lives" in time and accumulates changes under the same identity, the class with encapsulation remains appropriate. If the type represents a fact, a command, a value, or a contract for passage between layers, the record fits.
Architectural boundaries
Records shine at the boundaries of the system. Input and output DTOs, commands, queries, domain events, and read projections are all types that exist to carry data between contexts. In those zones, transparency is a virtue. When a record crosses a queue, a REST API, or an event bus, its meaning does not depend on hidden code. The structure defines the identity of the data. That same structure maps directly to JSON, to OpenAPI contracts, or to the payload of a message. Serialization libraries can inspect the record's components and generate schemas, validate input, and produce documentation with no extra code.
Record serialization reinforces that philosophy. The historical Java serialization mechanism (java.io.Serializable) relied on a version number, serialVersionUID, to ensure compatibility between who serializes and who deserializes. In practice, that created fragility. Forgetting to declare the UID meant a simple refactor could break compatibility. Declaring it and then changing the class in an incompatible way while keeping the same UID produced silent bugs. Records eliminate that problem. The serialized format is derived directly from the record's components, with no separate version identifier. The structure is the contract. Whoever reads the record declaration knows exactly what is being exchanged.
Conscious hybridism
Goetz puts the coexistence of the paradigms precisely.
"The techniques of OOP and data-oriented programming are not in conflict. They are different tools for different granularities and situations. We can combine them freely as we see fit."
β Brian Goetz, Data-Oriented Programming in Java
Maturity in the use of modern Java appears when the engineer recognizes that object orientation and data orientation complement each other. Rich entities concentrate behavior where identity and domain invariants demand protection. Records appear at the boundaries, where data moves between contexts and structural clarity matters more than encapsulation. Sealed classes enter when the domain demands a finite, known set of variants. Pattern matching gains ground where the interpretation of data varies with context.
Those working with legacy code on Java 8 or 11 can adopt records and sealed classes gradually. The strategy that most reduces risk is to start at the edges of the system. New API DTOs, new domain events, new commands or queries can be modeled as records. Existing entities and aggregates remain as classes. Over time, value objects that are currently immutable classes can be migrated to records, and hierarchies that represent closed choices (such as operation results or event types) can be sealed. Compatibility with legacy serialization, reflection, and type-inspecting tools is usually preserved, since records are classes in the bytecode. What changes is the semantics and conciseness in the source code.
Java's multiparadigm turn accompanies a change in the kind of software the world has come to demand. Distributed systems, event-driven architectures, massive data processing, and APIs as contracts between teams and organizations demand different ways of expressing intent. When the language offers only one paradigm, the engineer ends up distorting the problem to fit the solution. With more than one paradigm available, the solution can adapt to the problem, and each context chooses the style that describes it best.
The evolution of Java, from a purely object-oriented language to a complete multiparadigm ecosystem, reflects a mature response to the pressures of modern software. The language incorporated new modes of expression without abandoning what already worked. What defines the outcome is the conscious use of each tool, more than the quantity available. Where data-oriented programming applies, that means letting data drive design. First the structure and the variants, then the code that consumes them. In every genuine evolution, the gain lies in knowing when to apply which paradigm, and in letting the problem guide the choice.
Conclusion
In the previous article, we followed Java incorporating lambdas, records, sealed classes, pattern matching, and switch expressions. Each feature answered a real limitation of the language. In this article, we saw what happens when those pieces organize into a coherent paradigm and where that paradigm meets its limits. The separation between data and behavior works when data crosses boundaries. Encapsulation works when the domain needs protection. Identity, invariants, and lifecycle ask for classes with behavior. Transport, contracts, and immutable evolution ask for records. The point of maturity is knowing where each applies.
The language offers more repertoire than most projects use today. Records, sealed classes, and pattern matching have been available since Java 21, but adoption depends less on the tool and more on how teams think about design. The real gain does not come from replacing classes with records or from adopting pattern matching in every switch. It comes from recognizing the nature of the type being modeled and choosing the construct that expresses that nature with the greatest fidelity.
"When we're modeling complex entities, OO techniques have a lot to offer us. But when we're building simple services that process simple, ad-hoc data, the techniques of data-oriented programming can offer us a more direct path."
β Brian Goetz, Data-Oriented Programming in Java
The best tool is not the newest. It is the one the engineer knows when to use. And knowing when to use it requires understanding what each paradigm protects, what each paradigm exposes, and what the problem at hand is asking for.
References
Brian Goetz. Data-Oriented Programming in Java. InfoQ, 2022.
Brian Goetz. Data-Oriented Programming in Java (v1.1). Inside.java, 2024.
Eric Evans. Domain-Driven Design: Tackling Complexity in the Heart of Software. Addison-Wesley, 2003.
Martin Fowler. AnemicDomainModel. martinfowler.com, 2003.
Yehonathan Sharvit. Data-Oriented Programming. Manning, 2022.
OpenJDK. JEP 395: Records. 2021.
OpenJDK. JEP 409: Sealed Classes. 2021.
OpenJDK. JEP 440: Record Patterns. 2023.
OpenJDK. JEP 441: Pattern Matching for switch. 2023.
Kotlin Documentation. Data Classes. kotlinlang.org.