The Modern Java Stack: Records, Virtual Threads, and DI Without Frameworks
Part 7 of 7 in Mastering Modern Java Jvm
Java 21 arrived with a wave of changes that fundamentally shift how we write enterprise Java — immutable data types, pattern matching on sealed hierarchies, first-class virtual threads for concurrency, and richer language constructs that reduce boilerplate across the board. The framework-heavy Java of the early 2010s (XML configs, verbose annotations) is giving way to a leaner stack where the language itself does more of the heavy lifting.
This post walks through three concrete examples: functional-style data processing with records and sealed types; concurrent I/O with virtual threads; and dependency injection built from first principles rather than pulled in as a framework dependency. All use only the JDK — no Spring, no libraries, just javac and java.
Functional Patterns: Sealed Types + Records + Streams
For years, Java developers wrote hundreds of lines of boilerplate for simple data classes: a class with private fields, a constructor, getters, setters, equals/hashCode, toString. Then to process that data, they either wrote loops mutating local state or reached for Streams with verbose lambdas.
Java 14+ records collapse the entire data-carrier pattern into one line. Java 17 sealed interfaces let you enumerate every possible subtype at compile time. Java 21’s deconstruction patterns in switch then let you extract fields directly, without getters or instanceof casts.
Here’s a complete example that demonstrates all three together:
record OrderItem(String product, double price, int quantity) {}
sealed interface Shape permits Circle, Rectangle, Triangle {
double area();
}
static record Circle(double radius) implements Shape {
public double area() { return Math.PI * radius * radius; }
}
static record Rectangle(double w, double h) implements Shape {
public double area() { return w * h; }
}
static record Triangle(double base, double height) implements Shape {
public double area() { return 0.5 * base * height; }
}
// Pattern match — fields extracted directly into var bindings
double area = switch (shape) {
case Circle(var r) -> Math.PI * r * r;
case Rectangle(var w, var h) -> w * h;
case Triangle(var b, var ht) -> 0.5 * b * ht;
};
The sealed interface permits clause means the compiler knows every subclass. If you add a fourth shape and forget to handle it in the switch, compilation fails — unlike the old pattern of instanceof checks where a missed case silently falls through.
Records also integrate with Streams, which lets you group, filter, and aggregate data without ever mutating a variable:
var totals = orderItems.stream()
.collect(Collectors.groupingBy(
OrderItem::product,
Collectors.summingDouble(oi -> oi.price() * oi.quantity())
));
Running the code produces:
- Circle with r=5 computes an area of 78.5398, Rectangle 4×6 gives exactly 24.0 — the math is correct.
- Stream grouping correctly aggregates Widget A (101.50) by product name.
Optionalavoids NPE: querying for “Widget C” (which doesn’t exist) returnsnot foundwithout a null check or try-catch.
The key shift here is thinking in transformations rather than state mutations — data flows through a chain of pure operations, and the compiler enforces that all shape subtypes are handled. Before Java 17-21, you needed an interface hierarchy with instanceof chains, manual equals/hashCode boilerplate, and mutable accumulators for aggregation.
Virtual Threads: Concurrency Without Complexity
The old Java concurrency model has two extremes: heavyweight platform threads (one OS thread per task, ~1–2 MB stack each) and the callback/event-loop pattern used in Node.js. The former makes scaling to thousands of concurrent connections expensive; the latter makes reasoning about control flow difficult.
Java 21’s virtual threads solve this by making lightweight scheduling a first-class language feature. A virtual thread costs ~few KB and is managed by the JVM, not the OS — it yields on I/O (like an event loop) but writes synchronous code like any other thread.
The demo below launches 10 I/O-bound tasks (each sleeps for 500 ms to simulate a service call) twice: once with a fixed thread pool of 4 platform threads, and once with virtual threads:
// Platform threads: only 4 run concurrently
var executor = Executors.newFixedThreadPool(4);
futures.add(executor.submit(() -> doIOWork("task-" + i, 500)));
// Virtual threads: all 10 run simultaneously
try (var vtExec = Executors.newVirtualThreadPerTaskExecutor()) {
vFutures.add(vtExec.submit(() -> doIOWork("vtask-" + i, 500)));
}
The output is clear:
- Platform threads took 1511 ms for 10 × 500ms tasks on a pool of 4 — the bottleneck was the pool size, not the work.
- Virtual threads completed the same 10 tasks in 506 ms — roughly wall-clock time of one task, because all 10 were running concurrently.
That ~3x speedup is the key takeaway: virtual threads remove the connection-limiting bottleneck that traditionally forced Java backends to use reactive frameworks (Project Reactor, Vert.x) or async callbacks. With virtual threads, you write blocking-style code that scales like an async framework.
You can also create individual virtual threads directly:
Thread vt = Thread.ofVirtual()
.name("my-virtual-thread")
.start(() -> { /* I/O work */ });
The isVirtual method on Thread lets you verify at runtime that your thread is virtual — the demo shows it switching from pool-1-thread-N (platform) to empty-named threads (virtual).
Dependency Injection: Framework-Free
Spring’s annotation-based DI (@Autowired, @Component) made dependency injection accessible but also locked teams into a specific framework’s lifecycle and classpath. Constructor injection — the pattern Spring itself recommends for testability — can be expressed cleanly without any container.
The core idea is simple: BalanceService declares its dependencies as constructor parameters on interfaces (UserRepository, NotificationService) rather than concrete implementations:
class BalanceService {
private final UserRepository repo;
private final NotificationService notifier;
BalanceService(UserRepository repo, NotificationService notifier) {
this.repo = repo;
this.notifier = notifier;
}
The “composition root” — the application’s entry point — wires everything together:
UserRepository repo = new InMemoryUserRepository();
NotificationService notifier = new EmailNotificationService();
BalanceService balanceService = new BalanceService(repo, notifier);
The output shows clean separation of concerns:
- Bob’s account is topped up: 125.50.
- Alice’s statement triggers an email notification (
[EMAIL] To: u1 | Balance for Alice: $150.00). - Non-existent users are handled gracefully without try-catch chains —
OptionalandifPresentOrElsedo the control flow.
This pattern is testable by construction: swap InMemoryUserRepository with a mock, swap EmailNotificationService with a LogNotificationService, and all tests work without any framework. The same code runs in production, test, and integration contexts — no XML, no annotation scanning, no classpath magic.
Takeaway
Java 21’s modern stack doesn’t require new frameworks to write clean, concurrent, maintainable code: records eliminate data-class boilerplate, sealed types + deconstruction patterns make type-safe logic exhaustive at compile time, virtual threads remove the concurrency complexity that reactive frameworks addressed, and constructor injection on interfaces gives you framework-free DI that works everywhere from unit tests to production.