You have the skeleton — folders, fixtures, empty classes, a pom that builds. Now we fill it in. This walkthrough goes layer by layer: first the model classes (TestData, TestUser, Product), then the abstraction (DataManager and its two subclasses), then the supporting utilities (FileHelper, Logger, DataGenerator), and finally the Main menu loop that ties everything together. Each step explains the design decision and the code. Read with your project open and type as you go — no copy-paste. Compile early, compile often.
Step 1 — The model: TestData, TestUser, Product
Two domain types share the same shape — they have an ID and a one-line summary. That commonality is exactly what an interface is for (Ch 5 lesson 2):
src/main/java/model/TestData.java:
package model;
public interface TestData {
String getId();
String toSummary();
}Two methods, no state. Any model that is a piece of test data implements this. The benefit shows up the moment your DataManager needs to print or filter both users and products through the same code path.
src/main/java/model/TestUser.java:
package model;
public class TestUser implements TestData {
private String id;
private String name;
private String email;
private String role;
private boolean active;
public TestUser() {} // required for Jackson
public TestUser(String id, String name, String email, String role, boolean active) {
this.id = id;
this.name = name;
this.email = email;
this.role = role;
this.active = active;
}
@Override public String getId() { return id; }
@Override public String toSummary() {
return String.format("%s (%s) [%s%s]", name, email, role, active ? "" : ", inactive");
}
public String getName() { return name; }
public String getEmail() { return email; }
public String getRole() { return role; }
public boolean isActive() { return active; }
public void setId(String id) { this.id = id; }
public void setName(String name) { this.name = name; }
public void setEmail(String email) { this.email = email; }
public void setRole(String role) { this.role = role; }
public void setActive(boolean active) { this.active = active; }
}Product.java follows the same shape — id, name, category, priceCents, with a priceDollars() helper that divides by 100. Both classes need the no-arg constructor and the public setters that Jackson uses for deserialisation (Ch 7.4). Encapsulation discipline (Ch 4.3) means private fields and public accessors; we'll add validation in the stretch goals.
@Override on getId and toSummary is the lesson-5 typo guard. If you misspell toSumarry, the compiler tells you.
Step 2 — The abstraction: DataManager
Both managers will share the same lifecycle: load from a file, filter, sort, save, log. That's the template method pattern from Ch 5 lesson 1. The parent owns the lifecycle; subclasses fill in only the parsing step.
src/main/java/manager/DataManager.java:
package manager;
import model.TestData;
import util.Logger;
import java.util.List;
import java.util.Optional;
public abstract class DataManager<T extends TestData> {
protected final String resourceName; // "users", "products"
protected List<T> items;
protected final Logger logger;
protected DataManager(String resourceName, Logger logger) {
this.resourceName = resourceName;
this.logger = logger;
}
public abstract void loadFrom(String path); // subclass parses JSON of the right type
public List<T> listAll() {
logger.log("LIST " + resourceName + " (n=" + items.size() + ")");
return items;
}
public Optional<T> findById(String id) {
return items.stream().filter(t -> t.getId().equals(id)).findFirst();
}
public void replaceAll(List<T> newItems) {
this.items = newItems;
logger.log("REPLACE " + resourceName + " (n=" + newItems.size() + ")");
}
}Three design choices worth naming:
<T extends TestData>— the manager is generic over anyTestDatatype.UserManagerwill extendDataManager<TestUser>. The compiler then knowsitemsis aList<TestUser>inside the user manager, no casting needed. Generics on classes follow the same rules you saw onList<T>andMap<K,V>.abstractloadFrom(...)— the JSON shape differs between users and products, so the parsing step is what each subclass implements. ThelistAll/findById/replaceAllmethods are concrete and shared.protected, notpublic— fields and the constructor are reachable by subclasses but not by random callers (Ch 4.3 access modifiers).
Step 3 — The two managers
Each subclass adds one abstract method implementation and any type-specific filters.
src/main/java/manager/UserManager.java:
package manager;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import exception.TestDataException;
import model.TestUser;
import util.Logger;
import java.io.File;
import java.io.IOException;
import java.util.Comparator;
import java.util.List;
public class UserManager extends DataManager<TestUser> {
private final ObjectMapper mapper = new ObjectMapper();
public UserManager(Logger logger) {
super("users", logger);
}
@Override
public void loadFrom(String path) {
try {
this.items = mapper.readValue(
new File(path),
new TypeReference<List<TestUser>>() {}
);
logger.log("LOAD users from " + path + " (n=" + items.size() + ")");
} catch (IOException e) {
throw new TestDataException("Could not load users from " + path, e);
}
}
public List<TestUser> filterByRole(String role) {
return items.stream()
.filter(u -> role.equals(u.getRole()))
.toList();
}
public List<TestUser> sortedByName() {
return items.stream()
.sorted(Comparator.comparing(TestUser::getName))
.toList();
}
}ProductManager is the parallel: replace TestUser with Product, add filterByCategory(String), and sortedByPrice() using Comparator.comparingInt(Product::getPriceCents). Almost identical shape, which is the point of the abstraction — the only differences are the type and the filters that make sense for that type.
The wrapped exception (Ch 7.2) is critical: a missing users.json should not crash with a Jackson stack trace; it should surface as a TestDataException carrying the file path. Callers higher up can catch the domain exception and print a clean error message to the menu.
Step 4 — DataGenerator: making fresh data
Test runs need fresh users (avoid email collisions, simulate sign-ups). The generator uses lists of first names, last names, domains, and roles, plus a HashSet<String> to dedupe emails (Ch 6.3).
src/main/java/generator/DataGenerator.java:
package generator;
import model.TestUser;
import java.util.*;
import java.util.stream.IntStream;
public class DataGenerator {
private static final List<String> FIRST_NAMES = List.of("Alice", "Bob", "Carol", "Dave", "Eve", "Frank");
private static final List<String> LAST_NAMES = List.of("Khan", "Lopez", "Tanaka", "O'Neill", "Patel", "Smith");
private static final List<String> DOMAINS = List.of("test.com", "qa.io", "example.org");
private static final List<String> ROLES = List.of("admin", "member", "guest");
private final Random random = new Random();
private int counter = 0;
public List<TestUser> generateUsers(int n) {
if (n <= 0) throw new IllegalArgumentException("n must be positive: " + n);
Set<String> seenEmails = new HashSet<>();
return IntStream.range(0, n)
.mapToObj(i -> {
while (true) {
String first = pick(FIRST_NAMES);
String last = pick(LAST_NAMES);
String email = (first + "." + last + "@" + pick(DOMAINS)).toLowerCase();
if (seenEmails.add(email)) {
return new TestUser(
"u-gen-" + (++counter),
first + " " + last,
email,
pick(ROLES),
true
);
}
}
})
.toList();
}
private <T> T pick(List<T> from) {
return from.get(random.nextInt(from.size()));
}
}IntStream.range(0, n).mapToObj(...) (Ch 8.4) is a tidy way to "do this n times and collect the results." seenEmails.add(email) returns false if the email was already generated, so the inner while retries until it produces a unique one. IllegalArgumentException (Ch 7.1) for invalid input keeps the caller honest.
Step 5 — FileHelper and Logger
Two small utilities that encapsulate I/O and logging so the rest of the code can stay focused on test data.
src/main/java/util/FileHelper.java:
package util;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class FileHelper {
private static final ObjectMapper MAPPER = new ObjectMapper();
public static void writeJson(String path, Object value) throws IOException {
ensureParentExists(path);
MAPPER.writerWithDefaultPrettyPrinter().writeValue(new File(path), value);
}
public static void writeCsv(String path, String header, Iterable<String> rows) throws IOException {
ensureParentExists(path);
StringBuilder sb = new StringBuilder();
sb.append(header).append(System.lineSeparator());
for (String row : rows) sb.append(row).append(System.lineSeparator());
Files.writeString(Path.of(path), sb.toString());
}
private static void ensureParentExists(String path) throws IOException {
Path p = Path.of(path).getParent();
if (p != null) Files.createDirectories(p);
}
}writeJson reuses Jackson; writeCsv does string-building with StringBuilder (Ch 8.1). ensureParentExists quietly creates output/ if it's missing — the kind of small detail that turns "this works on my machine" into "this works on a fresh checkout."
src/main/java/util/Logger.java:
package util;
import java.io.IOException;
import java.nio.file.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
public class Logger {
private final Path file;
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public Logger(String file) {
this.file = Path.of(file);
}
public void log(String message) {
String line = "[" + LocalDateTime.now().format(TS) + "] " + message + System.lineSeparator();
try {
Path parent = this.file.getParent();
if (parent != null) Files.createDirectories(parent);
Files.writeString(this.file, line, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (IOException e) {
System.err.println("[logger error] " + e.getMessage());
}
}
}Append mode (StandardOpenOption.APPEND) means each run accumulates. LocalDateTime.now().format(...) produces the same ISO-style timestamps you saw in Ch 8 lesson 2's regex examples. Errors writing the log are downgraded to System.err rather than thrown — a logger that crashes the app is worse than a logger that misses a line.
Step 6 — TestDataException and Main
The custom exception is straight from Ch 7 lesson 2:
package exception;
public class TestDataException extends RuntimeException {
public TestDataException(String msg) { super(msg); }
public TestDataException(String msg, Throwable c) { super(msg, c); }
}The menu loop in Main glues everything together. Keep it simple — a Scanner for input, a switch on the choice, a try/catch around every operation so a single bad input doesn't crash the menu:
import exception.TestDataException;
import manager.UserManager;
import generator.DataGenerator;
import util.FileHelper;
import util.Logger;
import java.util.List;
import java.util.Scanner;
import model.TestUser;
public class Main {
public static void main(String[] args) {
Logger logger = new Logger("output/operations.log");
UserManager users = new UserManager(logger);
try {
users.loadFrom("data/users.json");
} catch (TestDataException e) {
System.err.println("Startup failed: " + e.getMessage());
return;
}
DataGenerator generator = new DataGenerator();
Scanner in = new Scanner(System.in);
while (true) {
System.out.println("\n1. List users 2. Filter by role 3. Generate N 4. Export CSV 5. Quit");
System.out.print("> ");
String choice = in.nextLine().trim();
try {
switch (choice) {
case "1" -> users.listAll().forEach(u -> System.out.println(u.toSummary()));
case "2" -> {
System.out.print("role? ");
users.filterByRole(in.nextLine().trim())
.forEach(u -> System.out.println(u.toSummary()));
}
case "3" -> {
System.out.print("how many? ");
int n = Integer.parseInt(in.nextLine().trim());
List<TestUser> generated = generator.generateUsers(n);
generated.forEach(u -> System.out.println(u.toSummary()));
}
case "4" -> {
List<String> rows = users.listAll().stream()
.map(u -> String.join(",", u.getId(), u.getName(), u.getEmail(), u.getRole()))
.toList();
FileHelper.writeCsv("output/users.csv", "id,name,email,role", rows);
System.out.println("→ output/users.csv");
}
case "5" -> { return; }
default -> System.out.println("Unknown choice: " + choice);
}
} catch (Exception e) {
System.err.println("Error: " + e.getMessage());
}
}
}
}Two things to notice. First, the try/catch (Exception e) around the menu body is the exception to "catch specifically" (Ch 7.1) — at the very top of an interactive loop, you genuinely want to keep running on any failure rather than crash. Second, every menu case is one or two lines because the work is delegated to the right component. The Main is a coordinator, not the implementation.
How it all fits together
- – TestData (interface)
- – TestUser implements TestData
- – Product implements TestData
- – DataManager<T> (abstract)
- – UserManager extends DataManager<TestUser>
- – ProductManager extends DataManager<Product>
- – DataGenerator
- – uses HashSet for unique emails
- – uses Random + Streams
- FileHelper — JSON / CSV writes –
- Logger — timestamped operations.log –
- ensureParentExists –
Five packages, ten or so classes, every chapter of the course doing real work. Read each branch as "concept N from the course is in this file." If a class in the diagram feels unclear, that's the file to revisit lesson by lesson when you pair this with Chapter 9.3.
Project work
Spend 90-150 minutes implementing the walkthrough end-to-end:
- Implement
TestData,TestUser, andProductexactly as shown. Generate getters, setters, and the no-arg constructor with IntelliJ's Generate menu (Alt+Insert / Cmd+N). - Implement
TestDataException. Run a quickmainthat throws one; confirm the message and the cause chain print the way you expect. - Implement
DataManager<T>,UserManager, andProductManager. Confirmmvn packagebuilds. Don't worry about the menu yet — write a temporarymainthat callsusers.loadFrom("data/users.json")and printsusers.listAll()to verify the JSON round-trip works. - Implement
LoggerandFileHelper. Verifyoutput/operations.logaccumulates lines across multiple runs. - Implement
DataGenerator. Run it withn = 100and confirm every email is unique. - Implement the
Mainmenu loop. Walk through every menu item once and confirm errors don't kill the loop. - Open
output/users.csvin your editor — confirm the format is parseable. Openoutput/users.cleaned.json(after a save operation) and confirm it's valid JSON via the JSON formatter on qa.codes. - Add a README.md (genuinely — even a short one) describing how to build (
mvn package) and run (java -jar target/qa-datamanager-1.0.jar). Future-you will thank present-you.
When the menu runs end-to-end and operations.log grows with every action, you're done. Lesson 3 is your self-review and the bridge into the rest of the QA Java toolchain.