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");
}
}
- You get: private final fields, a constructor, accessors (
symbol()), and value-basedequals,hashCodeandtoString. Two orders with the same fields are equal, which makes tests like "replayed command equals original" one line. - Compact constructor: the place for validation. Prayog's
Instrumentrefuses a band that contains no valid price. - Shallowly immutable: the fields can't be reassigned, but a
Listfield could still be changed by whoever passed it in. Copy it:EngineSetupdoesinstruments = List.copyOf(instruments). - Not for everything: records can't extend classes and have no mutable state, so mutable things (an order resting in the book, whose leaves quantity shrinks) stay ordinary classes, like Prayog's
RestingOrder.
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
};
- Type patterns test and bind in one step: no cast.
- Exhaustiveness: over a sealed type, a switch expression without
defaultmust cover every permitted type or it doesn't compile. Add a seventh command and every such switch in the codebase becomes a compile error, pointing at exactly the places to update. That's the bank auditor. - Record patterns destructure:
case ClockTick(long t) -> .... - Guards:
case NewOrder o when o.quantity() <= 0 -> reject(...). - Null: a pattern switch throws
NullPointerExceptionon null unless there's an explicitcase null.
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.
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.
| Where | Virtual threads? | Why |
|---|---|---|
| Gateway: thousands of bot connections waiting on the network | A good fit | Simple blocking code per connection, without a thread-pool limit |
| Post-trade service: database and Kafka calls | A good fit | Lots of waiting on I/O |
| The matching thread | No | It never waits on I/O and must own a core: a dedicated platform thread, possibly pinned to a CPU |
| Journal stage | No | One 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
| Feature | Since | Example |
|---|---|---|
Switch expressions with -> | 14 | No fall-through; the switch returns a value |
Pattern instanceof | 16 | if (event instanceof Trade t) total += t.quantity(); |
| Text blocks | 15 | Multi-line strings for JSON and SQL in tests |
var | 10 | Local type inference; use where the type is obvious |
| Sequenced collections | 21 | list.getFirst(), list.getLast(), reversed(); used in Prayog's tests |
| Immutable factories | 9 / 10 | List.of, Map.of, List.copyOf |
HexFormat | 17 | The SBE golden-bytes test prints bytes with it |
9. In Prayog
| Where | Feature |
|---|---|
Command, ExchangeEvent | Sealed interfaces of records |
MatchingEngine.apply | Exhaustive pattern switch over Command |
JournalCodec.encode | Exhaustive switch expressions over commands and events. A new event type cannot be journaled by accident without a codec. |
Instrument, SessionSchedule, PipelineConfig | Records with validation in compact constructors |
EngineSetup | Record that defensively copies its list |
Replay.Report, Segments.ScanResult | Small 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.