What You'll Build
In this exercise you will build a Cricket Statistics Toolkit, a console program that consolidates everything from Module 3: collections to store and organise player data, generics for type-safe containers, exception handling for robust input processing, and string and regex work for parsing and validating text. The toolkit reads player records from text input, validates them, stores them in appropriate collections, and answers queries like top scorers and team averages.
This pulls the module's separate topics into one coherent application where they must cooperate: a malformed input line must be caught and reported rather than crashing the program, records must be parsed with string operations and validated with regex, and the parsed data must be stored in the right generic collection for efficient lookup. By the end you will have a program that ingests messy real-world-style text and turns it into queryable, validated data, the essential shape of countless real applications.
Prerequisites
- Completion of lessons 11 through 14: arrays and the Collections Framework, generics, exception handling, and strings and regex.
- A JDK installed (17 or 21 recommended) with javac and java available; verify with java -version.
- Comfort with List, Map, and Set and choosing among them, plus generic type parameters like List<Player> from lesson 11 and 12.
- Understanding of try-catch and custom exceptions from lesson 13, and of StringBuilder, split, and regex patterns from lesson 14.
- Familiarity with defining classes and methods from Module 2, since the toolkit models players as objects.
Setup & Project Structure
The toolkit is organised into a small set of classes: a Player record holding parsed data, a custom exception for invalid input, a parser that turns a text line into a Player, and a main class that drives ingestion and queries. Separating parsing, the data model, and the query logic keeps each concern testable and mirrors how real data pipelines are structured.
Lay out the files so the responsibilities are clear, then build incrementally: get the data model and parser working first, then ingestion with error handling, then the collection-backed queries. Confirm the project compiles at each stage so problems surface where they are introduced rather than accumulating.
# Project layout for the Cricket Statistics Toolkit.
mkdir cricket-stats-toolkit && cd cricket-stats-toolkit
# Classes (each its own .java file with a matching public class):
# Player.java -- the parsed data model (a record)
# InvalidRecordException.java -- custom checked exception for bad input
# RecordParser.java -- text line -> Player, using regex + split
# StatsToolkit.java -- main: ingest, store in collections, answer queries
# Sample input lines the toolkit will ingest (name,team,runs,dismissals):
# Virat Kohli,IND,12000,250
# Rohit Sharma,IND,9500,210
# BAD LINE WITHOUT COMMAS <- must be rejected, not crash
# Steve Smith,AUS,8500,abc <- non-numeric runs/dismissals, must be rejected
# Compile everything together once the files exist:
# javac *.java then java StatsToolkit
Step 1 — Foundation
Step 1 builds the data model and the custom exception. The Player is naturally immutable, parsed once and never changed, so a record (covered fully later, but used here as a concise immutable carrier) or a small final-field class fits well. The InvalidRecordException is a custom checked exception so that callers are forced to decide how to handle a bad line.
Establishing the data model and the failure type first means the rest of the program has a clear vocabulary: everything either produces a valid Player or raises an InvalidRecordException. This is the same discipline as defining your domain types and error conditions before writing the logic that uses them, so the logic reads in terms of meaningful concepts rather than raw strings and arrays.
// Player.java -- an immutable data model (record gives equals/hashCode/toString free).
public record Player(String name, String team, int runs, int dismissals) {
// Batting average with a not-out guard (dismissals == 0).
public double average() {
return dismissals == 0 ? runs : (double) runs / dismissals;
}
}
// InvalidRecordException.java -- custom CHECKED exception for malformed input.
public class InvalidRecordException extends Exception {
public InvalidRecordException(String message) { super(message); }
}
Step 2 — Core Logic
Step 2 implements the parser, the heart of the toolkit, where strings, regex, and exceptions converge. The parser takes a raw text line, validates its overall shape with a regex, splits it into fields, converts the numeric fields with care, and either returns a Player or throws InvalidRecordException with a clear message explaining what was wrong.
This is the core because it is the boundary where untrusted text becomes trusted data, and getting it right means every downstream query operates on clean, validated records. The key discipline is converting the numeric fields inside a try and translating any NumberFormatException into your domain InvalidRecordException, so callers deal with one meaningful failure type rather than a mix of low-level exceptions, exactly the wrapping-with-cause pattern from lesson 13.
// RecordParser.java -- Step 2: validate with regex, split, convert, wrap failures.
import java.util.regex.Pattern;
public class RecordParser {
// name (letters/spaces), team (3 uppercase), runs, dismissals -- compiled ONCE.
private static final Pattern SHAPE =
Pattern.compile("^[A-Za-z ]+,[A-Z]{3},\\d+,\\d+$");
public static Player parse(String line) throws InvalidRecordException {
if (line == null || !SHAPE.matcher(line.trim()).matches()) {
throw new InvalidRecordException("Bad shape: '" + line + "'");
}
String[] f = line.trim().split(","); // exactly 4 fields by the regex
try {
int runs = Integer.parseInt(f[2]);
int dismissals = Integer.parseInt(f[3]);
return new Player(f[0], f[1], runs, dismissals);
} catch (NumberFormatException e) {
// Wrap the low-level exception in our domain exception (cause preserved).
throw new InvalidRecordException("Non-numeric field in: '" + line + "'");
}
}
}
Step 3 — Integration & Enhancement
Step 3 wires the parser into ingestion and builds the collection-backed queries. The main class reads each input line, calls the parser inside a try-catch so a bad line is reported and skipped rather than aborting the whole run, and stores each valid Player in collections chosen for the queries: a List for all players, and a Map from team to its players for grouped lookups.
This integrates the whole module: generics give type-safe collections (List<Player>, Map<String, List<Player>>), exception handling makes ingestion robust to bad data, and the queries use the collections' strengths, sorting the list for top scorers, grouping by the map for team averages. The enhancement is resilience: the program processes every good record and clearly reports every bad one, the hallmark of production data ingestion.
// StatsToolkit.java -- Step 3: resilient ingestion + collection-backed queries.
import java.util.*;
public class StatsToolkit {
public static void main(String[] args) {
String[] input = {
"Virat Kohli,IND,12000,250", "Rohit Sharma,IND,9500,210",
"BAD LINE", "Steve Smith,AUS,8500,abc", "Steve Smith,AUS,9000,180"
};
List<Player> all = new ArrayList<>();
Map<String, List<Player>> byTeam = new HashMap<>();
// Resilient ingestion: a bad line is reported and SKIPPED, not fatal.
for (String line : input) {
try {
Player p = RecordParser.parse(line);
all.add(p);
byTeam.computeIfAbsent(p.team(), k -> new ArrayList<>()).add(p);
} catch (InvalidRecordException e) {
System.out.println("Skipped -> " + e.getMessage());
}
}
// Query 1: top scorer (sort a copy by runs, descending).
all.stream().max(Comparator.comparingInt(Player::runs))
.ifPresent(p -> System.out.println("Top scorer: " + p.name()));
// Query 2: average per team using the grouped Map.
byTeam.forEach((team, players) -> {
double avg = players.stream().mapToDouble(Player::average).average().orElse(0);
System.out.printf("%s avg: %.1f%n", team, avg);
});
}
}
Step 4 — Testing & Verification
Verify the toolkit handles both clean and dirty input correctly. Confirm that valid lines become Player records, that the malformed line and the non-numeric line are each skipped with a clear message rather than crashing the program, and that the queries return correct results computed only from the valid records. Test the not-out edge case in the average, and confirm the team grouping places players under the right team. Compile all files together and run, checking the output against the expectations below.
# Compile all classes and run the toolkit.
javac *.java && java StatsToolkit
# Expected behaviour:
# Skipped -> Bad shape: 'BAD LINE' (regex rejects it, no crash)
# Skipped -> Non-numeric field in: 'Steve Smith,AUS,8500,abc' (parse guard)
# Top scorer: Virat Kohli (12000 is the max)
# IND avg: ... AUS avg: ... (computed from VALID records only)
#
# Verification checklist:
# [ ] The two bad lines are skipped with messages; the program still completes.
# [ ] Valid records (including the second Steve Smith) are counted in queries.
# [ ] Team averages use only successfully-parsed players.
# [ ] A not-out player (dismissals == 0) does not cause a divide-by-zero.
#
# If a bad line ever crashes the run, the parse() call is not wrapped in try-catch.
Warning: A subtle bug in data ingestion is letting one bad record abort the entire batch. If the try-catch wraps the whole loop instead of the per-line parse call, the first malformed line stops all further processing, you lose every valid record after it. Always scope the try-catch to the smallest unit that can fail independently, here, a single line, so one bad record is isolated and the rest still process. Also ensure your regex anchors with ^ and $; without them, a partial match could let malformed lines slip through validation.
Extension Challenge: Add a query that returns the top N batters across all teams using a sorted collection, and a Set to detect and report duplicate player entries (same name and team appearing twice). Then make ingestion read from an actual file using try-with-resources from lesson 13, so the toolkit processes a real data file and closes it safely even if parsing throws. As a final touch, collect all rejected lines into a report rather than only printing them.
- The toolkit combines all of Module 3: collections for storage, generics for type safety, exception handling for robust ingestion, and strings/regex for parsing and validation.
- An immutable Player model and a custom checked InvalidRecordException give the program a clear vocabulary: every input yields a valid Player or a typed rejection.
- The parser is the single validation checkpoint: it shape-checks with a compiled regex, splits fields, converts numbers, and wraps low-level failures in the domain exception.
- Scoping the try-catch to a single line makes ingestion resilient, one bad record is reported and skipped while all valid records still process.
- Queries use well-chosen generic collections: a List<Player> for overall ranking and a Map<String, List<Player>> for fast per-team grouping.
- Verify both clean and dirty input: valid records become Players, malformed and non-numeric lines are skipped with clear messages, and queries compute only from valid data.