Modern Java: records, sealed types, pattern matching

Java 16 to 21 changed how domain models are written. Prayog's commands and events are built on three features that work together: records for data, sealed interfaces for a closed set of types, and pattern-matching switch that the compiler checks for completeness. Plus where virtual threads fit, and where they don't.

1. The problem

An exchange has a fixed vocabulary: new order, cancel, modify, clock tick, session change, account switch. Every part of the system (engine, codec, gateway, replay) must handle every one. The danger is adding a seventh command and forgetting one of the places that handles them. Older Java had clumsy answers: long instanceof chains, the visitor pattern, or a "type" enum plus casting. All of them fail silently or at run time when a case is forgotten.

2. A business example: the bank's request forms

A bank branch accepts exactly three forms: deposit, withdrawal, transfer. Each form has fixed boxes (a record). The list of forms is printed on the wall and nobody may invent a fourth at the counter (sealed). Every department has a checklist with one line per form, and the auditor refuses any checklist missing a line (exhaustive switch). When head office adds a "standing order" form, every department's checklist is flagged until it's updated.

3. Records (Java 16)

public record NewOrder(String clientOrderId, long accountId, String symbol,
                       Side side, OrderType type, long price, long quantity) implements Command {
    public NewOrder {                       // compact constructor: validate, then fields are set
        Objects.requireNonNull(symbol, "symbol");
    }
}

4. Sealed interfaces (Java 17)

public sealed interface Command
        permits NewOrder, CancelOrder, ModifyOrder, ClockTick, SetSessionState, SetAccountEnabled {}

Only the listed types may implement Command. The compiler now knows the complete list, which unlocks the next feature. Permitted subclasses must be final (records are), sealed themselves, or explicitly non-sealed.

5. Pattern matching for switch (Java 21)

int length = switch (command) {
    case NewOrder c    -> encodeNewOrder(c);
    case CancelOrder c -> encodeCancel(c);
    case ClockTick c   -> encodeTick(c);
    // ... every permitted type, and no default
};
Don't add default to a switch over a sealed type. It compiles forever, silently routing a new type into the default branch. You lose the very check you wanted.

6. Try it: the compiler's exhaustiveness check

Tick the cases written in a switch over Prayog's ExchangeEvent, then add a new event type to the sealed interface and see what the compiler says.

Cases written in the switch

7. Virtual threads (Java 21)

A platform thread is an operating-system thread: about 1 MB of stack and an expensive context switch, so servers keep a pool of a few hundred. A virtual thread is scheduled by the JVM onto a few carrier threads. When it blocks on I/O, the JVM parks it and runs another, so a million of them is fine.

WhereVirtual threads?Why
Gateway: thousands of bot connections waiting on the networkA good fitSimple blocking code per connection, without a thread-pool limit
Post-trade service: database and Kafka callsA good fitLots of waiting on I/O
The matching threadNoIt never waits on I/O and must own a core: a dedicated platform thread, possibly pinned to a CPU
Journal stageNoOne long-lived thread doing sequential disk writes

A caveat in Java 21: a virtual thread blocking inside a synchronized block "pins" its carrier thread. Java 24 removed most of that (JEP 491); on 21, prefer ReentrantLock around blocking calls.

8. Smaller features worth knowing

FeatureSinceExample
Switch expressions with ->14No fall-through; the switch returns a value
Pattern instanceof16if (event instanceof Trade t) total += t.quantity();
Text blocks15Multi-line strings for JSON and SQL in tests
var10Local type inference; use where the type is obvious
Sequenced collections21list.getFirst(), list.getLast(), reversed(); used in Prayog's tests
Immutable factories9 / 10List.of, Map.of, List.copyOf
HexFormat17The SBE golden-bytes test prints bytes with it

9. In Prayog

WhereFeature
Command, ExchangeEventSealed interfaces of records
MatchingEngine.applyExhaustive pattern switch over Command
JournalCodec.encodeExhaustive switch expressions over commands and events. A new event type cannot be journaled by accident without a codec.
Instrument, SessionSchedule, PipelineConfigRecords with validation in compact constructors
EngineSetupRecord that defensively copies its list
Replay.Report, Segments.ScanResultSmall records as multi-value return types

10. Questions

Records or Lombok?

Records are part of the language: no annotation processor, no IDE plugin, and the compiler knows their structure (record patterns). Lombok still covers cases records don't, such as builders and mutable classes. For immutable data, records are the standard choice.

Is a pattern switch slower than a virtual method call?

Not meaningfully. The JIT compiles it to a short chain of type checks, which stays predictable even with many types, while a virtual call with many receiver types becomes "megamorphic" and can't be inlined. Measure before choosing either for speed; choose the switch for the compile-time completeness check.

When would I still use the visitor pattern?

On older Java versions, or when the type hierarchy is open (third parties add types) and the operations are closed. Sealed types plus switch suit the opposite: a closed set of types, with new operations added often.