100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Java Mastery
50 minintermediate

Modern Java Features Practice: Match Event Processor

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.

Analogy🏏Cricket
🏏 Think of it like cricket: after drilling individual skills in the nets, batting, bowling, fielding, a player finally puts them together in a full practice match where the skills must work in combination under live conditions. Just as the practice match reveals whether the separately-drilled skills actually cohere into a performance, these two programs reveal whether your separately-learned constructs, types, operators, branches, loops, actually cohere into working software. Just as the match is where a player first feels how the pieces fit, the exercise is where you first feel how the language fits together. Just as a coach moves players from drills to matches once the basics are sound, the course moves you from concepts to construction once the fundamentals are in place. The insight is that fundamentals only become capability when you assemble them into something complete that runs.

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.

Analogy🏏Cricket
🏏 Think of it like cricket: setting up these two programs is like preparing two separate match fixtures, each with its own scorecard and playing eleven. Just as each fixture is self-contained — its own teams, its own result, no shared state that could tangle the outcomes — each program lives in its own clearly-named Java file with a single public class and a main method, the simplest structure for a console app. Just as the Laws require a clear team sheet before play begins so everyone knows who is on the field, the one-public-class-per-file rule keeps each program's entry point unambiguous. And just as scheduling two independent matches means a problem in one never delays the other, keeping the calculator and the number game in separate files keeps the exercises independent. The payoff: clean, isolated structure that lets you build, run, and reason about each program without the other getting in the way.

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.

bash
# 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.

Analogy🏏Cricket
🏏 Think of it like cricket: before drilling specific shots, a batter establishes their basic routine at the crease, take guard, face the ball, decide, repeat, the rhythm that frames every delivery. Just as that repeating routine frames each shot before the shot itself is chosen, the menu loop frames each operation before the operation is computed. Just as the batter always takes guard at least once before deciding whether to continue the innings, the do-while shows the menu at least once before checking whether to quit. Just as a sound routine lets the batter focus on each delivery cleanly, a sound interaction loop lets you focus on each operation cleanly. The insight is that establishing the repeating interaction structure first gives every later piece a clean frame to slot into.
java
// 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.

Analogy🏏Cricket
🏏 Think of it like cricket: the routine at the crease means nothing if the actual shots are mistimed, the core skill is making clean contact, and a batter who plays down the wrong line gets out no matter how good their routine. Just as clean contact is where runs are actually scored, correct arithmetic is where the calculator actually works. Just as misjudging the line of the ball, the equivalent of integer division truncating, produces a wrong result despite a good setup, forgetting to cast to double produces a wrong number despite a good menu. Just as a batter carefully watches for the ball that could dismiss them, you carefully guard against the zero denominator that would crash the calculation. The insight is that the core computation must be exactly right, because no amount of surrounding structure rescues a wrong result.
java
// 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.

Analogy🏏Cricket
🏏 Think of it like cricket: a bowler searching for the right length to dismiss a batter does not know in advance how many balls it will take, they bowl, observe whether the ball was too short or too full, adjust, and repeat until they get it right. Just as the bowler loops an unknown number of times, adjusting from feedback each ball, the guessing game loops until correct, adjusting from higher/lower feedback each round. Just as 'too short, pitch it up' guides the bowler's next delivery, 'too low, guess higher' guides the player's next guess. Just as the bowler is guaranteed to eventually find the length, the game must guarantee the loop can end. The insight is that feedback-driven repetition of unknown length, the while loop's natural shape, models exactly this kind of iterative homing-in.
java
// 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.

Analogy🏏Cricket
🏏 Think of it like cricket: verifying your programs is like checking a scorecard against the video before it's official. Just as a scorer confirms a strike rate is a true decimal — 150.00, not a truncated 150 — you check each calculator operation returns a real decimal result, not an integer that silently drops the fraction. Just as the Laws guard against computing an economy rate for a bowler who hasn't bowled a single over, your zero-denominator guards prevent a divide-by-zero crash. Just as you'd test average, strike rate, and run rate with a known innings you can total by hand, you verify with values you can check yourself. And just as an umpire confirms a decision at the exact edge — level scores, an exact target — you test the higher/lower game's boundary where the guess equals the target. The payoff: normal and edge cases both proven correct, so the result stands up to scrutiny.
bash
# 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.
Lesson 30 of 35
0% complete