Java Study Projects

Three programs that pull the whole track together, written with the standard library only. Type each one yourself — do not copy and paste — then break it deliberately and repair it. These are the programs that teach more than any amount of reading: a text analysis built on HashMap and streams, a scheduler that turns a comparator into a policy, and a report generator built from records and grouping.

Each project is complete as shown and takes about an hour to write and understand. Every file is a single class with a main method, so you can compile it with javac and run it without a build tool. The challenge at the end of each is deliberately open: it is the version of the project you would meet in real work, where the requirements arrive as a paragraph rather than as a specification.

Project 1 — Word Frequency Counter

Every programmer writes this program once. It is the smallest project that exercises files, strings, maps, sorting, and formatting together — and the smallest one where the obvious implementation is also the slow one if you let it re-scan the text for every word.

The design: read once, normalise each word, count in a HashMap, then sort the entries. Two decisions carry all the subtlety. What counts as a word is a pattern question, and how case is treated is a correctness question — The and the must be the same word or the counts are wrong.

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/** Project 1 — word frequency counter. */
class WordFrequency {

  // A word is a run of letters, digits, apostrophes or hyphens. \p{L} and \p{N}
  // are the Unicode classes, so words with accents survive lowercasing.
  private static final Pattern WORD = Pattern.compile("[\\p{L}\\p{N}'-]+");

  /**
   * Count the words in one pass. HashMap.merge does the "start at one or add
   * one" work in a single statement, which is why no containsKey test appears.
   */
  static Map<String, Integer> countWords(String text) {
    Map<String, Integer> counts = new HashMap<>();
    Matcher matcher = WORD.matcher(text);
    while (matcher.find()) {
      String word = matcher.group().toLowerCase(Locale.ROOT);
      counts.merge(word, 1, Integer::sum);
    }
    return counts;
  }

  /**
   * Most frequent first; ties are broken alphabetically so the output is
   * deterministic instead of merely "whatever the hash order happened to be".
   */
  static List<Map.Entry<String, Integer>> topWords(
      Map<String, Integer> counts, int limit) {
    return counts.entrySet().stream()
        .sorted(Comparator.<Map.Entry<String, Integer>>comparingInt(Map.Entry::getValue)
            .reversed()
            .thenComparing(Map.Entry::getKey))
        .limit(limit)
        .toList();
  }

  static void report(List<Map.Entry<String, Integer>> top) {
    for (Map.Entry<String, Integer> entry : top) {
      System.out.printf("%6d  %s%n", entry.getValue(), entry.getKey());
    }
  }

  public static void main(String[] args) throws IOException {
    // A file named on the command line wins; otherwise use a built-in sentence
    // so the program is runnable with no arguments at all.
    String text = (args.length > 0)
        ? Files.readString(Path.of(args[0]))
        : "The quick brown fox jumps over the lazy dog. "
          + "The fox, the dog, and the cat watched the quick brown fox.";

    Map<String, Integer> counts = countWords(text);
    int total = counts.values().stream().mapToInt(Integer::intValue).sum();

    System.out.printf("distinct words: %d%n", counts.size());
    System.out.printf("total words:    %d%n", total);
    report(topWords(counts, 5));
  }
}

Run it and you will see that the wins — five occurrences of the article against three of the animal that keeps being watched. Now open a real text file and give the program its path: java WordFrequency book.txt. The counts change, the code does not.

Challenge

Turn the counter into an index. For each word, record the line numbers it appears on, so that the reports [1, 2, 7]. That means a Map<String, List<Integer>> instead of counts, a line counter around the read, and a second Matcher pass per line. Then decide what to do about multi-word phrases: a search for "quick brown" should be able to report the same line numbers, which turns the pattern question into a design discussion rather than a bug.

Project 2 — Priority Task Scheduler

A queue that always hands back the most important item is one of the most useful structures in everyday programming: build systems, interrupt handlers, and print spoolers all use one. Java's PriorityQueue is a binary heap, so it never sorts the whole collection — it only guarantees the next element out.

The interesting part is not the heap, it is the ordering. "Most urgent first, then the shortest job, then alphabetically" is a policy expressed as a chain of comparators. Change the chain and you have changed the product, without touching the queue at all.

import java.util.Comparator;
import java.util.PriorityQueue;
import java.util.Queue;

/** Project 2 — priority task scheduler. */
class TaskScheduler {

  // Ordinal order is LOW, NORMAL, HIGH, URGENT — the declaration order.
  enum Priority { LOW, NORMAL, HIGH, URGENT }

  // A record is enough for a task: immutable, printable, field-addressable.
  record Task(String name, Priority priority, int minutes) { }

  /**
   * Negating the ordinal puts URGENT at the front. The remaining comparators
   * break ties deterministically, so two tasks of equal priority never come
   * out in an order that depends on hash codes or insertion luck.
   */
  static Queue<Task> newQueue() {
    return new PriorityQueue<>(
        Comparator.comparingInt((Task task) -> -task.priority().ordinal())
            .thenComparingInt(Task::minutes)
            .thenComparing(Task::name));
  }

  static void add(Queue<Task> queue, Task task) {
    queue.add(task);
    System.out.printf("queued  %-20s %-7s %2d min%n",
                      task.name(), task.priority(), task.minutes());
  }

  /** Drain the queue and report the elapsed time after each task. */
  static int run(Queue<Task> queue) {
    int total = 0;
    while (!queue.isEmpty()) {
      Task task = queue.poll();
      total += task.minutes();
      System.out.printf("running %-20s %-7s %2d min (elapsed %d min)%n",
                        task.name(), task.priority(), task.minutes(), total);
    }
    return total;
  }

  public static void main(String[] args) {
    Queue<Task> queue = newQueue();

    add(queue, new Task("write report", Priority.NORMAL, 40));
    add(queue, new Task("fix build", Priority.URGENT, 15));
    add(queue, new Task("reply email", Priority.LOW, 5));
    add(queue, new Task("review pull request", Priority.HIGH, 25));
    add(queue, new Task("deploy release", Priority.URGENT, 10));

    System.out.println("--- work starts ---");
    int total = run(queue);
    System.out.printf("total minutes: %d%n", total);
  }
}

The output shows the policy at work: the two urgent tasks come out first — and between them the ten-minute deployment beats the fifteen-minute build fix, exactly as the comparator says. Only then does the scheduler work down through HIGH, NORMAL, and LOW.

Challenge

Make the policy configurable. Add a Comparator<Task> parameter to a schedulerFor(...) factory and offer three: the one above, "deadline first" once tasks carry a due time, and "shortest job first" for throughput. Then add a dependencies field to the record and refuse to run a task whose dependencies have not run yet — you will need a second structure to hold the blocked tasks, and that is precisely where a task scheduler stops being a toy.

Project 3 — Account Ledger Report

Business software is mostly this shape: take a list of immutable facts, reduce it into a summary, print the summary. The reduction is a grouping and a sum; the printing is a format string. Both are small, and both are easy to get subtly wrong — which is why this project is worth typing out in full.

Two design choices do the work. The first is a record with a compact constructor, because a ledger entry that cannot be constructed in an invalid state never has to be validated again. The second is storing the amount unsigned and keeping the direction in an enum, which removes the double-negation mistakes that plague hand-signed arithmetic.

import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

/** Project 3 — account ledger report. */
class LedgerReport {

  enum Kind { DEBIT, CREDIT }

  /**
   * One ledger entry. The compact constructor enforces the invariant that the
   * amount is stored unsigned — the sign lives in the kind, not the number.
   */
  record Entry(String account, Kind kind, double amount) {
    Entry {
      if (amount < 0) {
        throw new IllegalArgumentException("amount must be positive: " + amount);
      }
    }

    /** Signed value: credits add, debits subtract. */
    double signedAmount() {
      return kind == Kind.CREDIT ? amount : -amount;
    }
  }

  /**
   * Group by account and sum the signed amounts. groupingBy with a downstream
   * summingDouble is the single-pass version of a loop plus a get/put pair.
   */
  static Map<String, Double> balanceByAccount(List<Entry> ledger) {
    return ledger.stream().collect(Collectors.groupingBy(
        Entry::account,
        Collectors.summingDouble(Entry::signedAmount)));
  }

  static void printReport(Map<String, Double> balances) {
    System.out.printf("%-10s %12s%n", "ACCOUNT", "BALANCE");
    balances.entrySet().stream()
        .sorted(Map.Entry.comparingByKey())
        .forEach(entry -> System.out.printf("%-10s %12.2f%n",
                                            entry.getKey(), entry.getValue()));
  }

  public static void main(String[] args) {
    List<Entry> ledger = List.of(
        new Entry("alice", Kind.CREDIT, 1200.00),
        new Entry("bob",   Kind.CREDIT,  300.00),
        new Entry("alice", Kind.DEBIT,   250.00),
        new Entry("bob",   Kind.DEBIT,   150.00),
        new Entry("carol", Kind.CREDIT,   75.50));

    Map<String, Double> balances = balanceByAccount(ledger);
    printReport(balances);

    balances.entrySet().stream()
        .max(Comparator.comparingDouble(Map.Entry::getValue))
        .ifPresent(top -> System.out.printf("largest balance: %s (%.2f)%n",
                                            top.getKey(), top.getValue()));

    // The invariant is enforced at construction time, not checked later.
    try {
      new Entry("dave", Kind.DEBIT, -10.0);
    } catch (IllegalArgumentException e) {
      System.out.println("rejected: " + e.getMessage());
    }
  }
}

Challenge

Read the ledger from a CSV file instead of a literal list. One line per entry, account,kind,amount, and a header you should skip by name rather than by position. Add a running balance column so the report shows the order in which the totals were reached. Then add a LocalDate field and a monthly summary — the grouping key becomes a pair, and you will discover why record Month(int year, int month) is the natural way to name that pair in modern Java.

Where to go next. These three projects cover reduction, ordering, and reporting. The remaining half of practical Java is I/O and concurrency: reading streams safely, writing atomically, and splitting the work across threads without sharing mutable state. The Lab Examples page has the small programs for that, and the References & Landscapes page points at the official documentation.

Return to the Java roadmap to track progress, or revisit any lesson: the sidebar on every page lists the full chapter outline.