Data-oriented programming with the latest Java JDK

Estafet consultants occasionally produce short, practical tech notes designed to help the broader software development community and colleagues. 

If you would like to have a more detailed discussion about any of these areas and/or how Estafet can help your organisation and teams with best practices in improving your SDLC, you are very welcome to contact us at enquiries@estafet.com

Introduction

In this article, I explore the evolving landscape of Java programming particularly in relation to data-oriented programming (DOP). I discuss how recent language features such as records, sealed classes, enhanced instanceof, and pattern matching can simplify and enhance data handling in Java applications. By adhering to the principles of DOP—modelling data accurately, ensuring immutability, validating at boundaries, and preventing illegal states—we can address common challenges in traditional object-oriented programming; challenges such as scalability, modifiability, and thread safety. I aim to provide a concise, practical guide for developers looking to adopt these modern Java features, offering examples to help you effectively apply these principles in real-world applications.

Principles of Data-oriented programming

The following are considered the key principles of DOP:

  • Model the data, the whole data, and nothing but the data – Each record should model one thing with clear, self-explanatory properties (record components). Alternatives should be modelled as sealed classes. All operations should be available
  • Data is immutable – In DOP, changes to data are accomplished by creating new versions (copies) of the data. The reference to a variable may be changed to a new version of the data, but the value of the data itself must never change.
  • Validate at the boundary – Before accepting data into our system, we should ensure that it is valid. This can be done in the code that receives the data from the (external) source or in the record constructor.
  • Make illegal states unrepresentable – Entries and sealed types (alternatives) make it easy to fully model our domain. Using pattern matching without a default clause, the compiler does not accept invalid states and at the same time obliges us to process all valid ones.

Problems of traditional OOP that DOP can avoid

Software developers can often be tempted to take shortcuts such as making extensive use of inheritance instead of conscientiously following the SOLID principles. Objects can therefore end up being used in different contexts, with classes hiding states everywhere. This can lead to problems in relation to scalability, modifiability, threading performance, and testing – to name a few.

Applications have long processed data in parallel. The ability for code to run in multithreaded mode without locks is practically a must. Synchronisation in Java greatly slows down performance.

Immutability means that once the object is created and initialised, its internal state cannot be changed again – access to the object is effectively READ_ONLY. Because of that, it automatically becomes thread-safe once it is created. Working with immutable data guarantees high-speed multithreaded performance without collisions and synchronisation overhead.

Now let’s have a look at which tools Java provides to write cleaner and more efficient data-oriented code.

Records

Records are designed as “transparent carriers of immutable data”. They automatically get field accessors, hashcode, equals, toString and constructors by the compiler. The developer can override these if needed. All properties are designed as private final fields. They are final classes but can implement interfaces and have type parameters. To comply with the immutability requirement, you must handle complex type properties yourself. The compiler-generated constructor will guarantee the immutability of the reference, but the content itself will remain mutable. So, for example, if you have a property of type Collection, you can use one of the static methods of Collections to create a deep immutable copy (as shown in the references below).

Let’s look at an example with a fairly simple document (the purpose here is to illustrate the mechanisms of the language rather than the accuracy of the representation). It has a title, summary, content, author and references.

“Validate at the boundary” can be done in the following ways:

  1. Add validation code in the constructor. The record’s compact constructor (as shown here) is a good candidate.
  2. Add validation code before entering the output data from the external system into our application. This is a good approach when the input is in a common place, such as at initialization.

Please note that records have natural constraints about serialisation:

  1. the serialisation of a record is based only on its state components, and
  2. the deserialization of a record uses only the canonical constructor.

Sealed classes for modelling the alternatives

Sealed classes are classes or interfaces designed to restrict which other classes or interfaces may extend it. Like enums, they allow the entire possible range of classes to be modelled for the given domain.

Sealed classes are advantageous in a few ways:

  • Provides clear hierarchy
  • Only permitted classes could be implemented, avoiding unexpected behaviour. This is exactly the last rule for DOP – “Make illegal states unrepresentable”. The compiler can check for exhaustiveness.
  • Enhances security, preventing malicious access/implementation. Permitted subclasses must be in the same module (if the sealed class is in a named module) or in the same package. Alternatively, you can define permitted subclasses in the same file as the sealed class (and omit the permits clause)
  • JVM can do a static analysis and optimizations, which is beneficial for performance

We can define the possible (mutually exclusive) variations for the author attribute.

An author can be a specific person, a group of people, or an organisation. 

Now you can see the relationships expressed with sealed classes.

Typecheck and cast in advanced instanceof

In Java 16, the improved version of instanceof was finalised. 

As you can see on the snippet lines

The enhanced instanceof operator performs the following steps:

  1. Check if the document’s author is of type MultipleAuthors
  2. Declares a new variable collective of type MultipleAuthors and assigns the value to it
  3. Value collective is available (in scope) only if the first check pass

Pattern matching on sealed types

Pattern matching on sealed types is perfect for applying polymorphic operations to data. 

Since Java 21 pattern matching can be used in a switch statement. “A switch statement transfers control to one of several statements or expressions, depending on the value of its selector expression. In earlier releases, the selector expression must evaluate to a number, string or enum constant, and case labels must be constants. However, in this release, the selector expression can be any reference type or an int type but not a long, float, double, or boolean type, and case labels can have patterns. Consequently, a switch statement or expression can test whether its selector expression matches a pattern, which offers more flexibility and expressiveness compared to testing whether its selector expression is exactly equal to a constant.”

This function returns some indicative string based on the author type. For the first case, the value of document.author() is assigned to the ia variable. As for the second and the third cases output is not related to the author’s value, I can use _ to indicate unused (and unnamed) variables. This is valid as of Java 22 (JEP 456).

A few things are important to note here:

  1. The switch statement has no default case. This forces the compiler to check that we have listed all possible options. If we missed something, the code simply won’t compile.
  2. If someone extends the Author type in the future, they will need to add this subtype in the switch case as well. Otherwise, the compiler gives an error “switch expression does not cover all possible input values”

Thus, we adhere to the recommendations “Model the data, all data and nothing but the data” and “Make illegal states unrepresentable”.

Decomposing the data

return switch (document.author()) {

As you can see here, we can deconstruct and extract all individual properties of the record type in switch and if statements. Of course, we can use unnamed _ for all unused state components

Conclusion

Data-oriented programming has 4 principles:

  • Model the data, the whole data, and nothing but the data
  • Data is immutable
  • Validate at the boundary
  • Make illegal states unrepresentable

It is positioned somewhere between OOP and Functional Programming(FP) but closer to FP. The Java language provides tools and functionality that make them easy to follow and implement – records, sealed classes, and pattern matching.

  • Use types to represent data:
    • Model data transparently and immutably (usually with records).
    • Model alternatives with sealed classes/interfaces.
    • Model the data as closely as possible and only represent legal states.
  • Separate behaviour/operations as methods on other classes:
    • Use exhaustive switch statements, predominantly over sealed interfaces and without a default branch.
    • Use pattern matching to identify and decompose data.

By Delcho Delov, Consultant at Estafet

Stay Informed with Our Newsletter!

Get the latest news, exclusive articles, and updates delivered to your inbox.