For decades, the Gang of Four (GoF) design patterns were the standard for object-oriented programming. If you had conditional behavior, you built a Factory. If you had interchangeable algorithms, you built a Strategy.
While these patterns remain foundational, applying them blindly in modern Java (21 through 26) often introduces an unnecessary Abstraction Tax.
When engineering the Exeris Kernel, my goal was "No Waste Compute." This didn't mean chasing micro-optimizations, but rather rethinking control flow, context propagation, and concurrency using modern JVM primitives.
Here is a pragmatic look at where the JVM is heading, and how Data-Oriented Programming (DOP) combined with Project Loom changes the architectural calculus for closed-domain systems.
The Enterprise Reality Check & Disclaimers
Before diving in, let's ground this in reality.
1. The Migration Path: You don't rewrite systems overnight.
- Java 21 gives you Records, Sealed Interfaces, Pattern Matching, and Virtual Threads (GA). You can adopt DOP today.
- Java 25 brings
ScopedValueto GA, fixing context propagation. - Java 26+ further stabilizes
StructuredTaskScope(STS). Disclaimer: As of JDK 26, STS is in its 6th preview (JEP 525). Running it in production requires--enable-previewand organizational buy-in. It is highly stable, but it is not a final standard yet.
2. The "Closed World" Constraint: The DOP approaches shown below are designed for Closed-World domains (e.g., the core business logic of a specific microservice). If you are building an Open-World system (a plugin architecture, an extensible framework, or an SPI), you must respect the Open/Closed Principle. In those cases, sealed interfaces are the wrong tool, and traditional polymorphism with Service Registries remains the correct choice.
Step 1: The Legacy Approach (Understanding the Tax)
Historically, to process different payment methods, we relied on polymorphic Strategy classes managed by a Factory. If we needed to pass context (like a Transaction ID), we relied on ThreadLocal.
public class PaymentService {
public void process(String type) {
PaymentStrategy strategy = PaymentFactory.get(type);
strategy.process();
}
}
Let's be clear: allocating a single 24-byte Strategy object per request will not kill your heap. The true "Abstraction Tax" at scale comes from the combination of indirection: cognitive overhead, polymorphic dispatch costs on extreme hot-paths, deep object graph complexity, and crucially, the severe memory overhead of copying InheritableThreadLocal maps when spawning thousands of Virtual Threads.
Step 2: Data-Oriented Programming
In modern Java, we can separate data from behavior. Instead of a polymorphic Factory returning Strategy objects, we use Sealed Interfaces and Records to model our domain, and Pattern Matching for dispatch.
In DOP, records must guarantee the validity of their state at creation using Compact Constructors.
import java.math.BigDecimal;
// 1. A closed hierarchy. The compiler ensures exhaustiveness.
public sealed interface PaymentMethod permits CreditCard, Blik {}
// 2. Compact Constructors prevent invalid state
public record CreditCard(String cardNumber) implements PaymentMethod {
public CreditCard {
if (cardNumber == null || !cardNumber.matches("\\d{16}")) {
throw new IllegalArgumentException("Invalid card format");
}
}
public String getLastFourDigits() { return cardNumber.substring(12); }
}
public record Blik(String code) implements PaymentMethod {
public Blik {
if (code == null || !code.matches("\\d{6}")) {
throw new IllegalArgumentException("Invalid BLIK code");
}
}
}
For closed-domain dispatch, this eliminates the Factory entirely. You get compile-time exhaustiveness, guaranteed data validity, and more predictable control flow.
The Memory Footprint Shift
Step 3: Scoped Values (Context Without the Leak)
If nested logic needs a Transaction ID for logging, InheritableThreadLocal becomes a massive bottleneck. Copying state across millions of Virtual Threads destroys the lightweight nature of Project Loom.
In JDK 25, Scoped Values are GA. ScopedValue provides immutable, downward-only data flow. It drastically reduces inheritance overhead and automatically vanishes when the execution scope exits.
public class PaymentContext {
public static final ScopedValue<String> TX_ID = ScopedValue.newInstance();
}
// Binding the context
ScopedValue.where(PaymentContext.TX_ID, "TX-9981").run(() -> {
System.out.println("Processing ID: " + PaymentContext.TX_ID.get());
});
(Caveat: Because ScopedValues are strictly downward, patterns like updating MDC context deep in the call stack require architectural adjustments).
Step 4: Execution (Pure DOP + Structured Concurrency)
We have fixed data modeling (DOP) and state propagation (ScopedValue). Now we need execution.
If we execute a payment while simultaneously calling a fraud service, and the fraud service fails, the payment must abort instantly (Fail-Fast).
In 2026, many still use libraries like Resilience4j for this. To be clear: StructuredTaskScope does not replace retries or circuit breakers (which belong in your Service Mesh/Envoy layer). However, STS natively replaces application-layer bulkheads and timeouts.
Here is the implementation using JDK 26 Preview API. We fork virtual threads and pass validated data records directly to a function.
import java.math.BigDecimal;
import java.util.UUID;
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;
public record PaymentRequest(BigDecimal amount, PaymentMethod method) {}
public class PaymentOrchestrator {
public void handle(PaymentRequest request) {
String txId = UUID.randomUUID().toString().substring(0, 8);
ScopedValue.where(PaymentContext.TX_ID, txId).run(() -> {
executeConcurrentWorkflow(request);
});
}
private void executeConcurrentWorkflow(PaymentRequest request) {
// Enforces a strict concurrency model with ownership constraints
try (var scope = StructuredTaskScope.open()) {
Subtask<String> paymentTask = scope.fork(() -> executePayment(request.method(), request.amount()));
Subtask<Boolean> fraudTask = scope.fork(() -> performFraudCheck(request));
// Automatically interrupts sibling tasks if one fails
scope.join();
System.out.println("Payment: " + paymentTask.get());
System.out.println("Fraud Check: " + (fraudTask.get() ? "Clear" : "Suspicious"));
} catch (StructuredTaskScope.FailedException e) {
System.err.println("Transaction aborted: " + e.getCause().getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private String executePayment(PaymentMethod method, BigDecimal amount) throws InterruptedException {
System.out.println("[Tx: " + PaymentContext.TX_ID.get() + "] Executing...");
Thread.sleep(500); // Simulate I/O
return switch (method) {
case CreditCard c -> "Processing card ending in " + c.getLastFourDigits();
case Blik b -> "Authorizing BLIK code: " + b.code();
};
}
private boolean performFraudCheck(PaymentRequest request) throws InterruptedException {
Thread.sleep(300);
return true;
}
}
The Native Fail-Fast Architecture
The Pragmatic Verdict
What replaces traditional GoF patterns in closed-domain systems is not a new pattern, but a shift in native JVM primitives: data (Records), context (ScopedValue), and execution (StructuredTaskScope). By combining these primitives, we achieve:
- Reduced Indirection: We pass strictly validated records to functions running on virtual threads, bypassing proxy layers and improving predictability.
- Data Safety: Compact Constructors ensure invalid state never enters the pipeline.
-
Native Fail-Fast:
StructuredTaskScopenatively handles cancellation propagation across thread boundaries.
System design in 2026 isn't about entirely removing OOP or chasing zero-allocation myths. It is about understanding which infrastructure problems are now solved natively by the JVM and your Service Meshโand pragmatically removing the workarounds we used to rely on.
If you want to see how this model scales beyond simple request handling into a durable, off-heap runtime, take a look at the Exeris Kernel repository on GitHub.


Top comments (0)