What You'll Build
In this exercise you will build a Match Event Processor: a program that reads a stream of cricket match events from a text source, models each event with modern Java types, and processes them into a live scorecard and summary. It consolidates Module 6: NIO.2 file reading, records to model events immutably, a sealed interface to define the closed set of event kinds, and pattern-matching switches to handle each kind exhaustively, all while writing allocation-friendly code that respects the JVM's memory behaviour.
Rather than practising each modern feature in isolation, you will combine them as they are meant to be used together: events are read from a file with NIO.2, parsed into a sealed hierarchy of record types, and folded into a scorecard by a pattern-matching switch that the compiler verifies handles every event kind. By the end you will have a small but complete data-processing pipeline written in idiomatic modern Java, the style that defines current best practice, consolidating Module 6 before the final module on tooling and production concerns.
Prerequisites
- Completion of lessons 26 through 29: I/O and NIO.2, the module system, records and sealed classes and pattern matching, and JVM internals.
- A JDK installed, version 21 recommended so records, sealed types, and record patterns in switch are all available; verify with java -version.
- Comfort reading files with Files.lines or Files.readAllLines and an explicit charset from lesson 26.
- Understanding of records, sealed interfaces, and pattern-matching switches with record deconstruction and exhaustiveness from lesson 28.
- Awareness from lesson 29 that short-lived objects are cheap, so a clean allocation-per-event design is fine.
Setup & Project Structure
The processor is organised around a sealed event hierarchy, a parser that turns text lines into events, a scorecard that accumulates state, and a driver that reads the file and folds events into the scorecard. Modelling the events as a sealed set of records up front is the key design move: it makes the closed set of possibilities explicit and lets the compiler enforce that processing handles them all.
Lay the pieces out so the data flow is clear, file lines in, parsed into events, folded into a scorecard, summary out, and build incrementally: define the event model first, then the parser, then the pattern-matching processing, then file reading. Use NIO.2 for the input and process events one line at a time so the design naturally scales to large files, with each event a short-lived object the JVM reclaims cheaply.
# Project layout for the Match Event Processor.
mkdir match-event-processor && cd match-event-processor
# Pieces (modern-Java idioms throughout):
# MatchEvent.java -- a SEALED interface + RECORD subtypes (the closed event set)
# EventParser.java -- text line -> MatchEvent (returns Optional for bad lines)
# Scorecard.java -- accumulates runs/wickets/extras as events are applied
# Processor.java -- main: read with NIO.2, parse, fold via a pattern-matching switch
# Sample input (events.txt), one event per line:
# RUN,4
# WICKET,Kohli,bowled
# EXTRA,wide,1
# RUN,1
# GARBLED LINE <- rejected gracefully, not fatal
# Compile and run (JDK 21):
# javac *.java then java Processor
Step 1 — Foundation
Step 1 defines the event model: a sealed interface MatchEvent permitting a closed set of record subtypes, one per event kind. Each event is an immutable record carrying exactly the data that kind needs, a Run carries its run count, a Wicket carries the batter and dismissal mode, an Extra carries its type and count, and the sealed interface declares that these are the only possibilities.
Establishing the sealed record hierarchy first is the foundation everything else builds on, because it makes the closed set of events explicit and gives the later pattern-matching switch its exhaustiveness guarantee: the compiler will know there are exactly these event kinds. This is the modern equivalent of defining your domain types before the logic, except that sealing turns 'these are the kinds' from a comment into a compiler-enforced fact, which directly enables safe, complete processing downstream.
// MatchEvent.java -- Step 1: a sealed set of record event types (the closed possibilities).
public sealed interface MatchEvent
permits MatchEvent.Run, MatchEvent.Wicket, MatchEvent.Extra {
// Each event kind is an immutable RECORD carrying exactly its own data.
record Run(int count) implements MatchEvent {
public Run {
if (count < 0 || count > 6) throw new IllegalArgumentException("runs 0-6");
}
}
record Wicket(String batter, String mode) implements MatchEvent {}
record Extra(String type, int count) implements MatchEvent {}
}
Step 2 — Core Logic
Step 2 builds the parser and the scorecard. The parser turns a text line into a MatchEvent, returning Optional<MatchEvent> so a malformed line yields an empty Optional rather than crashing, the null-safety idiom from earlier modules applied here. The scorecard holds the accumulating state (total runs, wickets, extras) and exposes an apply method, but the heart of the logic is the pattern-matching switch that processes each event by kind.
This is the core because the pattern-matching switch over the sealed MatchEvent is where modern Java shines: each case deconstructs a record to pull out exactly that event's data and updates the scorecard accordingly, and because MatchEvent is sealed and the switch covers every permitted kind with no default, the compiler verifies completeness. If you later add a new event type to the sealed interface, this switch will fail to compile until you handle it, turning 'handle every event' into a guarantee rather than a hope, exactly the safety the sealed-plus-pattern-matching combination provides.
// EventParser.java + Scorecard.java -- Step 2: parse to Optional, fold via pattern matching.
import java.util.*;
class EventParser {
// Bad lines -> Optional.empty() (no crash); good lines -> a typed event.
static Optional<MatchEvent> parse(String line) {
String[] f = line.trim().split(",");
try {
return switch (f[0]) {
case "RUN" -> Optional.of(new MatchEvent.Run(Integer.parseInt(f[1])));
case "WICKET" -> Optional.of(new MatchEvent.Wicket(f[1], f[2]));
case "EXTRA" -> Optional.of(new MatchEvent.Extra(f[1], Integer.parseInt(f[2])));
default -> Optional.empty(); // unknown tag -> rejected
};
} catch (RuntimeException e) {
return Optional.empty(); // malformed fields -> rejected
}
}
}
class Scorecard {
int runs, wickets, extras;
// The HEART: a pattern-matching switch over the SEALED type -> exhaustive, no default.
void apply(MatchEvent e) {
switch (e) {
case MatchEvent.Run(int n) -> runs += n;
case MatchEvent.Wicket(String b, String m) -> { wickets++; System.out.println(b + " out: " + m); }
case MatchEvent.Extra(String t, int n) -> { extras += n; runs += n; }
// No default: adding a new MatchEvent subtype makes THIS fail to compile.
}
}
@Override public String toString() {
return runs + "/" + wickets + " (extras " + extras + ")";
}
}
Step 3 — Integration & Enhancement
Step 3 wires the pieces into the driver: read the event file with NIO.2, parse each line to an Optional<MatchEvent>, and fold the valid events into a Scorecard. Using Files.lines streams the file lazily so the processor scales to large feeds, and the Streams API filters out the empty Optionals from bad lines before applying the rest, combining the modern I/O, Optional, and stream idioms with the sealed-event processing.
The integration shows the full modern pipeline end to end: NIO.2 supplies the data, records and the sealed type model it, Optional handles parse failures, and the pattern-matching switch processes each event exhaustively, with every malformed line skipped gracefully. The enhancement is resilience and scale, the processor tolerates bad input and streams rather than loads the file, while the compiler guarantees every event kind is handled, the hallmark of robust, idiomatic modern Java data processing.
// Processor.java -- Step 3: NIO.2 streaming + Optional filtering + sealed-event folding.
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.stream.Stream;
public class Processor {
public static void main(String[] args) throws Exception {
Path file = Path.of("events.txt");
// Sample feed including a garbled line that must be tolerated.
Files.writeString(file,
"RUN,4\nWICKET,Kohli,bowled\nEXTRA,wide,1\nRUN,1\nGARBLED LINE\nRUN,6\n",
StandardCharsets.UTF_8);
Scorecard card = new Scorecard();
// Stream the file lazily; parse each line; keep only valid events; apply them.
try (Stream<String> lines = Files.lines(file, StandardCharsets.UTF_8)) {
lines.map(EventParser::parse) // Stream<Optional<MatchEvent>>
.flatMap(Optional::stream) // drop empties -> Stream<MatchEvent>
.forEach(card::apply); // fold each event via the pattern switch
} // file handle closed by try-with-resources
System.out.println("Final: " + card); // e.g. 12/1 (extras 1)
Files.deleteIfExists(file);
}
}
Step 4 — Testing & Verification
Verify the processor on both clean and messy input. Confirm that valid events update the scorecard correctly (runs accumulate, wickets count, extras add to both their own tally and the total), that the garbled line is skipped without crashing, and that the final scorecard matches a hand-computed expectation from the sample feed. Most importantly, test the exhaustiveness guarantee: temporarily add a new permitted subtype to MatchEvent and confirm the apply switch fails to compile until you handle it, which is the safety the sealed-plus-pattern-matching design provides.
# Compile (JDK 21) and run; verify correctness, resilience, and exhaustiveness.
javac *.java && java Processor
# Expected (from the sample feed RUN,4 / WICKET / EXTRA,wide,1 / RUN,1 / garbled / RUN,6):
# Kohli out: bowled
# Final: 12/1 (extras 1) <- 4 + 1(extra) + 1 + 6 = 12 runs, 1 wicket, 1 extra
#
# Verification checklist:
# [ ] The garbled line is skipped; the program still completes and scores the rest.
# [ ] Runs, wickets, and extras match a hand-computed total from the feed.
# [ ] EXHAUSTIVENESS TEST: add 'record Penalty(int n) implements MatchEvent' to the
# sealed interface (and its permits clause). Scorecard.apply() should now FAIL
# to compile until you add a 'case MatchEvent.Penalty(int n) -> ...' branch.
# That compile error is the feature working: it forces you to handle the new kind.
#
# If the switch compiles WITHOUT handling a newly-added subtype, you accidentally left
# a 'default' branch in apply() -- remove it so the compiler enforces exhaustiveness.
Warning: The whole point of the sealed-plus-exhaustive-switch design is defeated if you add a default case to the apply switch. With a default, the switch compiles even when it does not handle a newly-added event kind, so a new MatchEvent subtype silently falls into the catch-all and is mishandled with no warning. Omit the default so the compiler fails the build on every switch that becomes incomplete, pointing you straight at the code to update. Also remember Files.lines holds an open file handle, so keep it in try-with-resources to avoid leaking a descriptor.
Extension Challenge: Add more event kinds to the sealed interface, an Over boundary marker, a NoBall with a free-hit flag, and let the compiler guide you to every switch that must handle them. Then compute richer statistics with the Streams API: the run rate per over, the most expensive over, and a breakdown of extras by type using groupingBy. For a stretch, read the feed concurrently using virtual threads from Module 5 (one parsing task per chunk) while keeping the scorecard updates safe, and write the final summary back to a file with NIO.2, demonstrating that all the modules now compose into one program.
- The processor combines all of Module 6: NIO.2 file reading, records for immutable events, a sealed interface for the closed event set, and an exhaustive pattern-matching switch.
- Defining the events as a sealed hierarchy of records first makes the closed set explicit and gives the processing switch its compiler-enforced exhaustiveness.
- The parser returns Optional<MatchEvent> so malformed lines yield empties rather than crashing, and the Streams API filters them out with flatMap(Optional::stream).
- The apply method is a pattern-matching switch with no default that deconstructs each record and updates the scorecard, verified to handle every permitted event kind.
- Files.lines streams the feed lazily inside try-with-resources, so the processor scales to large files and never leaks the file handle.
- The exhaustiveness test, adding a new subtype and watching the switch fail to compile, demonstrates the core safety guarantee; never add a default that would hide unhandled kinds.