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: true

Notice 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

Pro Tips

📋 Quick Reference

ConceptSyntaxNote
immutable carrierrecord Product(...)Free equals/hashCode
validatecompact constructorRuns for every build
readable constructionbuilder().x().build()Defaults + chaining
dedupnew HashSet<>(list)Needs correct hashCode
sum per groupgroupingBy(k, summingInt(f))Totals
count per groupgroupingBy(k, counting())Map<K, Long>
deterministic orderTreeMap::new supplierSorted keys
max per groupcollectingAndThen(maxBy, fn)Optional then map
scale I/OnewVirtualThreadPerTaskExecutor()Many blocking calls
decoupleconstructor injectionTestable
defensive copyList.copyOf(x)For mutable components

📚 Resources / Keep Learning

🎉 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

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.

Related lessons