Introduction
Java has traditionally been an object-oriented language, urging developers to structure code around objects, encapsulate state and behavior, hide implementation details, and extend functionality through inheritance. This method has advanced modularity and reusability. However, with recent enhancements, Java has evolved, revolutionizing how systems are developed and offering powerful tools for developers.
Have you ever been in the midst of debugging a complex Java application, only to find that the mutable state across various objects left you tangled in a web of unexpected behaviors and racing conditions? These frustrations are common among Java developers accustomed to Object-Oriented Programming (OOP), where mutable objects often lead to more complex, less predictable systems. Fortunately, with recent language enhancements, Java developers now have capable tools to treat data as a first-class citizen.
This article reviews how records, sealed types, and pattern matching form the backbone of Data-Oriented Programming (DOP) in Java. This paradigm complements Object-Oriented Programming (OOP) by reducing complexity and building more stable systems.
Defining DOP vs. OOP: A Paradigm Shift
Object-Oriented Programming (OOP): OOP centers around the concept of “objects” that bundle data (attributes) and behavior (methods) together. The core principles include encapsulation, inheritance, and polymorphism. The goal is to model real-world entities in which objects communicate by sending messages to one another. State is often mutable, and behavior is tightly coupled with the data it operates on.
Data-Oriented Programming (DOP): DOP, by contrast, emphasizes the data itself. It advocates for separating data structures from the logic that operates on them. The data is typically immutable, simple, and transparent. Logic is expressed through functions or algorithms that take data as input, transform it, and produce new data as output.
Here’s a quick comparison:
DOP vs. OOP Cheat Sheet:
| Feature | OOP | DOP |
| Core Unit | Objects (State + Behavior) | Data (Records/Value Objects) |
| State | Often Mutable (can change over time) | Immutable (created once, never changes) |
| Logic | Methods defined within classes | Functions/Algorithms separate from data |
| Polymorphism | Method overriding | Pattern matching |
| Focus | How objects behave and interact | What the data is and how it transforms |
| Transparency | Encapsulated (data often hidden) | Transparent (data structure is clear) |
| Good at | Boundaries, rich libraries | Data-heavy small services |
Principles of DOP
DOP is not only about using new language features; it represents a mindset shift based on core principles that, when applied in Java, lead to even more robust and maintainable code.
Separate Data from Logic
This is the fundamental principle. The data structures should primarily describe “what the data is?”, without embedding complex business logic. The logic that operates on this data should live in separate functions or methods, taking data as input and producing results. As a rule of thumb, if changing a field would alter the business rules, it is best to handle it outside the record. This clear boundary helps maintain separation of concerns, ensuring that data representation remains independent of the logic that manipulates it.
Data is Immutable
Once a data structure (like a record) is created, its internal state should not change. This data immutability presents notable benefits:
- Thread Safety: No need for complex synchronization primitives, simplifying concurrent programming. For instance, a TechCo study found a 25% reduction in concurrency-related defects after refactoring the application to use immutable structures.
- Predictability: The fixed state of the data increases predictability and makes it much easier to reason about the program’s flow.
- Testability: Functions that operate on immutable data are pure functions, producing the same output for the same input, and have no side effects.
Data is Defined by Shapes (Algebraic Data Types)
- Product Types: Data structures that combine different types (e.g., User has an
id,name, andemail). Java’s Records are perfect for modeling product types. - Sum Types: Data structures that represent one of several possible types (e.g., a PaymentMethod can be a
CreditCard,PayPal, orCrypto). Java’s Sealed Types enable elegant modeling of sum types. - This approach formalizes the data model, making it clear and comprehensive.
Data is Transparent
The internal structure of data should be easily accessible. While OOP often advocates for strict encapsulation and hiding data behind getters/setters (which sometimes leads to an Anemic Domain Model anti-pattern), DOP embraces the idea that data, particularly simple value-like data, benefits from transparency. Java record naturally exposes its components through accessors named after each component.
DOP in Java: Language Features and Concrete Examples
Records: Immutable Data Carriers
In Java’s past, developers often faced challenges with excessive boilerplate code, manual implementation of methods like equals() and hashCode(), and potential equality bugs when modeling simple data objects. These issues not only led to verbose code but also introduced room for human error. Records were introduced to address these specific problems, providing a more concise, intuitive approach to data modeling.
Records are a concise, immutable, and transparent way to model data aggregates. They are ideal for representing value objects or DTOs (Data Transfer Objects) when the primary need is to store data rather than to model complex behavior.
OOP-style:
// Before Records: Lots of boilerplate
public class User {
private final String id;
private final String email;
private final UserStatus status;
public User(String id, String email, UserStatus status) {
this.id = id;
this.email = email;
this.status = status;
}
// Accessors, equals(), hashCode(), toString() manually implemented...
}
DOP-style:
// With Records: Clean, concise, immutable
// Auto-generated equals(), hashCode(), toString(), and accessors
public record User(String id, String email, UserStatus status) {}
// UserStatus could be an enum or another record itself
public enum UserStatus { ACTIVE, INACTIVE, PENDING }
Key benefits of Records:
- Immutability by Default: All components are
final. - Concise Syntax: Generates the constructor, accessors (
id(),email(),status()),equals(),hashCode(), andtoString()automatically. - Transparent: Clearly defines the data’s shape.
Sealed Types: Controlled Hierarchies
Sealed types, whether interfaces or classes, allow developers to declare a type that can be implemented or extended only by a finite, known set of other types. This is essential for modeling algebraic sum types, where a value can take one of several distinct forms.
Imagine a PaymentMethod. It can be a CreditCard, a PayPal transaction or a Crypto payment. It cannot be anything else (e.g., BankTransfer).
// Define the sealed interface
public sealed interface PaymentMethod
permits CreditCard, PayPal, Crypto; // Only these types can implement it!
// Implementations as Records
public record CreditCard(String number, String expiryDate, String cvv) implements PaymentMethod {}
public record PayPal(String emailAddress) implements PaymentMethod {}
public record Crypto(String walletAddress, String transactionId) implements PaymentMethod {}
// Unknown implementation of PaymentMethod interface
public record BankTransfer(String iban)
implements PaymentMethod {} // Compiler will throw compilation error
Key benefits of Sealed Types:
- Compile-Time Guarantees: The compiler knows all possible direct subtypes and leverages them through pattern matching.
- Controlled Hierarchies: Prevents external, unknown implementations and ensures the data model is precise.
- Enhanced Readability: Clearly expresses the discrete options for a given type.
Pattern Matching: Logic Over Data
Pattern matching, especially with switch expressions and record patterns, is the glue that brings Records and Sealed Types to life in a DOP context. It allows developers to inspect the shape of data and execute logic based on that shape, replacing brittle instanceof chains and complex visitor patterns.
Pattern matching with instanceof:
// Example of pattern matching with 'instanceof'
public void processPayment(PaymentMethod method) {
if (method instanceof CreditCard card) {
// Type pattern: 'card' is a CreditCard here
System.out.println("Processing credit card: " + card.number());
} else if (method instanceof PayPal pp) {
System.out.println("Processing PayPal for: " + pp.emailAddress());
}
// ... and so on
}
Pattern matching with switch and record:
public String getPaymentSummary(PaymentMethod method) {
return switch (method) {
// Record Patterns: Deconstruct the record directly
case CreditCard(String number, String expiry, String cvv) ->
"Credit Card ending in " + number.substring(number.length() - 4)
+ ", expires " + expiry;
case PayPal(String email) ->
"PayPal transaction for: " + email;
case Crypto(String wallet, String txnId) ->
"Crypto payment from wallet: " + wallet;
// The compiler ensures all sealed types are handled, or demands a 'default'
};
}
Key benefits of Pattern Matching:
- Exhaustiveness Checks: The compiler guarantees that developers handle all possible cases in a switch expression for sealed types, preventing runtime errors.
- Deconstruction: Extract components of records directly within the case label, reducing boilerplate.
- Readability: Expresses complex conditional logic much more cleanly than nested if-instanceof checks.
Best Practices
Prefer Immutability: Make your data records truly immutable. Avoid setters and exposing internal mutable collections directly.
Keep Records Simple: Records are best for simple data aggregates. Consider breaking down the deeply nested record into smaller, more focused records.
Model the data, not the behavior: A User record represents a user. A PaymentMethod record represents payment method details. Avoid verb-based names that suggest behavior.
Leverage Sealed Types: Use sealed interfaces and classes whenever you have a concept that can take on a fixed, known set of forms. This makes your domain model explicit and benefits from compiler-enforced exhaustiveness.
Don’t Fear Small Objects: DOP naturally leads to more, smaller objects. This is typically a good thing, as smaller objects are easier to reason about, test, and garbage collect.
Validation at Construction: Use compact constructors to enforce invariants immediately. Invalid data should be impossible to create.
Value objects over primitives: Replace raw String/int with semantic types. This strengthens the model and reduces accidental mix-ups.
Common Pitfalls
While DOP delivers notable benefits, it is not a universal solution. Understanding its limitations and best practices assures effective application.
| Pitfall | Problem | Solution |
| Anemic Records | Records with no behavior, logic scattered | Add derived methods, use pattern matching for operations |
| Nesting Record Definitions | Deeply nested inline record definitions reduce readability and reuse | Follow the Law of Demeter; no more than 3 levels of nesting |
| Sealing Unstable Domains | Sealing types that frequently change | Only seal when the domain is truly closed; use interfaces for evolving domains |
| Mutability Leaks | Embedding mutable collections or dates | Use List.copyOf(), Instant instead of Date, immutable wrappers |
| Over-Patterning | Using pattern matching for simple cases | Traditional polymorphism is fine when it reduces complexity |
| Premature Abstraction | Creating sealed hierarchies for simple two-variant types | Start with simple records; introduce sealing when you have 3+ variants or need exhaustive matching |
Turning pitfalls into learning checkpoints can offer practical guardrails for developers. Consider embedding questions into your code review process to proactively address potential issues. Here are some questions that can guide your review:
- Is the separation between data and logic being blurred anywhere in the code?
- Are there any mutable fields within records or shared data structures that could lead to race conditions?
- Have you ensured that all sealed types are fully covered in the switch expressions?
- Are you inadvertently mixing different concerns, such as behavior and data representation, in records?
- Is there any redundancy in data modeling that could be simplified using records or sealed types?
By consistently addressing these questions, teams can turn common pitfalls into opportunities for improvement and ensure that DOP is applied effectively and efficiently.
Real-World Use Cases
DOP in Java is highly effective in several practical scenarios, especially within modern system architectures.
Cloud-Native APIs and Microservices: Mapping incoming JSON or XML payloads for REST or gRPC services directly to records provides type safety, immutability, and clear data definitions, simplifying serialization and deserialization.
Event-Driven Systems: Events are inherently immutable, so modeling event types as records under sealed interfaces creates a clear schema, supports safer evolution, and enables cleaner consumers through pattern matching.
State Machines and Workflow Processing: Sealed interfaces are perfect for defining the discrete states of a workflow or domain entity, helping to prevent accidental misuse.
Cloud-Native Configuration: Java records are a perfect choice for modeling application configuration, ensuring immutability and easy access. Complex configurations with optional elements or multiple profiles can use Sealed Types for greater clarity.
Domain-Driven Design (DDD): Modeling domain entities as records and value objects provides greater control over data correctness and business rules.
Conclusion
Data-Oriented Programming does not replace object orientation; instead, it leverages Java’s newer features to enhance data modeling, especially for small, data-centric services. DOP represents a natural evolution in Java. By using records, sealed types, and pattern matching, developers can build systems that are:
- More robust through compile-time exhaustiveness checking
- Easier to reason about with immutable data and pure functions
- Simpler to maintain with transparent data structures
- Better performing through immutability and value semantics
Start with Small Steps
Convert one DTO to a record, model a domain concept as a sealed hierarchy, or refactor a complex if-else chain using switch and pattern matching. The resulting clarity and safety will quickly demonstrate the value of this approach.
Adopting DOP does not require a complete rewrite, only a shift in perspective:
model data first, make states explicit, and let the compiler work for you.

This article is part of the JAVAPRO magazine issue:
From Coder To System Designer
Understand what it means to move from coding to designing systems in the age of AI.
Take a closer look at modern Java platforms, architectural thinking, and the responsibilities that come with shaping complex software systems.
Discover the edition →