Checkpoint: Practical Java
Reviewed & published by Brayan K
This checkpoint ties together the practical-Java track — collectors, virtual threads, the HTTP client, equals/hashCode, immutability, the builder pattern, dependency injection, and serialization — into one combined build challenge.
Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What This Checkpoint Covers
A recap of eight lessons, a multi-step build challenge (an immutable record assembled by a builder, grouped with collectors, with a correct equals/hashCode), a written checkpoint quiz, and a 10-question timed quiz. If anything here feels shaky, revisit that lesson before moving to the final project.
The Big Idea — Assembling the Workshop
💡 Analogy: Up to now you've been collecting individual tools — a saw here, a clamp there. A checkpoint is where you build something using several tools at once. The challenge below makes one small program that needs a record (the material), a builder (the jig that assembles it), equals/hashCode (so duplicates don't sneak in), and collectors (to measure and sort the finished pieces). That's how real code feels — patterns combine.
🧩 The Build Challenge
Complete the starter below: finish the record's validation, the nested Builder, and the four stream pipelines. Then compare against the full compiled solution. The expected output is shown in the comments.
import java.util.*;
import java.util.stream.*;
public class Main {
// BUILD CHALLENGE — put it all together:
// * an IMMUTABLE record Product(name, category, priceCents)
// * built through a static nested BUILDER (with defaults + validation)
// * with correct equals/hashCode (FREE from the record)
// * then GROUPED with Collectors.
//
// Fill in the Builder and the four stream pipelines below.
record Product(String name, String category, int priceCents) {
Product {
// TODO: validate — name non-blank, priceCents >= 0
}
static Builder builder(String name) { return new Builder(name); }
static final class Builder {
private final String name;
private String category = "misc"; // default
private int priceCents = 0; // default
Builder(String name) { this.name = name; }
// TODO: fluent category(String) and priceCents(int), each return this
// TODO: Product build() -> new Product(name, category, priceCents)
}
}
public static void main(String[] args) {
List<Product> products = List.of(
Product.builder("Apple").category("food").priceCents(50).build(),
Product.builder("Banana").category("food").priceCents(30).build(),
Product.builder("Notebook").category("office").priceCents(250).build(),
Product.builder("Pen").category("office").priceCents(120).build(),
Product.builder("Milk").category("food").priceCents(99).build());
// 1. Dedup via the record's equals/hashCode (add a duplicate Apple first).
// 2. Total priceCents per category (TreeMap + summingInt).
// 3. Count per category (TreeMap + counting).
// 4. Most expensive product NAME per category
// (collectingAndThen + maxBy + map to name).
//
// Expected output (see the solution):
// distinct products: 5
// total by category: {food=179, office=370}
// count by category: {food=3, office=2}
// dearest by category: {food=Milk, office=Notebook}
}
}✅ Full Solution & Real Output
A record built by a builder, deduplicated by its generated equals/hashCode, and grouped four ways with Collectors:
import java.util.*;
import java.util.stream.*;
public class Main {
// An immutable record (free equals/hashCode/toString), built via a Builder,
// then grouped with Collectors.
record Product(String name, String category, int priceCents) {
// compact constructor validates
Product {
if (name == null || name.isBlank())
throw new IllegalArgumentException("name required");
if (priceCents < 0)
throw new IllegalArgumentException("price must be >= 0");
}
static Builder builder(String name) { return new Builder(name); }
static final class Builder {
private final String name;
private String category = "misc";
private int priceCents = 0;
Builder(String name) { this.name = name; }
Builder category(String c) { this.category = c; return this; }
Builder priceCents(int p) { this.priceCents = p; return this; }
Product build() { return new Product(name, category, priceCents); }
}
}
public static void main(String[] args) {
List<Product> products = List.of(
Product.builder("Apple").category("food").priceCents(50).build(),
Product.builder("Banana").category("food").priceCents(30).build(),
Product.builder("Notebook").category("office").priceCents(250).build(),
Product.builder("Pen").category("office").priceCents(120).build(),
Product.builder("Milk").category("food").priceCents(99).build());
// 1. equals/hashCode from the record: dedup works.
Set<Product> set = new HashSet<>(products);
set.add(Product.builder("Apple").category("food").priceCents(50).build()); // duplicate
System.out.println("distinct products: " + set.size());
// 2. Group total price (cents) by category, sorted via TreeMap.
Map<String, Integer> totalByCat = products.stream()
.collect(Collectors.groupingBy(
Product::category, TreeMap::new,
Collectors.summingInt(Product::priceCents)));
System.out.println("total by category: " + totalByCat);
// 3. Count per category.
Map<String, Long> countByCat = products.stream()
.collect(Collectors.groupingBy(Product::category, TreeMap::new, Collectors.counting()));
System.out.println("count by category: " + countByCat);
// 4. Most expensive product per category (name only).
Map<String, String> dearest = products.stream()
.collect(Collectors.groupingBy(
Product::category, TreeMap::new,
Collectors.collectingAndThen(
Collectors.maxBy(Comparator.comparingInt(Product::priceCents)),
opt -> opt.map(Product::name).orElse("none"))));
System.out.println("dearest by category: " + dearest);
// 5. Confirm equals/hashCode contract.
Product a = Product.builder("Pen").category("office").priceCents(120).build();
Product b = Product.builder("Pen").category("office").priceCents(120).build();
System.out.println("a.equals(b): " + a.equals(b));
System.out.println("hashCodes match: " + (a.hashCode() == b.hashCode()));
}
}Real output (compiled and run with javac 21):
distinct products: 5
total by category: {food=179, office=370}
count by category: {food=3, office=2}
dearest by category: {food=Milk, office=Notebook}
a.equals(b): true
hashCodes match: trueNotice how every concept appears: the record gives free equals/hashCode (so the duplicate Apple collapses, leaving 5 distinct), the builder assembles each product with defaults and validation, and collectors compute totals, counts, and the dearest item per category — all in deterministic TreeMap order.
📝 Checkpoint Quiz
Answer each in your head, then reveal.
Answer: the record auto-generates equals/hashCode from all components, so two Apples with the same name, category and price are equal and hash to the same bucket — the HashSet treats them as one.
Answer: sorted, deterministic key order. Without it you'd get a HashMap whose printed order is undefined.
Answer: it runs for every way the record is created (including via the builder's build()), so an invalid Product can never exist.
Answer: submit each HTTP send to Executors.newVirtualThreadPerTaskExecutor() — virtual threads let thousands of blocking calls run concurrently without exhausting OS threads.
Answer: defensively copy it in the compact constructor with tags = List.copyOf(tags), so neither the caller's list nor the accessor can mutate the record.
Answer: maxBy reduces each group to an Optional<Product>; the finishing function opt.map(Product::name).orElse("none") turns it into just the name.
Common Errors
- ❌ Forgetting the TreeMap supplier. Output order becomes unpredictable. Fix: pass TreeMap::new to groupingBy.
- ❌ Validating in a builder setter, not build()/constructor. Cross-field checks miss. Fix: validate where the whole object exists.
- ❌ Returning a record's mutable component directly. It leaks. Fix: defensively copy in the compact constructor.
- ❌ Hand-writing equals/hashCode when a record would do. Extra bug surface. Fix: use the record's generated pair.
- ❌ Treating an HTTP 404 as an exception. It's a status. Fix: check statusCode().
Pro Tips
- 💡 Records + builders + collectors are the everyday trio for data-shaped code — immutable carriers, readable construction, expressive aggregation.
- 💡 Static-import Collectors.* to keep grouping pipelines readable.
- 💡 Keep output deterministic (TreeMap, sorting) so your programs are testable and your demos reproducible.
- 💡 Inject collaborators rather than newing them, so this whole pipeline could be unit-tested with fakes.
📋 Quick Reference
| Concept | Syntax | Note |
|---|---|---|
| immutable carrier | record Product(...) | Free equals/hashCode |
| validate | compact constructor | Runs for every build |
| readable construction | builder().x().build() | Defaults + chaining |
| dedup | new HashSet<>(list) | Needs correct hashCode |
| sum per group | groupingBy(k, summingInt(f)) | Totals |
| count per group | groupingBy(k, counting()) | Map<K, Long> |
| deterministic order | TreeMap::new supplier | Sorted keys |
| max per group | collectingAndThen(maxBy, fn) | Optional then map |
| scale I/O | newVirtualThreadPerTaskExecutor() | Many blocking calls |
| decouple | constructor injection | Testable |
| defensive copy | List.copyOf(x) | For mutable components |
📚 Resources / Keep Learning
- 📘 Collectors — Java 21 API documentation (docs.oracle.com)
- 📘 Record Classes — Java 21 Language Guide (docs.oracle.com)
- 📗 dev.java — official tutorials and guides
🎉 Checkpoint Complete!
You combined records, builders, equals/hashCode and collectors into one working program, and recapped the whole practical track — collectors, virtual threads, the HTTP client, immutability, dependency injection, and serialization. These are the patterns you'll reach for constantly in real Java.
Practice quiz
Which collector groups elements and totals a numeric field per group?
- toMap(k, f)
- partitioningBy(f)
- groupingBy(k, summingInt(f))
- joining(f)
Answer: groupingBy(k, summingInt(f)). groupingBy with a summingInt downstream produces a Map of key to the integer total per group.
Why do records pair so well with hash-based collections?
- They auto-generate consistent equals and hashCode
- They are static
- They are faster to allocate
- They skip validation
Answer: They auto-generate consistent equals and hashCode. A record's generated equals/hashCode satisfy the contract, so records work correctly as Set elements and Map keys.
Where should a Builder validate the object it constructs?
- In each setter
- In toString
- In main
- In build() (or the product's constructor)
Answer: In build() (or the product's constructor). build() is the single point where the whole object exists; for a record the compact constructor also validates.
What scales virtual threads for I/O-bound work?
- They use more CPU
- Blocked threads unmount and free their carrier
- They never block
- They avoid the heap
Answer: Blocked threads unmount and free their carrier. A blocked virtual thread is unmounted so its carrier serves others — millions of mostly-waiting tasks become feasible.
What must you check after HttpClient.send for a 404?
- response.statusCode()
- Catch an exception
- response.error()
- Nothing — it throws
Answer: response.statusCode(). HTTP error statuses are returned, not thrown; inspect statusCode() yourself.
Defensive copying in an immutable class prevents...
- faster code
- compilation
- callers mutating your internal collection
- serialization
Answer: callers mutating your internal collection. Copying mutable inputs/outputs (e.g. List.copyOf) stops outside code from changing your state.
Constructor injection improves testability because...
- it removes interfaces
- you can inject fakes instead of real collaborators
- it runs faster
- it avoids constructors
Answer: you can inject fakes instead of real collaborators. Depending on an injected interface lets tests pass a fake and assert on interactions with no real I/O.
Which field keyword excludes a field from serialization?
- volatile
- static
- final
- transient
Answer: transient. transient fields are skipped and return to defaults after deserialization.
What does Collectors.collectingAndThen do?
- Sorts the stream
- Collects then applies a finishing function
- Counts elements
- Splits the stream
Answer: Collects then applies a finishing function. It runs a collector and post-processes the result — e.g. mapping a maxBy Optional to a name, or making a list unmodifiable.
If a.equals(b) is true, the contract requires...
- a == b
- a.toString() == b.toString()
- a.hashCode() == b.hashCode()
- nothing
Answer: a.hashCode() == b.hashCode(). Equal objects must have equal hash codes, or hash collections break.
Continue this course
- Previous: Serialization
- Next: Introduction to Spring & Spring Boot — Why Spring exists and how Spring Boot makes production Java fast to build
- Quick reference: Java cheat sheet
Frequently asked questions
How do records, builders and collectors fit together?
They form a clean pipeline for working with data. A record gives you an immutable carrier with correct equals, hashCode and toString for free. A builder constructs those records readably when there are several optional fields, validating in build(). Collectors then aggregate streams of those records — grouping by a field, summing prices, counting, or finding the maximum per group. In this checkpoint you combine all three: build immutable products, store them safely in a Set thanks to the record's hashCode, and group them with Collectors.
Why use a TreeMap supplier in groupingBy?
By default groupingBy collects into a HashMap, whose iteration order is undefined — so printing it gives unpredictable ordering. Passing a map supplier such as TreeMap::new as the second argument makes the result a TreeMap, which keeps keys in sorted order. That is exactly what we want for deterministic, repeatable output: {food=..., office=...} prints the same way every run, which also makes the program's behaviour easy to test.
When should I reach for a builder instead of the record's constructor?
Use the record's canonical constructor directly when it has only a few fields and you always provide them all. Add a builder when there are several optional fields with defaults, or when positional arguments would become hard to read (the telescoping-constructor problem). In this checkpoint, Product has a default category and price, so the builder lets you write Product.builder("Pen").priceCents(120).build() and omit what you don't need — clearer than remembering argument order.
How does collectingAndThen find the dearest product per group?
Inside groupingBy, the downstream Collectors.maxBy(comparator) reduces each category's products to an Optional<Product> (the most expensive one). collectingAndThen then applies a finishing function to that Optional — here opt.map(Product::name).orElse("none") — turning it into just the product's name. The result is a Map<String, String> of category to the name of its priciest item, computed entirely within the stream.
What have I covered by completing this practical track?
A lot of professional Java: Stream Collectors (grouping, downstream reductions, teeing, collectingAndThen), virtual threads for scalable I/O, the modern HTTP client, the equals/hashCode contract, immutability with defensive copying, the Builder pattern, dependency injection for decoupled and testable code, and serialization with its security caveats. Together these are the patterns and APIs that show up constantly in real-world codebases — the toolkit that takes you from writing Java that works to writing Java that is clean, safe, and maintainable.