Projects — Phase 04

You have read about generics, streams, Optional, and CompletableFuture. Reading does not transfer. These three projects make the ideas muscle memory: rewrite a real analyzer with streams and prove it’s not slower, build a small type-safe DSL that forces you to use bounded generics for real, and drill 30 imperative→stream conversions until the idiomatic form comes without thinking. Ship all three to a public repo — they double as portfolio pieces.

Project A · Log-File Analyzer, Streams Edition (with Benchmarks)

You wrote a log-file analyzer in Phase 01 as imperative Java (BufferedReader + for loops + HashMap). Now rewrite it end-to-end with Files.lines, Stream, and Collectorsand JMH-benchmark both versions so you know whether streams cost you anything on real data.

Scope

Take an access-log file (Apache/Nginx combined format is easiest — public sample datasets on Kaggle work; NASA HTTP log is a canonical choice at ~1.9M lines). Produce these outputs:

  1. Requests per status-code bucket (2xx / 3xx / 4xx / 5xx).

  2. Top 10 URLs by hit count.

  3. Top 10 client IPs by bytes served.

  4. Requests per hour-of-day histogram.

  5. p50 / p95 / p99 response size across all successful requests.

Required techniques

  • Parse lines into a record LogEntry(String ip, Instant ts, String method, String url, int status, long bytes) — records for value objects, per Phase 03.

  • Use Files.lines(path) inside a try-with-resources so the file handle closes.

  • Use Collectors.groupingBy, counting, summingLong, partitioningBy, and teeing at least once each.

  • Compute percentiles with mapToLongIntSummaryStatistics for the mean, and a sorted long[] for p95/p99.

  • Return an immutable AnalysisReport record aggregating all five metrics.

Benchmarking

Add a benchmark/ module with JMH (org.openjdk.jmh:jmh-core:1.37). Compare:

  • Imperative for-loop version

  • Sequential stream version

  • Parallel stream version (.parallel())

Report throughput (ops/s) and allocation rate (JMH -prof gc). Write a BENCHMARKS.md with a small table and a one-paragraph honest verdict — most students discover that:

  • Sequential streams are within 5-15% of imperative, sometimes faster (compiler hoisting).

  • Parallel streams win only past ~500k lines on 8+ cores, and lose otherwise (the DZone/Langer result).

  • Allocation rate is higher for streams (boxed objects) but rarely matters.

Definition of done

  • Two implementations, one report record, one JMH module.

  • Repo README with the results table and honest verdict.

  • Unit tests confirming both implementations produce identical AnalysisReport for a fixed input.

  • No .parallel() in your code without a justifying comment referencing the JMH numbers.

Project B · Type-Safe Validation DSL

Build a small validation library using generics + functional interfaces. The API should read like English at the call site and refuse invalid types at compile time — not at test time.

Target API

Validator<User> userValidator = Validator.<User>of()
    .field(User::name,  notBlank().and(maxLength(80)))
    .field(User::email, matches(EMAIL_REGEX))
    .field(User::age,   between(0, 150));

ValidationResult result = userValidator.validate(user);
if (result.hasErrors()) return badRequest(result.errors());

Required techniques

  • Validator<T> is a generic class holding a list of field-level rules.

  • field(Function<T, F> getter, Rule<F> rule) uses bounded generics so you cannot pass a Rule<Integer> for a String field — the compiler catches the mismatch.

  • Rule<F> is a functional interface Function<F, Optional<String>> — returns an error message or empty for OK.

  • Built-in combinators: and, or, negate; use Predicate composition.

  • Built-in rules: notNull, notBlank, maxLength(int), minLength(int), between(min, max), matches(Pattern), nonEmpty() for collections.

  • ValidationResult is a record: record ValidationResult(Map<String, List<String>> errors) { boolean hasErrors() { ... } }.

  • Optional stretch: a custom Collector<Rule<F>, ?, Rule<F>> that composes rules via and.

Constraints

  • No reflection. This is a generics exercise — everything must be type-safe at compile time.

  • No dependency on Jakarta Bean Validation, Hibernate Validator, or third-party libs.

  • Public methods must have JUnit 5 tests covering: happy path, single-field failure, multi-field failure, chained rules.

Why this matters

You are simultaneously exercising PECS (rule combinators consume ? super F and produce ? super F), bounded generics (getter type must match rule type), functional composition (and/or/negate), and record-based results. That’s four Phase 04 concepts in ~300 lines.

Alternative: if you already work on a query-heavy service, build a tiny type-safe query builder instead — same generics muscles, different domain. Something like:

Query<User> q = Query.from(User.class)
    .where(User::age, gt(18))
    .where(User::email, endsWith("@zoho.com"))
    .orderBy(User::name)
    .limit(50);

Pick whichever domain will show up in your day job first.

Project C · 30 Stream Refactoring Katas

Drill until the idiomatic form appears without thought. This is the highest ROI exercise in the phase — every code review you fail on streams is one of these 30 shapes.

Format

Set up a katas/ module. For each kata, you get:

  • An Imperative.java file with a small imperative method (loops, mutable maps, temp lists).

  • A Streams.java file with the same method signature, empty body.

  • A KataNTest.java file with property-based tests (jqwik) or exhaustive JUnit tests that must pass for both.

Your job: implement the streams version, run the tests, then compare against a reference solution in a hidden solutions/ folder (only unlock after you’ve committed your attempt).

The 30 katas

Group by increasing difficulty. The list is deliberately practical — every shape here comes from real Zoho / Spring / enterprise codebases.

Level 1 — Basic transforms (1-8)

  1. Sum of integers in a list.

  2. Uppercase all strings, filter out blanks.

  3. Count elements matching a predicate.

  4. Find first element matching a predicate (return Optional).

  5. Map list of Order to list of orderId (long).

  6. Group Order by status, count per group.

  7. Partition numbers into even/odd.

  8. Convert List<Employee> to Map<Long, Employee> by id.

Level 2 — Collectors and grouping (9-16)

  1. Group orders by customer, sum total per customer (BigDecimal).

  2. Top 5 customers by lifetime spend.

  3. Group products by category, get max-priced product per category.

  4. Compute avg / min / max of a numeric field with summarizingDouble.

  5. Join names into a CSV string (Collectors.joining(", ", "[", "]")).

  6. Deduplicate a list preserving order (LinkedHashSet via collector).

  7. Collectors.teeing — compute mean and count in one pass.

  8. Group by two fields (nested groupingBy).

Level 3 — flatMap, Optional, and control flow (17-24)

  1. Flatten List<List<String>> into List<String>.

  2. From List<Order> where each order has List<Item>, get all items flat.

  3. Given List<Optional<User>>, get List<User> (Java 9+ Optional::stream).

  4. Zip two lists into a list of pairs (hint: IntStream.range).

  5. Compute a running sum (prefix sums) — force yourself to use Stream.reduce or streams-plus-mutable-accumulator; note the mutable-accumulator smell.

  6. From List<Employee>, find the department with the highest average salary.

  7. Return first N distinct elements matching a predicate.

  8. Convert Map<String, List<Order>> to Map<String, BigDecimal> (total per customer) without loops.

Level 4 — Traps and edge cases (25-30)

  1. Group-then-reduce that mishandles empty groups — fix it so empty groups return BigDecimal.ZERO, not throw.

  2. A parallel() version of kata 9 — benchmark and decide whether to keep it.

  3. A pipeline that side-effects into a shared Map — refactor to Collectors.toMap.

  4. Convert an imperative pagination loop (fetch pages until empty) to Stream.iterate with a takeWhile (Java 9+).

  5. A collector that produces an immutable List — use Collectors.toUnmodifiableList or .stream().toList() and explain the difference.

  6. Rewrite a Comparator-heavy sort using Comparator.comparing(...).thenComparing(...).reversed().

Rules

  • Each kata gets its own commit with the message kata NN: <one-line summary>.

  • Every kata must have a failing test written first, then a passing implementation.

  • If a kata is easier in a for-loop, note that in the file — some are. Not every problem wants to be a stream.

Time budget

Roughly 1-2 hours per kata. Do 4-5 per week over the phase. By the end you should be able to write any of them in under 3 minutes without looking anything up.

Delivery Timeline

Week

Focus

Milestone

1-2

Generics + Project B skeleton

Type-safe DSL passes 3 tests

3

Lambdas / method refs

Katas 1-8 done

4

Streams anatomy

Project A imperative version → stream version, no benchmarks yet

5

Collectors deep dive

Katas 9-16 done, Project A benchmarks running

6

Optional

Katas 17-24 done, Project B validators complete with tests

7

CompletableFuture

Katas 25-30 done, Project A BENCHMARKS.md published

8

Polish + write-up

All three projects public on GitHub with READMEs

What This Phase Gives You in studies

Post-phase you can look at any .stream()... line and tell me why it exists — not just what it does. You can defend or refuse parallel() with numbers. You can explain PECS at a whiteboard without hedging. And you can compose async values without blocking a pool. That’s a mid-to-senior signal in every Java study.

Ship the three projects. Post the log-analyzer benchmarks in a small blog post — hiring managers read those and they take about 90 minutes to write once the data is in hand.


Return to README.md · Previous: 05_concurrency_primer_functional.md