The language that decided to change its mind
In 1995, James Gosling and the Sun Microsystems team launched Java with a clear promise of object-oriented simplicity for a connected world. The language was born as a response to C++, whose multiparadigm complexity was seen as a source of bugs and maintenance cost. Java chose a different path. Everything would be an object. All behavior would live inside a class. Every abstraction would be built on inheritance, encapsulation, and polymorphism. The bet was that a single paradigm, applied with discipline, would produce safer and more predictable software than the unrestrained freedom of mixing styles.
That bet worked for almost two decades. Java dominated the corporate market, trained generations of engineers, and sustained banking, government, and telecommunications systems around the world. But the world changed. Distributed computing, massive data processing, the rise of event-driven architectures, and the proliferation of APIs as contracts between systems created an environment where pure object orientation began to exact a high price in verbosity, rigidity, and a growing gap between what the engineer wanted to express and what the language allowed.
Thomas Kuhn, in "The Structure of Scientific Revolutions" (1962), described how dominant paradigms do not disappear at once. They accumulate anomalies, spot evidence that the current model no longer covers the whole of reality, until the tension leads to the emergence of a new paradigm that reorganizes the field under new premises. Java's trajectory fits that dynamic. The tensions described above accumulated for almost two decades, until Java 8 (2014) marked the first deliberate break. From then on, each release cycle expanded what the official syntax and semantics allow one to express, without abandoning what already worked.
Languages like Scala, Clojure, and Kotlin already offered immutability, first-class functions, and algebraic types on the same platform. With Java 8, the language itself began to move closer to those neighbors, incorporating into the JLS constructs that had previously existed only outside it. Lambdas and Streams came first. Then switch expressions (Java 14), records (Java 16), sealed classes (Java 17), and pattern matching with record patterns (Java 21) completed the path to data-oriented design. The result is a language that today allows choosing between organizing the system around identity and encapsulated behavior or around the explicit structure of data, depending on what the problem demands.
In practice, much of the industry applies OO more as syntactic convention than as a conceptual model. Typical training teaches syntax (classes, inheritance, interfaces) and classic patterns, but devotes little time to the reasoning that underlies the paradigm. When to encapsulate state, how to protect invariants over an object's lifecycle, and in which situations identity matters more than the structure of data are questions that rarely get the emphasis they deserve. The result is that many systems written in Java are, in essence, structured programming dressed in classes. Data exposed in public fields or indiscriminate getters, logic concentrated in procedural services, invariants that depend on discipline rather than design. OO as a label, not as a modeling tool. Recognizing this gap matters because the arrival of new paradigms in the language does not resolve it automatically. Without clarity on what OO does well (protect state, guarantee consistency, express identity), it is hard to know when to replace it with data orientation. The choice between paradigms depends on the nature of the problem, the architectural boundaries of the system, and the maturity of the team, no longer on the limitations the language imposed.
Java 8: the functional confession
Each Java 8 feature answered a real pressure from the ecosystem. Lambdas allowed expressing ephemeral behavior without the ceremony of anonymous classes. Streams offered a declarative model of processing that opened the door to transparent parallelism. Optional made the absence of value explicit as part of the type contract. Together, these additions expanded the language's repertoire. Problems that once required ceremony and indirection gained direct expression, and the programmer could choose among more than one path to the same solution.
Before Java 8, processing a collection required a ceremonial ritual. You had to create an anonymous class, implement an interface, instantiate iterators, and accumulate results in mutable variables. The code devoted more lines to the iteration mechanism than to the logic the engineer actually wanted to express, and that disproportion accumulated in every method, every class, across the whole project. Compare:
// Java 7: imperative processing
List<String> names = new ArrayList<>();
for (Order order : orders) {
if (order.getValue() > 1000) {
names.add(order.getClient().getName());
}
}
Collections.sort(names);// Java 8: declarative processing
List<String> names = orders.stream()
.filter(o -> o.getValue() > 1000)
.map(o -> o.getClient().getName())
.sorted()
.collect(Collectors.toList());The difference between these two snippets goes beyond appearance. In the first case, the engineer takes on the responsibility of managing mutable state, controlling iteration, and ensuring ordering, all of it intertwined with the filtering and extraction logic that is the real goal of the code. In the second, they describe what they want to obtain, and the runtime decides how to execute, including the possibility of transparent parallelism via parallelStream(). That shift from imperative to declarative reflects a broader change in how we think about processing. Instead of dictating each step, the engineer defines the desired result and delegates the mechanics to the runtime.
Brian Goetz, Java language architect and one of the main leads of Project Lambda, explained the motivation in terms that reveal the depth of the change:
"The biggest language change in Java 8 is the addition of lambda expressions. [...] Lambda expressions give us a lightweight way to represent a function as data."
— Brian Goetz, State of the Lambda
The key phrase is "represent a function as data." That is the essence of the functional paradigm. Functions are first-class values; they can be passed as arguments, returned as results, stored in variables. By adopting this, Java allowed the engineer to choose between expressing behavior as a method of an object or as a value that flows through the system. The single model of 1995 gave way to an ecosystem in which object orientation and functional style coexist, each applicable where it fits best.
That change was neither accidental nor rushed. Project Lambda took years of careful design, with internal debate over whether lambdas should be objects (the natural approach for Java) or something new. The final decision was pragmatic. Lambdas are instances of functional interfaces, which preserves compatibility with the existing ecosystem while opening the door to a fundamentally different programming style.
Immutability as an architectural principle
Java 8 also brought, if indirectly, a higher regard for immutability as a design principle. The Streams API operates on data without modifying it. Optional encourages making the absence of value explicit instead of silent mutation via null. Functional interfaces like Function<T, R> and Predicate<T> express pure transformations, without side effects.
That regard did not emerge in a vacuum. It reflects a lesson software engineering learned from distributed and concurrent computing. Shared mutable state is at the root of most bugs in complex systems. When two threads access the same mutable object, the order of execution determines the result, and that order, in real systems, is unpredictable. Race conditions, deadlocks, and state inconsistencies are direct consequences of uncontrolled mutability.
An imperfect but useful analogy comes from physics. In quantum mechanics, the act of measuring a particle disturbs its state. The observer cannot capture reality without altering it. In concurrent systems with mutable state, something similar happens. You cannot safely observe the state of an object while another thread may be changing it. Reading and writing interfere with each other in unpredictable ways. The value you read may have already been changed between the read and the use. Immutable data removes that interference. Once created, it can be read by any number of threads without risk, because there is no mutation to invalidate the observation. It is as if the data, by becoming immutable, started to behave like a physical constant: any observer reads it and gets the same value, regardless of when or from where they observe.
"If you have an immutable value, it can be used by multiple threads with no problem at all. [...] You simply cannot have a race condition with an immutable value."
— Rich Hickey, creator of Clojure, Are We There Yet? (JVM Language Summit, 2009)
Java 8 did not make the language immutable by default, but it planted the seeds. Streams are immutable transformation pipelines. Optional is an immutable container. Functional interfaces encourage pure functions. The ground was being prepared for something larger.
Records: transparency as a design value
Six years after Java 8, JEP 395, delivered in Java 16 (March 2021), took the next step. The motivation behind records came from a recurring observation about the relationship between what the engineer wanted to model and the volume of code Java required for it. The JEP is direct in naming the problem:
"It's a common complaint that 'Java is too verbose' or has 'too much ceremony'. Some of the worst offenders are classes that are nothing more than immutable carriers of data for a handful of values."
— JEP 395: Records
To understand what records solve, consider what a Java engineer had to write before them to represent a simple concept like a domain event:
// Before records: ~60 lines to carry 4 fields
public final class OrderCreated {
private final String orderId;
private final String clientId;
private final BigDecimal value;
private final Instant createdAt;
public OrderCreated(String orderId, String clientId,
BigDecimal value, Instant createdAt) {
this.orderId = orderId;
this.clientId = clientId;
this.value = value;
this.createdAt = createdAt;
}
public String orderId() { return orderId; }
public String clientId() { return clientId; }
public BigDecimal value() { return value; }
public Instant createdAt() { return createdAt; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof OrderCreated)) return false;
OrderCreated that = (OrderCreated) o;
return Objects.equals(orderId, that.orderId)
&& Objects.equals(clientId, that.clientId)
&& Objects.equals(value, that.value)
&& Objects.equals(createdAt, that.createdAt);
}
@Override
public int hashCode() {
return Objects.hash(orderId, clientId, value, createdAt);
}
@Override
public String toString() {
return "OrderCreated[orderId=" + orderId
+ ", clientId=" + clientId
+ ", value=" + value
+ ", createdAt=" + createdAt + "]";
}
}// With records: the same semantics in one line
public record OrderCreated(
String orderId,
String clientId,
BigDecimal value,
Instant createdAt
) {}The reduction in boilerplate is the most visible consequence, but the real gain lies elsewhere. In the first case, the engineer implemented equals, hashCode, and toString by hand. Each of those implementations is a decision that can contain bugs, diverge from the others (a field included in equals but forgotten in hashCode), or be changed by a well-intentioned developer who did not understand the original contract. The type becomes opaque: looking at the field declarations is not enough to know how the object compares, identifies, or represents itself as text, because those answers are scattered in hand-written methods that may diverge from the declared structure. Its meaning depends on code that must be read, checked, and maintained, and any change to a field requires coordinated updates in several methods.
With the record, the compiler generates everything from the declaration, and that generation is deterministic. The structure of the type defines its identity, its equality, and its textual representation. The room for divergence between what the type declares and what it does disappears. That transparency is the philosophical core of records, and the JEP is explicit about it:
"A record class is a transparent carrier for a fixed set of values, called the record components, which are shallowly immutable."
— JEP 395: Records
The word "transparent" carries technical and philosophical weight. In a record, the API is derived mechanically from the description of the state. There is no hidden behavior, secret state, or embedded business logic. Whoever reads the type declaration knows exactly what it carries and how to access it. That transparency inverts the classic principle of encapsulation, where the goal is to hide state behind behavior. In records, the state is the contract. The visible structure defines identity, equality, and representation, with no intermediate layers.
"With records, the API of a class is derived mechanically from the description of the state. This gives up a degree of encapsulation: the fields of the class are exposed through its API."
— Brian Goetz, Data Classes and Sealed Types for Java
Giving up encapsulation may sound like heresy to those who grew up in the object-oriented paradigm. But Goetz argues that this openness is intentional. There are types whose reason for existing is to carry data, and forcing them to pretend they have encapsulated behavior is a lie the code tells itself.
Records also allow validation in the compact canonical constructor, so creation invariants can be preserved:
public record Coordinate(double latitude, double longitude) {
public Coordinate {
if (latitude < -90 || latitude > 90)
throw new IllegalArgumentException(
"Latitude must be between -90 and 90: " + latitude);
if (longitude < -180 || longitude > 180)
throw new IllegalArgumentException(
"Longitude must be between -180 and 180: " + longitude);
}
}After construction, the Coordinate is an immutable snapshot. Its values cannot change. Any thread can read it safely. Any serialization can reconstruct the record from its components, and the serialized form is deterministic: it derives from the canonical component list, with no separate version identifier. The contrast with the old POJO and serialVersionUID model is left for the article on DOP, where data crosses boundaries and the serialization contract gains weight.
Before records, much of the Java ecosystem relied on Lombok to reduce boilerplate in data-carrying classes. Annotations like @Data, @Value, @Getter, and @Builder generated at compile time constructors, accessors, equals, hashCode, and toString, bringing the code closer to a "data as data" model. Lombok became almost a standard in many projects. At the same time, it divides opinion. Supporters highlight productivity and readability. Critics point to the dependency on bytecode manipulation, fragility on JDK or IDE upgrades, the opacity of code that depends on invisible generation, and the difficulty of troubleshooting during debug, since the executed code does not match what the developer wrote.
Records enter as the language's native answer to the same problem Lombok addressed for immutable data carriers. For DTOs, events, commands, and value objects, a record replaces the class annotated with @Value or much of what @Data did, with no extra library and with semantics defined by the JLS. That does not invalidate Lombok where it still adds value (for example, mutable entities with setters or complex builders), but it clearly reduces the need for the library in new data-oriented code. The evolution of the language gradually absorbs the space that external tools occupied.
Records also carry deliberate restrictions worth knowing. A record cannot extend a class (only implement interfaces). It cannot declare instance fields beyond the header components. It does not support inheritance between records. These limitations exist precisely to preserve transparency. If a record could hide extra fields or inherit state from a superclass, the promise that "the declaration is the contract" would break. The rigidity is the price of the guarantee.
The value semantics of records also bring a consequence that often goes unnoticed. The in-memory representation becomes predictable. The compiler and the JVM know the exact layout of the components. That predictability opens room for optimizations that opaque types make difficult, from more efficient allocation to the possibility of inlining in scenarios the runtime considers advantageous. With sealed classes and switch over sealed types, the compiler can, in some cases, resolve at compile time which branch to execute, reducing the dispatch cost that polymorphic inheritance imposes. None of this requires the programmer to think about performance day to day; the form of the data, transparent and stable, allows the machine to make decisions it had no way to make before.
Sealed Classes: closing the universe of types
Records alone solve half the problem. They model what type theory calls product types, types defined by the combination (Cartesian product) of their components. An OrderCreated(String, String, BigDecimal, Instant) is the product of four types. But real systems also need sum types, types that represent a choice among mutually exclusive alternatives. A result can be success or failure. An event can be creation, update, or cancellation. A geometric shape can be circle, rectangle, or triangle.
In type theory, the combination of product types and sum types forms what are called Algebraic Data Types (ADTs), a fundamental construction in languages like Haskell, ML, Scala, and Rust. In Scala, case classes and sealed traits have played this role for years. In Kotlin, data classes combined with sealed classes offer the same pattern. Java, until Java 17, had no native mechanism for sum types. The solution was to use class hierarchies with inheritance, but nothing prevented a developer from creating a new subclass anywhere in the system, breaking the exhaustiveness of the model. With sealed interfaces and records, Java now speaks the same language as those languages for this kind of modeling.
JEP 409 (Java 17, September 2021) addressed this with sealed classes:
"A sealed class or interface can be extended or implemented only by those classes and interfaces that have permission to do so."
— JEP 409: Sealed Classes
In formal logic, this is known as the closed-world assumption. The set of possibilities is finite and known at compile time. Compare with the open-world assumption of traditional inheritance, where any class can extend any other (unless it is final). Sealed classes bring the closed-world assumption to Java:
public sealed interface PaymentResult
permits PaymentApproved, PaymentDeclined, PaymentPending {}
public record PaymentApproved(
String transactionId,
Instant processedAt
) implements PaymentResult {}
public record PaymentDeclined(
String reason,
String code
) implements PaymentResult {}
public record PaymentPending(
Instant expiresAt
) implements PaymentResult {}The compiler now knows that PaymentResult can only be one of these three variants. No other class can implement the interface. That has a decisive consequence. The compiler can verify exhaustiveness. If a switch over PaymentResult does not handle all variants, the compiler reports an error. The domain model ceases to be a convention that depends on team discipline and becomes a machine-verified constraint.
What sealed classes do is not eliminate extensibility, but choose where it ends. Inside the sealed type, the universe is finite. Outside it, the system remains open. The author of the type decides which variants exist, and the compiler ensures no other appears.
The same sealed-plus-records pattern serves to model operations that can fail in a typed way. Instead of throwing exceptions or returning null, the method returns a type that explicitly represents success or failure. For example, one can define a sealed interface Result<T, E> permitted only by Success(T value) and Failure(E error). The caller is required, at compile time, to handle both cases in a switch. The error becomes data that flows through the system with the same dignity as the success value; the decision of how to log, display, or recover stays at the point of use, and the type guarantees that neither branch is forgotten. Languages like Rust and Haskell popularized this style; in Java, sealed classes and records bring the same discipline to those who prefer that failures be part of the type contract.
The combination of sealed interfaces with records is where the pieces fit together. Each variant is a record (product type), and the sealed interface is the sum type that unites them. The result is a complete ADT in Java.
classDiagram
PaymentResult <|.. PaymentApproved
PaymentResult <|.. PaymentDeclined
PaymentResult <|.. PaymentPendingPattern Matching: the language that decomposes
Records define transparent data. Sealed classes define closed universes of types. One piece was still missing: decomposing that data in a safe and expressive way. The answer came with pattern matching.
JEP 394 (Java 16) introduced pattern matching for instanceof, eliminating the manual cast that accompanied every type check:
// Before: check + manual cast
if (result instanceof PaymentApproved) {
PaymentApproved approved = (PaymentApproved) result;
process(approved.transactionId());
}
// With pattern matching: check + binding in one expression
if (result instanceof PaymentApproved approved) {
process(approved.transactionId());
}But the real revolution came with JEP 441 (Java 21), which brought pattern matching to switch, and JEP 440, which brought record patterns. Together, they allow the deconstruction of type hierarchies:
public String describe(PaymentResult result) {
return switch (result) {
case PaymentApproved(var txId, var when) ->
"Approved: transaction %s at %s".formatted(txId, when);
case PaymentDeclined(var reason, var code) ->
"Declined [%s]: %s".formatted(code, reason);
case PaymentPending(var expires) ->
"Pending until %s".formatted(expires);
};
}Notice what is happening. The switch is an expression (not a statement), meaning it produces a value. The patterns deconstruct the records into their components, extracting the values directly. And because the interface is sealed, the compiler verifies exhaustiveness. If a new variant is added to PaymentResult, all switches that use it will stop compiling until they are updated. The type system becomes an active guardian of code consistency.
Java 21 also brought guarded patterns, which allow refining a case with a boolean condition via the when clause. The guard adds expressiveness without compromising exhaustiveness. The compiler still verifies that all variants are covered, regardless of the guard conditions.
String description = switch (result) {
case PaymentApproved(var txId, var when) when txId.startsWith("INT") ->
"International payment approved: " + txId;
case PaymentApproved(var txId, var when) ->
"Payment approved: " + txId;
case PaymentDeclined(var reason, var when) ->
"Declined: " + reason;
case PaymentPending(var deadline) ->
"Waiting until " + deadline;
};The contrast with polymorphism via virtual methods helps place each approach. In polymorphism, behavior lives in the subclasses and the decision of which method to run is made at runtime. In pattern matching, behavior stays at the point of use and the structure of the data guides the decision. The choice between them depends on the problem. When the behavior belongs intrinsically to the type (for example, an operation that only that type knows how to perform), polymorphism fits better. When the same data needs to be interpreted in different ways depending on context (transformations, validation, serialization, logging, response generation), centralizing the logic in a switch with pattern matching avoids spreading domain knowledge across many classes and makes it easier to add new consumers without changing the data types.
That same simplicity makes obsolete, in practice, several patterns that existed to work around language limitations. The Visitor was the classic solution for traversing a type hierarchy and performing a different action per variant. It required a visit interface, an accept method in each class of the hierarchy, and implementations that delegated to the correct visitor overload. The goal was to get something close to an exhaustive switch over the type, but in old Java there was no way to express that directly. With sealed classes and pattern matching, the same effect appears in a few lines. A single switch over the sealed interface replaces the Visitor interface, the accept implementations, and the visitor methods. Compare the classic Visitor for computing the area of geometric shapes with the same logic using sealed and pattern matching:
// Classic Visitor: visit interface + accept in each class
interface Shape {
<R> R accept(Visitor<R> v);
}
interface Visitor<R> {
R visit(Circle c);
R visit(Rectangle r);
}
class Circle implements Shape {
final double radius;
Circle(double radius) { this.radius = radius; }
@Override
public <R> R accept(Visitor<R> v) { return v.visit(this); }
}
class Rectangle implements Shape {
final double width, height;
Rectangle(double w, double h) { this.width = w; this.height = h; }
@Override
public <R> R accept(Visitor<R> v) { return v.visit(this); }
}
// Each new behavior requires a new visitor class
class AreaVisitor implements Visitor<Double> {
public Double visit(Circle c) { return Math.PI * c.radius * c.radius; }
public Double visit(Rectangle r) { return r.width * r.height; }
}
// Usage: shape.accept(new AreaVisitor());// Sealed + pattern matching: same result, no Visitor
sealed interface Shape permits Circle, Rectangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
double area(Shape s) {
return switch (s) {
case Circle(double r) -> Math.PI * r * r;
case Rectangle(double w, double h) -> w * h;
};
}Analogous patterns, such as the "type-safe heterogeneous container" for type-based dispatch, lose much of their reason for being when the compiler verifies switch exhaustiveness. The language also reduced the need for other verbosity workarounds. The Strategy pattern, when it boiled down to a single method, was largely absorbed by lambdas and method references. Before: an interface and several classes to vary behavior; today, the behavior is passed directly as a function:
// Classic Strategy: interface + one class per variant
interface SortStrategy {
int compare(Order a, Order b);
}
class SortByValue implements SortStrategy {
public int compare(Order a, Order b) { return a.value().compareTo(b.value()); }
}
class SortByDate implements SortStrategy {
public int compare(Order a, Order b) { return a.date().compareTo(b.date()); }
}
// Usage: list.sort((a, b) -> strategy.compare(a, b));// With lambda, the strategy becomes an argument
orders.sort(Comparator.comparing(Order::value));
orders.sort(Comparator.comparing(Order::date));Elaborate builders for objects with many optional fields remain useful in specific cases, but for simple data aggregates the record with its canonical constructor is enough. Optional itself, already in Java 8, made the "null object" pattern unnecessary in many contexts. What once required design patterns and libraries to compensate for the language is now expressed directly. Complexity does not disappear, but Java's evolution shifts much of it to where the language solves it natively.
This journey, from Java 8 to 21, shows the language incorporating immutability, data transparency, and exhaustiveness in an integrated way. The switch itself, which in classic Java was a statement prone to bugs from implicit fall-through, became an expression that produces a value and in which the compiler guarantees that every case is handled. Combined with records, sealed classes, and pattern matching, the repertoire is complete. These are not loose features, but pieces that fit together. In the article Data-Oriented Programming in Java: when data drives design, we explore how these pieces come together in the data-oriented paradigm and how to decide where data should drive the design of your system.