Data-Oriented Programming in Spring Boot 4 (Using Java 25 Features)
Object-Oriented Programming (OOP) has been the dominant paradigm in Java for decades. It emphasizes hiding data behind methods and binding behavior to state. However, as backend systems become more concurrent and complex, tracking hidden state mutations becomes a source of critical bugs. With recent additions to the Java language—culminating in the robust features of Java 25—Data-Oriented Programming (DOP) has emerged as a powerful, safer alternative for modeling business logic in modern web services.
In this comprehensive guide, we will explore how to apply Data-Oriented Programming principles in a Spring Boot 4 application. We will use Java 25 features like record classes, sealed interfaces, and advanced Pattern Matching to build a safer, more transparent REST API. By the end of this article, you will understand when to use OOP, when to use DOP, and how to seamlessly integrate DOP with tools like Jackson for effortless JSON serialization.
What is Data-Oriented Programming?

Data-Oriented Programming is a paradigm that models data as immutable, transparent structures and completely separates the data from the operations that act upon it. Unlike OOP, where data and behavior are tightly coupled and hidden within objects, DOP treats data as passive.
If you have ever struggled to trace a bug because an object’s internal state was modified deep within a nested method call, you have experienced the pitfalls of OOP state mutations. DOP eliminates this by keeping data visible, immutable, and separate from the behavior.
The Four Core Principles of DOP in Java
- Model data as unconstrained data: Use transparent data structures (like Records) rather than hiding data behind getter and setter methods.
- Represent data immutably: Data should never change once it is created. If you need updated data, you create a new instance with the updated values.
- Separate code from data: Business logic should reside in stateless services or functions, not within the data structures themselves.
- Make illegal states unrepresentable: Use the type system (like Sealed Interfaces) to ensure that only valid states can exist in your application.
For more insights on Java’s evolution towards immutability and performance, check out our guide on Project Valhalla Value Types in Java 25.

Modeling the Domain with Java 25
To demonstrate these concepts, let us build a payment processing API. In traditional OOP, we might define an abstract PaymentMethod class with a polymorphic process() method that subclasses (like CreditCard or PayPal) override. In DOP, we take a different approach: we separate the data from the behavior.
We use Java 25’s sealed interface to define a closed hierarchy of payment methods, and record classes to define the transparent, immutable data structures.
package com.webcodein.dop.model;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
// 1. We seal the interface to restrict implementations
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = CreditCard.class, name = "creditCard"),
@JsonSubTypes.Type(value = PayPal.class, name = "paypal"),
@JsonSubTypes.Type(value = Crypto.class, name = "crypto")
})
public sealed interface PaymentMethod permits CreditCard, PayPal, Crypto {}
// 2. We use records for immutable, transparent data
public record CreditCard(String cardNumber, String expiryDate) implements PaymentMethod {}
public record PayPal(String email) implements PaymentMethod {}
public record Crypto(String walletAddress, String currency) implements PaymentMethod {}
By using the sealed keyword, the compiler knows exactly which types are permitted to implement PaymentMethod. No other classes can join this hierarchy. Because we use record classes, the data is inherently immutable. We also add Jackson annotations (@JsonTypeInfo and @JsonSubTypes) directly on the interface, which allows Spring Boot to automatically deserialize incoming JSON payloads into the correct record type based on a “type” field.
Next, let’s wrap this in a request object:
package com.webcodein.dop.model;
public record PaymentRequest(double amount, PaymentMethod method) {}
Implementing Behavior with Pattern Matching

With our data correctly modeled as passive records, we now need a place to put our business logic. In Spring Boot, this naturally belongs in a @Service class.
We will use Java’s advanced switch expressions and Pattern Matching to handle the different payment types. This feature allows the compiler to guarantee exhaustiveness. Because our interface is sealed, the compiler knows every possible subtype. If you add a new PaymentMethod later (e.g., BankTransfer) and forget to update this switch statement, your code will fail to compile!
package com.webcodein.dop.service;
import com.webcodein.dop.model.*;
import org.springframework.stereotype.Service;
@Service
public class PaymentProcessingService {
public String processPayment(PaymentRequest request) {
// Data-Oriented Programming using Pattern Matching
return switch (request.method()) {
case CreditCard cc ->
"Processing credit card ending in " + cc.cardNumber().substring(cc.cardNumber().length() - 4) + " for $" + request.amount();
case PayPal pp ->
"Processing PayPal payment for " + pp.email() + " for $" + request.amount();
// We can use guards (when clauses) for additional logic
case Crypto cr when cr.currency().equals("BTC") ->
"Processing Bitcoin payment to " + cr.walletAddress() + " for $" + request.amount();
case Crypto cr ->
"Processing Crypto payment in " + cr.currency() + " to " + cr.walletAddress() + " for $" + request.amount();
};
}
}
Notice how clean this is. We extract the data directly in the case statements (Type Patterns) and we don’t need a default clause because the compiler knows we have handled every possible subtype of PaymentMethod. This is safer than traditional polymorphism because the business logic for all payment types is grouped together, making it easier to read and maintain.
Exposing the REST API in Spring Boot 4

Now that our domain and service are ready, exposing this logic via a REST controller in Spring Boot 4 is incredibly straightforward. The framework handles the conversion from JSON to our Java records automatically.
package com.webcodein.dop.controller;
import com.webcodein.dop.model.PaymentRequest;
import com.webcodein.dop.service.PaymentProcessingService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/payments")
public class PaymentController {
private final PaymentProcessingService paymentService;
public PaymentController(PaymentProcessingService paymentService) {
this.paymentService = paymentService;
}
@PostMapping("/process")
public String process(@RequestBody PaymentRequest request) {
return paymentService.processPayment(request);
}
}
When a client sends a POST request to this endpoint, Jackson reads the type field in the JSON (e.g., "type": "creditCard") and automatically instantiates the correct CreditCard record. This is a perfect example of how modern Java features integrate seamlessly with enterprise frameworks. If you are interested in modern persistence strategies for these records, consider reading about Hibernate 7 and Spring Data JPA 4 New Features.
Advantages and Limitations of DOP
Advantages
- Thread Safety: Because records are strictly immutable, you never have to worry about race conditions or concurrent modifications when sharing data across multiple threads (e.g., Virtual Threads).
- Compile-Time Safety: Exhaustiveness checking with sealed interfaces guarantees that you never miss a new edge case when extending your domain.
- Readability: Grouping behavior in one place (the service) instead of spreading it across dozens of domain classes makes complex business workflows easier to understand.
Limitations
- Deep Copying: Updating heavily nested immutable structures requires copying the entire structure, which can be verbose (though libraries like RecordBuilder help mitigate this).
- Paradigm Shift: Teams strictly accustomed to OOP may find it challenging to separate behavior from data initially.
Best Practices for DOP in Spring Boot
- Keep records passive: Never put complex business logic or external service calls inside a Java
record. Records should only contain their data and, at most, basic validation logic in their compact constructors. - Use Jackson strategically: Always use
@JsonTypeInfoand@JsonSubTypeswhen receiving sealed interfaces via REST controllers to ensure safe and correct deserialization. - Avoid primitive obsession: Even in DOP, you should wrap important domain concepts (like an Email Address or Money) in their own records rather than using raw Strings or Doubles everywhere.
Conclusion
Data-Oriented Programming in Java 25 and Spring Boot 4 allows you to write simpler, safer, and more transparent code. By leveraging records, sealed interfaces, and pattern matching, you can maintain strong typing and exhaustive compilation checks while cleanly separating passive data from active behavior.
As Java continues to evolve, adopting these functional programming concepts will prepare your codebase for high concurrency, easier testing, and fewer runtime errors.
Companion Code: The complete, compilable source code for this workshop, including Testcontainers and JUnit 5 tests, is available in our public GitHub repository: View on GitHub.
Frequently Asked Questions (FAQ)
Is Data-Oriented Programming replacing Object-Oriented Programming in Java?
No, DOP is not replacing OOP. Instead, it provides a powerful alternative for specific scenarios, particularly when modeling pure data and business logic boundaries. OOP is still highly effective for stateful components and framework integrations.
Can I use Java records with JPA and Hibernate?
Records cannot be directly used as JPA @Entity classes because JPA requires a no-args constructor and mutable state (proxies). However, you can perfectly use records for DTOs (Data Transfer Objects), projections, and transient data processing within your services.
What is exhaustiveness checking in Java?
Exhaustiveness checking is a compiler feature that ensures a switch expression handles all possible values of a variable. When used with sealed classes or enum types, the compiler will throw an error if you forget to add a case block for a new subtype.
