Skip to content

Java From Scratch (Beginner)

Beginner path: from never-coded to reading and contributing to real OSS Java.

Printing this page

Use your browser's PrintSave as PDF. The print stylesheet hides navigation, comments, and other site chrome; pages break cleanly at section boundaries; advanced content stays included regardless of beginner-mode state.


Java From Scratch - Beginner to OSS Contributor

From "I have never written code" to "I can clone a real Java project, read most of it, and submit a pull request."

Who this is for

  • You have never written code, OR
  • You have copy-pasted Java code from tutorials but couldn't explain it line by line.

That's it. If you need to know something, this path will teach it.

What you'll need

  • A computer (macOS, Linux, Windows).
  • A text editor - VS Code or IntelliJ IDEA Community Edition (free, excellent for Java).
  • A terminal.
  • About 5 hours per week. Path is sized for 4-6 months at that pace.

Why Java

  • Battle-tested. Java has run banks, telecoms, e-commerce backends for 25+ years. There's no shortage of jobs and OSS.
  • Strong, enforced types. The compiler catches a class of bugs that languages without type-checking find at runtime.
  • Excellent tooling. IntelliJ IDEA's free Community Edition is one of the best programming environments ever built. Maven and Gradle are mature. JUnit, JFR, async-profiler are top-tier.
  • The JVM ecosystem. Beyond Java itself, the JVM hosts Kotlin, Scala, Clojure, Groovy. Skills transfer.

How this path works

Each page does one thing: says what you'll learn, shows code, walks through it line by line, gives an exercise, ends with a Q&A. Do the exercises. Reading without doing won't stick.

The pages

# Title What you'll know after
00 Introduction What we're doing and why
01 Setup JDK installed, hello world, JShell
02 First real program Variables, primitives, strings
03 Decisions and loops if, for, modern switch
04 Methods Java's word for functions
05 Classes and objects Everything-is-a-class
06 Collections List, Map, Set
07 Exceptions try/catch/finally + try-with-resources
08 Records, sealed, pattern matching Modern Java's superpowers
09 Generics Type-safe collections
10 Tests JUnit 5
11 Packages, modules, Maven Using code other people wrote
12 Reading other people's code The bridge
13 Picking a project What "manageable" looks like
14 Anatomy of a Java OSS repo Case study
15 Your first contribution Workflow + PR

Start with Introduction.

00 - Introduction

What this session is

A 10-minute read. No code yet. Sets expectations honestly so you can decide if this path is for you.

What you're going to build, eventually

By the end of this path you'll have:

  • Written and run small Java programs that print, calculate, and decide.
  • Built a little command-line application that takes input and produces output.
  • Written tests for your own code and watched them pass and fail.
  • Cloned a real Java open-source project, browsed its code, run its tests, and understood roughly what it does.
  • Submitted a small fix to one of those projects as a real pull request.

That last point is the goal. Everything else is preparation.

The deal

A few things to know about how this path works:

It's slow on purpose. Most beginner Java tutorials drop you into Spring Boot by page three. That works for some people. For most, it leaves them able to copy code without understanding it. This path is the opposite - one concept per page.

It assumes nothing. If a word appears that you haven't seen, it's defined there. No glossary lookups.

It does the work where the work is. Some pages are short; some are long. We don't pad.

You have to type the code. Reading without typing is reading sheet music without playing. Type every example.

You will be confused. Often. Especially in the first month. That's normal. Programming is unusual in how often you feel stuck. Confusion is not a sign you're bad at this; it's a sign you're doing it.

What you need

  • A computer (any OS).
  • A text editor - IntelliJ IDEA Community Edition is the most-loved Java environment ever made, and it's free. VS Code with the Extension Pack for Java is the lighter alternative. Either works.
  • A terminal.
  • ~5 hours per week. Less is fine; the path takes longer.
  • A notebook for questions. You'll have lots. Write them down so you can keep going past them.

What you do NOT need

  • Math beyond basic arithmetic.
  • A computer-science degree.
  • A "gift" for computers. There is no such thing.
  • To know any other programming language first.

How long this realistically takes

4 to 6 months at 5 focused hours per week, to get to the "submit a pull request" goal.

The thing that takes weeks isn't "absorbing information" - it's your brain getting used to a new way of thinking. That happens at biology speed, not internet speed.

Java has a reputation problem

You might have heard Java is "verbose," "enterprise-y," "slow," or "old." Let me name each honestly:

  • Verbose. Pre-2017, yes. Modern Java (since 14) has records, sealed classes, switch expressions, var, pattern matching - much less ceremony. We'll use modern Java throughout.
  • Enterprise-y. Java is used in some places where teams over-engineer. The language isn't to blame. Small modern Java code reads cleanly.
  • Slow. Steady-state Java (after JIT warmup) is within 10-30% of C/C++ for almost everything. Startup is slow; per-op throughput isn't.
  • Old. Java is 30 years old. So are C, C++, Python, JavaScript. Old languages have ecosystems older languages don't.

If you've used Java before and bounced off, give modern Java a fresh look.

What success looks like at the end

You'll be able to:

  • Open a Java file you've never seen and read it like a recipe.
  • Open a Java project on GitHub and tell me in two paragraphs what it does and how.
  • Find a small bug or missing feature and fix it.
  • Submit that fix as a pull request matching the project's conventions.

You will not be able to:

  • Build a new application server. (Not in 6 months.)
  • Tell people you're a "senior Java engineer." (Years after this.)

What you will have: the foundation to keep going.

One last thing before we start

If at any point a page feels too dense, stop and re-read it. If it's still too dense, that's a bug in the page - note it, skip forward, come back. The path is alive; it gets fixed when readers say "this part lost me."

Ready? Next: Setup →

01 - Setup

What this session is

About 30 minutes. You'll install a modern Java Development Kit (JDK), open the terminal, write your first program, and meet JShell - Java's interactive scratchpad.

Step 1: Install a JDK

A JDK ("Java Development Kit") is the compiler + runtime + standard library, bundled. The current Long-Term-Support version is Java 25. Get any modern build:

  • macOS: install Temurin (a free, open-source JDK build) via the .pkg installer. Or brew install --cask temurin if you use Homebrew.
  • Linux: sudo apt install openjdk-25-jdk on Debian/Ubuntu, or download from Adoptium.
  • Windows: download the .msi from Adoptium and run it. Make sure the installer adds Java to your PATH.

Open a terminal:

  • macOS: ⌘ Space → "terminal".
  • Windows: Windows key → "powershell".
  • Linux: you know how.

Check:

java --version
javac --version

Both should print version numbers around 21 or later. If not, the install didn't work or PATH isn't set.

Step 2: Pick a folder for your code

mkdir -p ~/code/java-learning
cd ~/code/java-learning

Step 3: The simplest possible Java program

Create a file called Hello.java (note the capital H - Java requires the file name to match the class name).

Type this - don't copy-paste:

public class Hello {
    public static void main(String[] args) {
        System.out.println("hello, world");
    }
}

Save it.

Step 4: Compile and run

Two steps. Compile:

javac Hello.java

That creates Hello.class (the bytecode). Run:

java Hello

Note: java Hello (no .class, no .java). You'll see:

hello, world

If something went wrong, the compiler tells you exactly which line. Read the message; fix; re-run.

Step 4b: Even simpler - run the source directly

Modern Java (11+) can compile-and-run a single file without producing a .class file:

java Hello.java

That works for one-file programs. For projects with multiple files, you'll need a build tool (page 11).

What just happened - line by line

That five-line program has a lot of scaffolding. Let's walk through it:

  • public class Hello { - defines a class called Hello. Java is built around classes. Every program starts with at least one. public means "visible from outside this file."
  • public static void main(String[] args) { - defines a method called main. This is special: java Hello looks for exactly this signature and runs it.
  • public - visible from outside.
  • static - belongs to the class itself, not to any object of the class. (We'll explain static properly when we meet objects in page 05. For now: main has to be static.)
  • void - returns nothing.
  • String[] args - receives any command-line arguments as an array of strings.
  • System.out.println("hello, world"); - calls println on System.out (the standard output) with "hello, world". The semicolon ends the statement.
  • The curly braces { and } mark the boundaries of the class body and the method body.

Yes, this is a lot of scaffolding for one print. Java optimizes for predictability over brevity - every program has the same shape, so you always know where to look. You'll write public class X { public static void main(String[] args) { ... } } many times in the next few sessions; it'll become muscle memory.

Try changing things

The way to learn is to break things on purpose:

  1. Change "hello, world" to your name. Re-compile and re-run.

  2. Add a second println:

    System.out.println("hello, world");
    System.out.println("this is my second line");
    

  3. Print a number:

    System.out.println(42);
    

  4. Break it. Remove a semicolon. Run javac - read the error.

  5. Restore. Mistype Println (capital P). Run javac - read the error. Java is case-sensitive.

Reading errors is most of programming. Get comfortable seeing them.

JShell: instant gratification

Java's interactive scratchpad. Type lines, see results. No class boilerplate needed.

jshell

You'll see a jshell> prompt. Try:

jshell> 2 + 2
$1 ==> 4

jshell> "hello" + " " + "world"
$2 ==> "hello world"

jshell> System.out.println("hi")
hi

jshell> int x = 10
x ==> 10

jshell> x * 5
$5 ==> 50

Exit with /exit.

JShell is great for one-line experiments. "How does X behave?" → open JShell → try → see. Use it when you're stuck.

Use an IDE for real work

Compiling at the command line works for hello-world. For real projects, use an IDE (Integrated Development Environment):

  • IntelliJ IDEA Community Edition (free) - the gold standard for Java. Inline error highlighting, refactoring, navigation, debugging - all excellent.
  • VS Code with the "Extension Pack for Java" - lighter, runs everywhere, less Java-specific.

Set one up. For the rest of this path, you can use either the command line or the IDE - the IDE is much easier for anything past one file.

What you might wonder

"Why so much scaffolding for one print?" Java's design ethic is "explicit > terse." Every Java file has the same shape, so you know where things live. Python's print("hi") is shorter but Java's structure scales better to large programs.

"Why do I have to put the class in a file named after the class?" Convention enforced by the compiler. Makes finding code predictable: file Hello.java contains class Hello. The file holds (mostly) one public class.

"What's the difference between JDK and JRE?" JDK = compiler + tools + runtime (everything you need to develop). JRE = just the runtime (what you need to run compiled programs). For learning, install the JDK. JRE is for end-user deployment.

"Why System.out.println and not just println?" println is a method on the out field of the System class. There's no global println function in Java; everything lives inside a class. You'll get a sense for why this matters in page 05.

Done

You have: - A JDK installed. - A folder for your code. - One working program. - JShell as a scratchpad. - An IDE installed (or chosen).

This was the infrastructure step. Next page is where the real learning starts.

Next: First real program →

02 - First Real Program

What this session is

About 45 minutes. You'll learn variables, Java's primitive types, strings, and how Java handles text-with-values (text blocks and String.format/printf). By the end you'll have written a program that uses all three.

A small program with variables

Create a file Greet.java. Type:

public class Greet {
    public static void main(String[] args) {
        String name = "Alice";
        int age = 30;
        System.out.println(name + " is " + age + " years old");
    }
}

Compile and run:

javac Greet.java
java Greet

Output:

Alice is 30 years old

What's new

Two lines:

String name = "Alice";
int age = 30;
  • String name = "Alice"; - create a variable. String is the type; name is the name; = assigns; "Alice" is the value. Semicolon ends the statement.
  • int age = 30; - same for an integer.

The print line:

System.out.println(name + " is " + age + " years old");

+ between strings glues them together (called concatenation). When + sees a string on one side and a number on the other, it converts the number to a string and glues. Convenient - and also a source of bugs, as we'll see.

Java's type system: primitives vs everything else

Java has two kinds of types:

  1. Primitives. Built-in lowercase types that hold a single value directly. There are eight: byte, short, int, long, float, double, char, boolean.
  2. Reference types. Everything else - String, arrays, classes you define. Variables hold a reference to an object, not the object itself.

You'll meet references properly in page 05. For now, focus on the eight primitives.

The four primitives you'll actually use

Type Holds Example
int 32-bit whole number, ±2.1 billion range 42, -7, 1000
long 64-bit whole number, much larger range 42L, 1000000000000L
double 64-bit floating-point number 3.14, -0.5, 1.0
boolean true or false true, false

The other four (byte, short, float, char) exist for specific reasons; you'll meet them rarely. Use int for whole numbers, double for decimals, boolean for true/false. Use long if you need numbers bigger than 2.1 billion (timestamps, file sizes).

The L suffix on long literals: 42L means "the literal 42, treated as long." Otherwise 42 is an int and you get a compiler complaint when assigning to long.

Strings

String is a reference type, not a primitive - but it's so common that Java has special syntax for it. You write "hello" as a literal, and Java creates a String object.

String greeting = "hello";
String name = "world";
String message = greeting + ", " + name;
System.out.println(message);    // hello, world

Strings are immutable - once created, you can't change one. Operations that "modify" a string actually create a new one. (For long-running construction, use StringBuilder - we'll meet it when it matters.)

Type inference with var

Modern Java (10+) lets you skip the type when it's obvious:

var name = "Alice";    // Java infers String
var age = 30;          // Java infers int
var price = 19.99;     // Java infers double

var only works on local variables (inside methods). You can't use it on fields, parameters, or return types - those still need explicit types. The Java team's reasoning: var is convenient for locals but explicit types make APIs self-documenting.

Use var when the type is obvious from the right-hand side. Use the explicit type when it isn't.

Arithmetic

int x = 10;
int y = 3;
System.out.println(x + y);    // 13
System.out.println(x - y);    // 7
System.out.println(x * y);    // 30
System.out.println(x / y);    // 3   - integer division, drops remainder
System.out.println(x % y);    // 1   - modulo (remainder)

The // ... part is a comment - anything from // to end-of-line is ignored.

Integer division drops the remainder. 10 / 3 is 3, not 3.333.... To get the decimal, at least one operand must be a double:

double q = 10.0 / 3;
System.out.println(q);        // 3.3333333333333335

You can also explicitly cast an int to a double:

int a = 10, b = 3;
double q = (double) a / b;
System.out.println(q);        // 3.3333333333333335

The (double) is a cast - "treat a as a double for this expression." Without it you'd get integer division before the assignment.

Building strings: three ways

Way 1 - concatenation with +:

String name = "Alice";
int age = 30;
System.out.println(name + " is " + age + " years old");

Works for quick stuff. Gets unreadable past 3-4 pieces.

Way 2 - String.format and printf:

String msg = String.format("%s is %d years old", name, age);
System.out.println(msg);

// Or print directly:
System.out.printf("%s is %d years old%n", name, age);

The format codes: %s for any value (as string), %d for integers, %f for floats, %n for a line break.

Way 3 - text blocks (modern Java, 15+):

For multi-line text, use triple-quoted text blocks:

String poem = """
        Roses are red,
        Violets are blue,
        Java has text blocks
        Since version 15.
        """;
System.out.println(poem);

Indentation is stripped intelligently. Useful for SQL, JSON, HTML - anything multi-line you'd otherwise build with concatenation.

What you cannot do

int n = 5;
String s = "items: " + n;          // works - Java converts n to string
String s2 = "items: " + n + 3;     // probably surprising - see below

That second line gives "items: 53", not "items: 8". Why? + is left-associative, so it evaluates as ("items: " + n) + 3. The first + sees a string + an int → string "items: 5". The second + sees a string + an int → string "items: 53".

If you want "items: 8":

String s2 = "items: " + (n + 3);   // parens force int math first

This is a classic Java gotcha. Always parenthesize arithmetic when concatenating.

Reading input from the user (briefly)

To prompt the user for input:

import java.util.Scanner;

public class Echo {
    public static void main(String[] args) {
        Scanner in = new Scanner(System.in);
        System.out.print("What's your name? ");
        String name = in.nextLine();
        System.out.println("Hello, " + name);
    }
}

New things: - import java.util.Scanner; - bring the Scanner class into scope (it lives in the java.util package). - Scanner in = new Scanner(System.in); - create a Scanner that reads from standard input. - in.nextLine() - read one line of input.

You'll meet import and new properly soon. For now, use the pattern as-is.

Exercise

In a new file Me.java:

Write a program that:

  1. Has variables for your name (String), favorite number (int), and a boolean for whether it's morning.
  2. Prints a line like: Hi, I'm Victor, my favorite number is 7, and yes (true) it's morning.

Try it three ways: - With concatenation: "Hi, I'm " + name + ", ...". - With String.format and %s/%d/%b placeholders. - With System.out.printf.

Don't skip. The act of typing is the learning.

What you might wonder

"Why no f-strings like Python?" Java doesn't have them yet. There's a proposal (JEP 430, "String Templates") that's been through a few rounds. For now, use String.format or printf.

"Why String (capital) vs int (lowercase)?" Primitives are lowercase. Reference types (classes) are CapitalCase. The distinction is built into the language. Every class follows this - Integer, Double, Boolean (capital) are the boxed wrapper classes for primitives, used when you need a primitive value to live in a collection (page 06).

"What if I never use a variable?" The compiler warns but doesn't fail. Linters like SpotBugs or IntelliJ's inspector flag unused variables more loudly. Clean them up.

"What's the difference between = and ==?" = assigns. == compares. We'll meet == in page 03. They're easy to mix up; reading Java aloud as "is set to" (=) vs "equals" (==) helps.

Done

You can now: - Make variables with explicit types (String, int, double, boolean) or with var. - Recognize the eight primitives and know the four you'll actually use. - Do arithmetic with proper attention to integer vs float division. - Build strings three ways: concatenation, format/printf, text blocks. - Read a line of input with Scanner.

Next page: making your program decide and repeat.

Next: Decisions and loops →

03 - Decisions and Loops

What this session is

About an hour. You'll learn if/else/else if, the three loop forms (for, enhanced for, while), and modern Java's switch expressions - a much-improved version of the old switch statement.

Decisions: if / else

The world's smallest decision:

public class Age {
    public static void main(String[] args) {
        int age = 18;
        if (age >= 18) {
            System.out.println("adult");
        } else {
            System.out.println("minor");
        }
    }
}

What's new: - if (condition) { ... } - the parentheses are required. Code in braces runs only when the condition is true. - else { ... } - runs when the condition is false.

Java requires the braces. Some languages let you skip them for single-line bodies; Java doesn't, and that rule prevents a class of bugs. Always braces.

Comparison operators

Operator Meaning
== equal to
!= not equal to
< less than
<= less than or equal to
> greater than
>= greater than or equal to

Important - strings: == compares references, not contents. To compare string contents, use .equals:

String a = "hello";
String b = "hello";
if (a == b) { ... }            // might be true (or might not - undefined)
if (a.equals(b)) { ... }       // always correct for content comparison

Always use .equals for strings. The "use == for primitives, .equals for objects" rule is one of the most-told Java beginner rules.

Chaining: else if

int score = 75;
if (score >= 90) {
    System.out.println("A");
} else if (score >= 80) {
    System.out.println("B");
} else if (score >= 70) {
    System.out.println("C");
} else {
    System.out.println("F");
}

First match wins, top to bottom. If none match, else runs.

Combining: &&, ||, !

Operator Meaning
&& and (both true)
\|\| or (at least one true)
! not (flip)
int age = 25;
boolean hasLicense = true;
if (age >= 18 && hasLicense) {
    System.out.println("can drive");
}

Short-circuit: && doesn't evaluate the right side if the left is false. || doesn't evaluate the right if the left is true. Useful when the right side is expensive or might fail:

if (obj != null && obj.isReady()) { ... }   // safe even if obj is null

Repetition 1: for

The C-style for:

for (int i = 1; i <= 5; i++) {
    System.out.println(i);
}

Three parts, separated by semicolons: 1. int i = 1 - runs once, before anything else. 2. i <= 5 - checked before each iteration; loop continues while true. 3. i++ - runs after each iteration. (Shorthand for i = i + 1.)

Output: 1, 2, 3, 4, 5.

The compact form. Useful when you need the index.

Repetition 2: enhanced for (for-each)

When iterating a collection or array, the enhanced form reads better:

String[] fruits = {"apple", "banana", "cherry"};
for (String fruit : fruits) {
    System.out.println(fruit);
}

Read for (T x : collection) as "for each x in collection." Use this whenever you don't need the index.

Repetition 3: while

int n = 10;
while (n > 0) {
    System.out.println(n);
    n = n - 1;
}

Keep going while the condition is true. The body must change something that affects the condition.

There's also do-while, which runs the body at least once before checking:

int x;
do {
    x = readInput();
} while (x < 0);

Rare; use when "do something once, then maybe repeat" is the natural shape.

Breaking out: break and continue

for (int i = 1; i <= 10; i++) {
    if (i == 5) break;        // stop the loop entirely
    if (i % 2 == 0) continue; // skip to next iteration
    System.out.println(i);
}

Output: 1, 3. (i=1 prints; i=2 even → skip; i=3 prints; i=4 even → skip; i=5 → break.)

Switch expressions: modern Java

Old switch (still works, has a famous fall-through bug):

switch (day) {
    case 1: System.out.println("Mon"); break;
    case 2: System.out.println("Tue"); break;
    case 3: System.out.println("Wed"); break;
    default: System.out.println("?");
}

The break is required - without it, execution "falls through" to the next case. Forgetting break is the canonical switch bug.

Modern Java (14+) has switch expressions with arrow syntax - no fall-through, no break required, returns a value:

int day = 2;
String name = switch (day) {
    case 1 -> "Mon";
    case 2 -> "Tue";
    case 3 -> "Wed";
    default -> "?";
};
System.out.println(name);    // Tue

What's new: - case 1 -> instead of case 1:. The arrow form doesn't fall through. - The whole switch is an expression - produces a value you can assign or pass. - The semicolon after the closing } (because the whole thing is one statement).

For multiple matching values:

String type = switch (day) {
    case 1, 2, 3, 4, 5 -> "weekday";
    case 6, 7 -> "weekend";
    default -> "invalid";
};

For a block body (when one expression isn't enough), use yield:

String result = switch (input) {
    case "a" -> "got a";
    case "b" -> {
        System.out.println("processing b");
        yield "got b";    // yield is "return from this case"
    }
    default -> "unknown";
};

Always use the modern arrow form in new code. We'll see it heavily in page 08 when we meet pattern matching.

Exercise

In a new file Classify.java:

Write the classic FizzBuzz. For each number from 1 to 20:

  • Divisible by 3 → print Fizz.
  • Divisible by 5 → print Buzz.
  • Divisible by both → print FizzBuzz.
  • Otherwise → print the number.

Hint: check "both 3 and 5" first. Why? Think about what would happen if you checked "divisible by 3" first.

Expected output starts:

1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
...

Don't move on until your program prints exactly the right thing.

Stretch: rewrite using a switch expression on n % 15. (15 = 3 × 5 - what values of n % 15 mean "divisible by 3 only", "by 5 only", "by both"?)

What you might wonder

"Why are parens required around if conditions?" Java's grammar requires them. Python (if x > 0:) doesn't; Java does. Live with it.

"Why does == on strings sometimes work?" Java interns string literals - identical literals share the same object, so "hello" == "hello" is true. But "hello" == new String("hello") is false (different objects, same content). Always use .equals to avoid this trap.

"Switch expression vs switch statement - when does the old form make sense?" Almost never in new code. The arrow form is shorter, safer (no fall-through), and produces a value. The only reason to use the old form is to integrate with very old codebases.

"What about pattern matching?" Modern Java's switch can also match on types and destructure: case Point(int x, int y) -> .... We'll meet this in page 08 after we've learned classes.

Done

You can now: - Make a program take different actions with if/else if/else. - Use comparison and logical operators correctly. - Know that .equals compares string contents; == is for primitives. - Iterate with C-style for, enhanced for, and while. - Use break and continue. - Write modern switch expressions.

You have the core control flow. Next page: methods - Java's word for functions.

Next: Methods →

04 - Methods

What this session is

About 45 minutes. You'll learn how to define your own methods (Java's word for functions), use static, return values, and pass parameters. By the end you can break programs into named pieces.

"Method" vs "function"

Other languages call them functions. Java calls them methods because they always live inside a class. That's the only difference in vocabulary - the concept is the same: a named, reusable block of code that takes input and (usually) returns output.

The shape

public static returnType name(parameters) {
    // body
    return something;
}

Concrete example:

public class Math1 {
    public static int doubleIt(int x) {
        return x * 2;
    }

    public static void main(String[] args) {
        System.out.println(doubleIt(5));   // 10
        System.out.println(doubleIt(7));   // 14
    }
}

Walk through doubleIt:

  • public - visible from outside the class.
  • static - belongs to the class itself, not to any object. (We'll explain static properly when we meet objects in page 05. For now: methods in a main-only program need to be static so main can call them.)
  • int - the return type.
  • doubleIt(int x) - name and parameter list.
  • return x * 2; - compute and send back.

Compile and run:

javac Math1.java && java Math1

Output: 10, 14.

Multiple parameters

public static int add(int a, int b) {
    return a + b;
}

Parameters are separated by commas. Each gets its own type.

Methods that return nothing

void means "this method returns nothing":

public static void sayHi(String name) {
    System.out.println("Hi, " + name);
}

public static void main(String[] args) {
    sayHi("Alice");
    sayHi("Bob");
}

No return line. The method runs for its side effect (printing).

Methods calling methods

Methods can call other methods (including the same class's):

public class Math2 {
    public static int square(int x) {
        return x * x;
    }

    public static int sumOfSquares(int a, int b) {
        return square(a) + square(b);
    }

    public static void main(String[] args) {
        System.out.println(sumOfSquares(3, 4));   // 9 + 16 = 25
    }
}

Composition - small named pieces combined.

Method overloading: multiple methods, same name, different parameters

Java lets you define several methods with the same name as long as their parameter lists differ:

public static int add(int a, int b)         { return a + b; }
public static double add(double a, double b) { return a + b; }
public static String add(String a, String b) { return a + b; }

The compiler picks the right one based on the argument types at the call site. Useful for variants of an operation on different types.

Common overload: println itself is overloaded for every type - that's why System.out.println(42) and System.out.println("hello") both work.

Default arguments - Java doesn't have them

Languages like Python have default parameters: def greet(name, greeting="hello"). Java doesn't.

The Java way: overload methods.

public static String greet(String name) {
    return greet(name, "hello");
}
public static String greet(String name, String greeting) {
    return greeting + ", " + name;
}

One method delegates to the more-general one with a default. The IDE and the compiler give you the same affordance via overloads.

Variable scope

Variables created inside a method exist only inside that method:

public static int doubleIt(int x) {
    int result = x * 2;
    return result;
}

public static void main(String[] args) {
    System.out.println(result);   // ERROR - `result` doesn't exist here
}

Each method has its own world. Pass values in via parameters; get values out via return.

Local variables, parameters, and final

Inside a method you can mark variables final, meaning "this can't be reassigned after its first value":

public static int doubleIt(int x) {
    final int multiplier = 2;
    // multiplier = 3;   // ERROR - can't reassign a final
    return x * multiplier;
}

Useful for "this constant won't change." Increasingly, modern Java code marks variables final by default unless they need to change - the discipline forces you to think about mutation.

var for local variables (modern Java)

You met var in page 02 for inferred types. Same rule for locals declared from a method call:

public static void main(String[] args) {
    var sum = add(3, 4);            // Java infers int
    var greeting = greet("Alice");  // Java infers String
    System.out.println(sum);
    System.out.println(greeting);
}

var for locals; explicit types for everything else.

Why methods matter

  1. Naming. doubleIt(7) reads better than re-typing 7 * 2 everywhere, especially when the operation is more complex.
  2. Reuse. Write once, call many times.
  3. Testing. You can test doubleIt separately from the rest (page 10).
  4. Structure. Reading a 500-line main is awful. Reading 20 small named methods tells you what the program does at a glance.

These benefits compound. They're invisible at 30 lines and decisive at 300.

Exercise

In a new file IsEven.java:

  1. Write a method isEven(int n) returning boolean. Use the % operator.

  2. From main, print isEven(4) and isEven(7). You should see true and false.

  3. Write a method countEvens(int max) returning int that counts even numbers in 1..max. Use a for loop and call isEven.

  4. Print countEvens(10). Expected: 5.

  5. Print countEvens(100). Expected: 50.

  6. Stretch: add overloaded countEvens(int min, int max) that counts evens in min..max. Test with countEvens(5, 15) (expected: 6).

What you might wonder

"Why static? When do I use non-static?" static means "belongs to the class." Non-static means "belongs to an instance" (object). You'll meet instance methods in page 05. For now, in a single-file program with only main, all your helper methods are static - they have to be, so main (which is static) can call them.

"Java doesn't have first-class functions?" It does - but they're called lambdas and method references, and they were added in Java 8. We'll meet them when we need them (page 06 with collections, page 08 with pattern matching).

"What if I forget return in a non-void method?" The compiler refuses to build. "Missing return statement." Read the error; fix the path that's missing one.

"Can a method modify its parameter?" For primitives: the parameter is a copy; modifying it doesn't affect the caller. For objects: the parameter holds a reference to the same object; modifying the object's fields does affect the caller (more in page 05).

Done

You can now: - Define methods with parameters and return types. - Use void for methods that don't return. - Overload methods (same name, different parameters). - Use var for inferred local types. - Use final to mark locals as non-reassignable. - Understand why static/non-static matters (preview).

Next page: classes and objects - where Java's everything-is-a-class design really kicks in.

Next: Classes and objects →

05 - Classes and Objects

What this session is

About 90 minutes. You'll learn how to define your own classes, create objects, write constructors and instance methods, and finally understand the difference between static and instance. By the end you can model real-world things - people, accounts, points, anything with structure.

This is the page that turns Java from "scripting with a lot of ceremony" into "object-oriented programming."

The problem

Variables hold one value. Real things have many properties at once: a person has a name, an age, a city. You could pass each property as a separate parameter - but past 3-4, code becomes unreadable.

A class lets you bundle properties (called fields) together, and attach methods that operate on them. An object is one instance of a class - like one specific person built from the Person template.

A class

public class Person {
    String name;
    int age;
    String city;
}

That's it - a class with three fields and no methods. Save as Person.java.

Now use it in another file, Main.java:

public class Main {
    public static void main(String[] args) {
        Person alice = new Person();
        alice.name = "Alice";
        alice.age = 30;
        alice.city = "Lagos";

        System.out.println(alice.name + " is " + alice.age);
    }
}

Compile both, run Main:

javac Person.java Main.java
java Main

Output: Alice is 30.

What's new:

  • new Person() creates a new object of type Person. new is the keyword for object creation; the parentheses call the constructor (which Java auto-generates if you don't write one - more soon).
  • alice.name = "Alice" sets the name field on this specific object.
  • Same person, written Person alice - the variable's type.

Constructors: setup logic

Setting each field manually after creation is tedious. A constructor runs when the object is created, with arguments:

public class Person {
    String name;
    int age;
    String city;

    public Person(String name, int age, String city) {
        this.name = name;
        this.age = age;
        this.city = city;
    }
}

The constructor has the same name as the class. No return type. this refers to "the object being constructed" - this.name is the object's field, name (without this) is the parameter. (When parameter names match field names, you need this to disambiguate. When they don't match, you can skip it.)

Now use it:

Person alice = new Person("Alice", 30, "Lagos");
System.out.println(alice.name);

Much cleaner.

Instance methods

A method without static is an instance method - it operates on a specific object via this:

public class Person {
    String name;
    int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String greet() {
        return "Hi, I'm " + this.name;
    }

    public void birthday() {
        this.age = this.age + 1;
    }
}

Call them on an object:

Person alice = new Person("Alice", 30);
System.out.println(alice.greet());     // Hi, I'm Alice
alice.birthday();
System.out.println(alice.age);         // 31

alice.greet() calls greet with this = alice. alice.birthday() mutates alice's age field in place.

static vs instance, finally explained

Now we can give the full answer:

  • Instance members (fields, methods without static) belong to each object. Each Person has its own name, age, city. alice.greet() operates on alice specifically.
  • Static members belong to the class itself, not any object. There's only one of them, shared.
public class Counter {
    static int instancesCreated = 0;     // shared across all Counters
    int count = 0;                       // each Counter has its own

    public Counter() {
        instancesCreated++;              // bumps the shared count
    }
}

You access static members through the class name:

Counter a = new Counter();
Counter b = new Counter();
System.out.println(Counter.instancesCreated);    // 2

In page 04 every method was static because we only had main. Now you can write proper instance methods.

main itself is static because it has to be - java Main needs to call it without first creating a Main object.

Encapsulation: private fields, public methods

The fields in Person above are accessible from anywhere: alice.name = "garbage" works from any code that has an alice. That's often bad - fields are implementation details that might change; you don't want everyone able to set them to anything.

The Java convention: mark fields private, expose methods to access/modify them.

public class Person {
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() { return name; }
    public int getAge() { return age; }

    public void birthday() {
        if (age < 200) age++;            // validation: refuse implausible
    }
}

Now external code can alice.getName() (read) and alice.birthday() (controlled mutation), but cannot directly alice.age = -1. The methods that read are conventionally called getters (getX); the methods that write are setters (setX). Java tools and frameworks rely on the naming convention.

Three access levels (you'll meet a fourth):

Modifier Visible from
public Anywhere
(no modifier - "package-private") Same package only
private Same class only

Default to private for fields; use public for methods that are part of your class's API.

The toString() method

If you print an object without doing anything special, you get something useless:

System.out.println(alice);
// Person@1540e19d

Override toString() to produce something readable:

public class Person {
    private String name;
    private int age;
    // ... constructor, getters ...

    @Override
    public String toString() {
        return "Person{name=" + name + ", age=" + age + "}";
    }
}

The @Override annotation is optional but recommended - it tells the compiler "I'm overriding a method from a parent class" (every Java class inherits from Object, which has a default toString that produces the useless form). If you misspell the method name, the compiler complains because @Override lies.

Now:

System.out.println(alice);    // Person{name=Alice, age=30}

println automatically calls toString(). So does string concatenation: "alice is " + alice builds a string by calling alice.toString().

A complete example

public class Account {
    private final String owner;
    private double balance;

    public Account(String owner, double initialBalance) {
        this.owner = owner;
        this.balance = initialBalance;
    }

    public String getOwner() { return owner; }
    public double getBalance() { return balance; }

    public void deposit(double amount) {
        if (amount <= 0) throw new IllegalArgumentException("amount must be positive");
        balance += amount;
    }

    public void withdraw(double amount) {
        if (amount <= 0) throw new IllegalArgumentException("amount must be positive");
        if (amount > balance) throw new IllegalArgumentException("insufficient funds");
        balance -= amount;
    }

    @Override
    public String toString() {
        return String.format("Account{owner=%s, balance=%.2f}", owner, balance);
    }
}

Notes:

  • private final String owner - owner is immutable after construction. final on fields is one of the strongest invariants Java offers; reach for it whenever a field's value shouldn't change after the object is built.
  • balance += amount is shorthand for balance = balance + amount. Common shortcuts: +=, -=, *=, /=.
  • throw new IllegalArgumentException(...) raises an error (next page, page 07, covers exceptions properly).

Use it:

Account a = new Account("Alice", 100.0);
a.deposit(50);
a.withdraw(30);
System.out.println(a);    // Account{owner=Alice, balance=120.00}

Inheritance (briefly)

Classes can extend other classes - pick up their fields and methods:

public class Animal {
    protected String name;     // protected = visible to subclasses

    public Animal(String name) {
        this.name = name;
    }

    public String speak() {
        return "(generic animal sound)";
    }
}

public class Dog extends Animal {
    public Dog(String name) {
        super(name);           // call parent constructor
    }

    @Override
    public String speak() {
        return name + " says woof";
    }
}

Dog extends Animal - Dog inherits Animal's fields and methods. super(name) calls the parent's constructor. @Override on speak() says "I'm replacing the parent's version."

The modern Java advice: prefer composition over inheritance. Inheritance is tight coupling that bites later. Use it for genuine "is-a" relationships (a Dog IS an Animal); reach for "has-a" (a Garage HAS a Car) by storing instances as fields instead.

Page 08 covers a modern alternative - sealed classes + records - that captures most of what inheritance is used for, more safely.

Exercise

In a new file Rectangle.java:

  1. Define a class Rectangle with two private fields: width and height (both double).

  2. Add a constructor taking width and height.

  3. Add getter methods for both.

  4. Add area() returning double - width * height.

  5. Add perimeter() returning double - 2 * (width + height).

  6. Override toString() to produce something like Rectangle{width=5.0, height=3.0}.

  7. In a Main.java, create a Rectangle(5, 3). Print its area, perimeter, and the rectangle itself.

  8. Stretch: add a method Rectangle scale(double factor) that returns a new Rectangle with both dimensions multiplied by factor. (Don't mutate the original.) Test: r.scale(2).area() for Rectangle(5, 3) should be 60.

What you might wonder

"What's the difference between a class and an object?" A class is the template ("Person - has name, age, city"). An object is one specific instance ("Alice, 30, Lagos"). You define classes once; you create many objects from each.

"What if I don't write a constructor?" Java provides a default no-arg constructor: new Person(). As soon as you write any constructor yourself, the default disappears - you'd need to write a no-arg one explicitly if you want both.

"What's protected?" The fourth access level: visible to subclasses (even in other packages) and to the same package. Used when a parent class wants subclasses to access a field. Rare in modern Java - composition is usually cleaner.

"Should I always write getters?" For classes you're going to use heavily, yes. For small private helpers nobody else sees, often you can skip them and just use the fields directly (in package-private classes). Records (page 08) eliminate the question for data-only types.

Done

You can now: - Define your own classes with fields and methods. - Write constructors (with this for disambiguation). - Distinguish static (class-level) from instance (object-level) members. - Apply encapsulation (private fields, public methods). - Override toString() for readable debug output. - Use final for immutable fields. - Recognize inheritance and the "prefer composition" advice.

You can now model real things. Next page: how Java handles collections - many things at once.

Next: Collections →

06 - Collections

What this session is

About an hour. You'll learn Java's standard collections - List, Set, Map - plus arrays (which Java has but you'll use rarely), the modern factory methods (List.of, Map.of), and how to iterate them. You'll also see a first hint of generics (List<String>) - the full story is in page 09.

Arrays: the low-level thing you'll rarely use

int[] nums = {1, 2, 3, 4, 5};
System.out.println(nums.length);     // 5
System.out.println(nums[0]);          // 1
System.out.println(nums[4]);          // 5
nums[0] = 99;
System.out.println(nums[0]);          // 99

Arrays in Java are fixed-size - int[5] always holds 5 ints; you can't grow it. The size is set when you create the array.

You'll meet arrays as String[] args in main, when working with low-level libraries, and in performance-critical code. For everyday "list of things," use List (next section), not arrays.

Lists: ordered, growable

import java.util.List;
import java.util.ArrayList;

List<String> fruits = new ArrayList<>();
fruits.add("apple");
fruits.add("banana");
fruits.add("cherry");

System.out.println(fruits);              // [apple, banana, cherry]
System.out.println(fruits.size());       // 3
System.out.println(fruits.get(0));       // apple
System.out.println(fruits.get(2));       // cherry

What's new:

  • import java.util.List; and import java.util.ArrayList; - pull in the types.
  • List<String> - "a list of strings." The <String> is a type parameter (generic) - it says what kind of thing the list holds. Without it, the list would hold Object and you'd have to cast on every access. We'll cover generics properly in page 09; for now, always include the type parameter.
  • new ArrayList<>() - create a new ArrayList. The empty <> (called "diamond") lets the compiler infer the type from the variable's declaration.
  • .add(value) - append.
  • .get(index) - read by index (0-based).
  • .size() - count.

List is an interface - a contract that says "this is something you can add to and get from." ArrayList is one implementation. There's also LinkedList (rarely worth the trouble), Vector (legacy - don't use). Always declare as List<T> and instantiate as new ArrayList<>().

Immutable list factories (modern Java)

For lists that won't change, use List.of(...):

List<String> colors = List.of("red", "green", "blue");
System.out.println(colors);     // [red, green, blue]

// colors.add("yellow");        // UnsupportedOperationException at runtime

List.of returns an immutable list - fast, safe, no accidental mutation. Use it for constants and config.

Iterating: the three ways

Way 1 - enhanced for (most common):

for (String fruit : fruits) {
    System.out.println(fruit);
}

Way 2 - indexed for (when you need the index):

for (int i = 0; i < fruits.size(); i++) {
    System.out.println(i + ": " + fruits.get(i));
}

Way 3 - streams (modern, functional):

fruits.stream()
      .filter(f -> f.startsWith("b"))
      .map(String::toUpperCase)
      .forEach(System.out::println);

Streams take a collection through a pipeline of operations: filter keeps matching items, map transforms each, forEach consumes. Useful for "process this collection through several transforms." Full coverage in the senior path; for now, recognize them.

f -> f.startsWith("b") is a lambda - a tiny inline function. Reads as "given f, return whether f starts with b." String::toUpperCase is a method reference - shorthand for s -> s.toUpperCase(). Both are modern Java (8+).

Maps: key → value lookups

import java.util.Map;
import java.util.HashMap;

Map<String, Integer> ages = new HashMap<>();
ages.put("Alice", 30);
ages.put("Bob", 25);
ages.put("Chioma", 35);

System.out.println(ages.get("Alice"));    // 30
System.out.println(ages.size());          // 3
System.out.println(ages.containsKey("Bob"));   // true

Note: Map<String, Integer> - Integer, not int. Map values cannot be primitives; they have to be reference types. Java uses wrapper classes (Integer, Double, Boolean) when a primitive needs to live in a collection. The conversion happens automatically - called autoboxing - but it's worth knowing.

Modify:

ages.put("Dimeji", 40);          // add or update
ages.remove("Bob");              // remove
int aliceAge = ages.getOrDefault("Alice", 0);    // safe lookup with default

Iterate:

for (Map.Entry<String, Integer> entry : ages.entrySet()) {
    System.out.println(entry.getKey() + ": " + entry.getValue());
}

// Or just keys:
for (String name : ages.keySet()) { System.out.println(name); }

// Or just values:
for (int age : ages.values()) { System.out.println(age); }

Map factories

Map<String, Integer> ages = Map.of(
    "Alice", 30,
    "Bob", 25,
    "Chioma", 35
);

Immutable. For more than ~10 entries, use Map.ofEntries(Map.entry(k, v), ...).

Sets: unique values

import java.util.Set;
import java.util.HashSet;

Set<String> visited = new HashSet<>();
visited.add("home");
visited.add("work");
visited.add("home");          // duplicate - ignored

System.out.println(visited);          // [home, work] - order varies
System.out.println(visited.size());   // 2
System.out.println(visited.contains("work"));    // true

Sets hold unique values. Adding a duplicate is silently ignored. Useful for: - Membership checks (contains is fast - much faster than scanning a list). - De-duplication (new HashSet<>(myList) gives unique values). - Set math (addAll, retainAll, removeAll for union/intersection/difference).

Immutable factory: Set.of("home", "work").

Quick comparison

Type Syntax Ordered? Mutable? Duplicates? Use when
Array int[] x = {1,2,3} yes yes (size fixed) yes low-level, rare
List List<T> yes (insertion) yes yes growable ordered collection
Set Set<T> no yes no membership, unique values
Map Map<K,V> no yes keys unique lookup by key

A worked example

import java.util.List;
import java.util.Map;
import java.util.HashMap;

public class WordCount {
    public static void main(String[] args) {
        List<String> words = List.of(
            "the", "quick", "brown", "fox", "jumps",
            "over", "the", "lazy", "dog", "the", "end"
        );

        Map<String, Integer> counts = new HashMap<>();
        for (String word : words) {
            counts.merge(word, 1, Integer::sum);
        }

        counts.forEach((word, count) ->
            System.out.println(word + ": " + count));
    }
}

New idiom: counts.merge(word, 1, Integer::sum). Reads as: "for the entry under word, if it doesn't exist set it to 1; if it does, combine the existing value and 1 using Integer::sum." A common pattern for counting.

counts.forEach(...) takes a lambda receiving each key/value pair.

Output (order varies - HashMap doesn't guarantee order):

the: 3
quick: 1
brown: 1
...

For insertion-order iteration, use LinkedHashMap. For sorted iteration, use TreeMap. Both work as Maps and have the same API.

Exercise

In a new file WordCount.java:

Write a program that counts how many times each word appears in a sentence.

  1. Hardcode this sentence: "the quick brown fox jumps over the lazy dog the end".

  2. Split it into words:

    String[] words = sentence.split(" ");
    
    .split on a String returns an array. (Use .split("\\s+") for any whitespace - that's a regex.)

  3. Build a Map<String, Integer> of counts (use the merge pattern above).

  4. Print each word and its count.

Stretch: print sorted by count (most-frequent first). Hint: convert to a list of Map.Entry, sort with a comparator:

import java.util.ArrayList;
import java.util.Comparator;
var sorted = new ArrayList<>(counts.entrySet());
sorted.sort(Comparator.comparingInt(Map.Entry::getValue).reversed());
for (var entry : sorted) {
    System.out.println(entry.getKey() + ": " + entry.getValue());
}

(Comparator is new; we'll meet it more in real code. For now, treat it as "the sort-key recipe.")

What you might wonder

"Why Integer not int in Map<String, Integer>?" Generics work only on reference types, not primitives. Java auto-converts between int and Integer (autoboxing) so you don't normally notice. The cost: a small allocation per boxed value.

"What's List vs ArrayList?" List is the interface (contract). ArrayList is the implementation. Declare variables and parameters as List<T> so you can swap implementations later. Instantiate with the specific implementation: new ArrayList<>().

"What's the difference between HashMap and LinkedHashMap and TreeMap?" All three implement Map. HashMap - fastest, no order guarantees. LinkedHashMap - preserves insertion order. TreeMap - keeps keys sorted. Pick by what you need.

"What about Stack, Queue, Deque?" - Stack (legacy, don't use). For LIFO, use Deque<T> via new ArrayDeque<>(). - Queue<T> - FIFO; use new ArrayDeque<>() or new LinkedList<>(). - Deque<T> - double-ended; flexible. Useful when you need either end.

Done

You can now: - Recognize arrays (rare in your code, common in others'); use lists instead. - Build List<T>s with ArrayList or List.of. - Build Map<K,V>s with HashMap or Map.of. - Build Set<T>s with HashSet or Set.of. - Iterate all three with enhanced for, indexed for, or streams. - Use the merge idiom for counting. - Distinguish List (interface) from ArrayList (implementation).

Next page: how Java handles things going wrong - exceptions.

Next: Exceptions →

07 - Exceptions

What this session is

About an hour. You'll learn how Java handles things going wrong - files that don't exist, numbers that can't be parsed, division by zero. Java uses exceptions that fly up the call stack until caught. You'll also meet checked vs unchecked exceptions (Java's controversial design choice) and try-with-resources, the modern way to handle cleanup.

A small example

public class Boom {
    public static void main(String[] args) {
        int n = Integer.parseInt("hello");    // boom
        System.out.println(n);
    }
}

Compile and run. You'll see:

Exception in thread "main" java.lang.NumberFormatException: For input string: "hello"
    at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
    at java.base/java.lang.Integer.parseInt(Integer.java:661)
    at java.base/java.lang.Integer.parseInt(Integer.java:777)
    at Boom.main(Boom.java:3)

NumberFormatException is the type of exception. The message says what happened. The stack trace below shows the call chain. Read it bottom-up - the last line (Boom.main(Boom.java:3)) is where the chain started.

If nothing catches the exception, the program crashes.

Catching: try/catch

public class SafeParse {
    public static void main(String[] args) {
        try {
            int n = Integer.parseInt("hello");
            System.out.println("got " + n);
        } catch (NumberFormatException e) {
            System.out.println("not a number: " + e.getMessage());
        }
    }
}

How it reads: - Try the code in the try block. - If it throws NumberFormatException, jump to the catch block. e is the exception object. - If no exception, skip the catch.

e.getMessage() gets the human-readable message. e.printStackTrace() dumps the stack trace (useful for debugging; avoid in production - log the exception properly instead).

Catching multiple exception types

Multiple catch clauses (first match wins, top to bottom):

try {
    risky();
} catch (NumberFormatException e) {
    handleBadNumber(e);
} catch (IOException e) {
    handleIoProblem(e);
} catch (Exception e) {
    System.out.println("something else went wrong: " + e);
}

Multi-catch (Java 7+) - same handling for several types:

try {
    risky();
} catch (NumberFormatException | IllegalStateException e) {
    handleEither(e);
}

finally: code that always runs

try {
    doSomething();
} catch (Exception e) {
    handle(e);
} finally {
    cleanup();    // runs whether or not an exception occurred
}

finally is for cleanup that must happen no matter what - closing files, releasing locks, restoring state. Use sparingly; the modern try-with-resources (below) usually replaces it.

try-with-resources: automatic cleanup

For anything that needs to be closed after use (files, network sockets, database connections), use try-with-resources:

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

public class ReadFile {
    public static void main(String[] args) {
        try (BufferedReader reader = new BufferedReader(new FileReader("notes.txt"))) {
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }
        } catch (IOException e) {
            System.out.println("couldn't read: " + e.getMessage());
        }
    }
}

The resource declared in the try (...) parens is automatically closed when the block exits - whether normally or via exception. No finally needed.

Multiple resources, semicolon-separated:

try (var in = new FileReader("input.txt");
     var out = new FileWriter("output.txt")) {
    // copy
}

Any class implementing AutoCloseable works with try-with-resources. Almost all standard I/O classes do.

Use try-with-resources for everything that needs closing. It's safer than manual close + finally and reads cleaner.

Checked vs unchecked: Java's controversial design

Java has two families of exceptions:

  • Checked exceptions - extend Exception but not RuntimeException. The compiler forces you to handle them (or declare you throw them). Examples: IOException, SQLException.
  • Unchecked exceptions - extend RuntimeException. No compiler enforcement. Examples: NullPointerException, IllegalArgumentException, NumberFormatException, IndexOutOfBoundsException.
// Unchecked - compiler doesn't force anything:
public int parseAge(String s) {
    return Integer.parseInt(s);    // can throw NumberFormatException; no compile error
}

// Checked - compiler forces declaration:
public String readConfig(String path) throws IOException {
    return Files.readString(Path.of(path));    // throws IOException; must declare
}

To call a method that throws a checked exception, you must either: 1. Catch it. 2. Declare your own method throws the same exception, passing the obligation upward.

public String readConfig(String path) throws IOException {
    return Files.readString(Path.of(path));
}

public void useConfig() {
    try {
        String text = readConfig("conf.json");
        // use text
    } catch (IOException e) {
        System.err.println("config unreadable: " + e.getMessage());
    }
}

The debate: checked exceptions force you to think about failures (good!) but also create unwanted noise when you don't have a sensible recovery (bad!). Modern Java practice:

  • Use unchecked exceptions for almost everything in your own code. Extend RuntimeException for custom errors.
  • Tolerate checked exceptions when consuming standard-library APIs that use them (IOException, SQLException).
  • Don't propagate checked exceptions up through many layers. Wrap them as unchecked at the boundary.

Raising your own exceptions

public void withdraw(double amount) {
    if (amount <= 0) {
        throw new IllegalArgumentException("amount must be positive: " + amount);
    }
    if (amount > balance) {
        throw new IllegalStateException("insufficient funds");
    }
    balance -= amount;
}

Use existing exception types when they fit: - IllegalArgumentException - bad argument value. - IllegalStateException - operation not legal in current state. - NullPointerException - usually let Java throw this for you. - UnsupportedOperationException - method not implemented or not allowed.

Define your own when callers should catch this specific kind of failure:

public class InsufficientFundsException extends RuntimeException {
    public InsufficientFundsException(String message) {
        super(message);
    }
}

Extending RuntimeException (not Exception) keeps it unchecked.

Common exceptions you'll meet

Exception Meaning
NullPointerException (NPE) called a method on null
ArrayIndexOutOfBoundsException array index out of range
IndexOutOfBoundsException similar, for List.get(n) etc.
ClassCastException tried to cast to wrong type
NumberFormatException Integer.parseInt got bad text
IllegalArgumentException bad argument
IllegalStateException wrong state for the operation
IOException I/O failed (checked)
RuntimeException base of all unchecked
Exception base of all (use sparingly)

The classic NPE

NullPointerException is Java's most-shipped bug. It happens when you call a method on a null reference:

String s = getMessage();   // returns null sometimes
int len = s.length();      // NPE if s was null

Modern fixes: - Objects.requireNonNull(s, "s") - fail loudly with a useful message if null is passed. - Optional<String> - explicit "value may or may not be there" type. - @NonNull annotations (JSR 305, Checker Framework, IntelliJ inspections) - static checks that catch potential NPEs at compile time.

Java doesn't have null-safety baked in (like Kotlin does). The discipline: be explicit about which references can be null and check at boundaries.

Exercise

In a new file Parse.java:

  1. Write a method parsePositive(String s) that returns an int:
  2. Parse s as an integer using Integer.parseInt.
  3. If parsing fails, catch the NumberFormatException and throw a new IllegalArgumentException with a useful message like "not a number: " + s.
  4. If the parsed number is ≤ 0, throw IllegalArgumentException with "must be positive, got " + n.
  5. Otherwise return the number.

  6. In main, loop over these inputs and print success or error for each:

    String[] inputs = {"42", "hello", "-5", "0", "100"};
    
    Use try-catch around each call to parsePositive. Format: 42 -> 42 for success, hello -> error: not a number: hello for failure.

  7. Stretch: create your own BadInputException extends RuntimeException. Have parsePositive throw it instead of IllegalArgumentException. Update main's catch accordingly.

What you might wonder

"When should I make my exception checked vs unchecked?" Modern advice: unchecked, unless you have a very strong reason. Checked forces callers to handle, which sounds good but in practice produces "swallow the exception" anti-patterns. The standard library still uses checked exceptions (IOException etc.) for historical reasons; you'll have to deal with them.

"What's the difference between throw and throws?" throw (verb) raises an exception: throw new RuntimeException("bad");. throws (clause) declares that a method might throw a checked exception: void foo() throws IOException { ... }. Easy to confuse; read aloud as "I throw" vs "I throws".

"Should I ever catch Throwable or Error?" No. Throwable is the root of both Exception and Error. Error (OutOfMemoryError, StackOverflowError) means "the JVM is in trouble" - there's nothing useful you can do; let it propagate and crash. Catch Exception at most.

"What about try without catch but with finally?" Legal: try { ... } finally { ... }. Useful when you want cleanup but can't actually handle the exception meaningfully. Almost always replaced by try-with-resources in modern code.

Done

You can now: - Read stack traces (bottom-up). - Catch exceptions with try/catch. - Use multi-catch and finally. - Use try-with-resources for automatic cleanup. - Distinguish checked from unchecked exceptions. - Raise your own exceptions. - Define custom exception types.

Next page: modern Java's superpowers - records, sealed classes, pattern matching.

Next: Records, sealed, pattern matching →

08 - Records, Sealed Classes, Pattern Matching

What this session is

About an hour. You'll learn three modern Java features that, combined, let you model data and decisions in a way old Java couldn't. Records replace boilerplate-heavy data classes. Sealed classes restrict which classes can extend a type. Pattern matching in switch and instanceof makes type-based decisions clean.

If you've heard Java is "verbose" - this is the page that fixes that reputation.

Records: data classes without boilerplate

Recall the Account class from page 05:

public class Account {
    private final String owner;
    private double balance;

    public Account(String owner, double balance) {
        this.owner = owner;
        this.balance = balance;
    }

    public String getOwner() { return owner; }
    public double getBalance() { return balance; }

    @Override
    public String toString() {
        return "Account{owner=" + owner + ", balance=" + balance + "}";
    }

    @Override
    public boolean equals(Object o) { /* tedious */ }

    @Override
    public int hashCode() { /* tedious */ }
}

20+ lines for what's essentially "a pair of name and balance." Modern Java (16+):

public record Account(String owner, double balance) {}

One line. Java generates: - A constructor taking owner and balance. - Accessor methods owner() and balance() (note: no get prefix - the Java team chose shorter names). - equals, hashCode, toString - all the things you'd write by hand. - The class is implicitly final and the components are implicitly private final.

Use it:

var alice = new Account("Alice", 100.0);
System.out.println(alice.owner());     // Alice
System.out.println(alice.balance());   // 100.0
System.out.println(alice);             // Account[owner=Alice, balance=100.0]

Two records with the same component values are .equals - they're treated as the same data:

var a = new Account("Alice", 100);
var b = new Account("Alice", 100);
System.out.println(a.equals(b));    // true

Records with validation

The auto-generated constructor accepts anything. For validation, write a compact constructor:

public record Account(String owner, double balance) {
    public Account {
        if (balance < 0) {
            throw new IllegalArgumentException("balance cannot be negative");
        }
        if (owner == null || owner.isBlank()) {
            throw new IllegalArgumentException("owner required");
        }
    }
}

No parameter list, no this.x = x. The validation runs before Java assigns the fields. If validation passes, Java does the assignment automatically.

Records with extra methods

Records can have methods like any other class:

public record Rectangle(double width, double height) {
    public double area() {
        return width * height;
    }

    public double perimeter() {
        return 2 * (width + height);
    }

    public Rectangle scale(double factor) {
        return new Rectangle(width * factor, height * factor);
    }
}

Records cannot have non-static instance fields beyond their components. They can have static fields, methods, and nested types. They're for immutable bundles of data with optional logic, not for arbitrarily-shaped classes.

When to use a record vs a class

Use a record when Use a class when
The type is a bundle of fixed data You need mutable state
Two values with the same data should be equal Identity matters (one object ≠ another with same data)
You want immutability You need to inherit from another class
You're modeling a value (point, money, response DTO) You're modeling behavior (service, controller, manager)

Modern Java code uses records heavily for DTOs, value types, and many domain models.

Sealed classes/interfaces: closed hierarchies

Normally, anyone can extend your public class. Sealed types restrict who can:

public sealed interface Shape permits Circle, Square, Triangle {}

public record Circle(double radius) implements Shape {}
public record Square(double side) implements Shape {}
public record Triangle(double base, double height) implements Shape {}

sealed interface Shape permits ... says: "only these three types are allowed to implement Shape." Anyone trying to add a fourth gets a compile error.

Why this matters: the compiler now knows the complete list of Shape subtypes. When you write a switch over a Shape, Java can verify you've covered every case. This combines with pattern matching to give you exhaustive case analysis - the kind functional languages have.

Pattern matching with switch

The "old way" of working with a polymorphic value (Shape) was a chain of instanceof + cast:

double area;
if (shape instanceof Circle) {
    Circle c = (Circle) shape;
    area = Math.PI * c.radius() * c.radius();
} else if (shape instanceof Square) {
    Square s = (Square) shape;
    area = s.side() * s.side();
} else if (shape instanceof Triangle) {
    Triangle t = (Triangle) shape;
    area = 0.5 * t.base() * t.height();
} else {
    throw new IllegalStateException("unknown shape");
}

Modern Java (21+):

double area = switch (shape) {
    case Circle c   -> Math.PI * c.radius() * c.radius();
    case Square s   -> s.side() * s.side();
    case Triangle t -> 0.5 * t.base() * t.height();
};

What's new: - case Circle c - matches when shape is a Circle; binds the matched value as c. - No default needed - because Shape is sealed, the compiler knows the three subtypes are the complete list. If you add a Pentagon implements Shape, every switch over Shape immediately gets a compile error pointing to where you forgot the case.

This is exhaustive pattern matching - the strongest correctness affordance modern Java offers. Use sealed + records + pattern-matching switch for any "value of several possible shapes" model.

Pattern matching with instanceof

For one-off type checks, pattern matching is built into instanceof too (Java 16+):

if (shape instanceof Circle c) {
    System.out.println("circle with radius " + c.radius());
}

The c is bound when the instanceof is true. Inside the if, you have c already cast - no manual cast needed.

Record patterns: destructuring (Java 21+)

You can match on the components of a record:

double area = switch (shape) {
    case Circle(double r)               -> Math.PI * r * r;
    case Square(double s)               -> s * s;
    case Triangle(double b, double h)   -> 0.5 * b * h;
};

Cleaner - no .radius(), just the named component directly. Especially nice for nested records:

record Point(int x, int y) {}
record Line(Point start, Point end) {}

Line line = new Line(new Point(0, 0), new Point(3, 4));

double length = switch (line) {
    case Line(Point(int x1, int y1), Point(int x2, int y2)) ->
        Math.hypot(x2 - x1, y2 - y1);
};

A complete worked example

Modeling a small "command" type with sealed + record + switch:

public sealed interface Command permits Add, Remove, List_, Quit {}

public record Add(String item) implements Command {}
public record Remove(String item) implements Command {}
public record List_() implements Command {}    // empty record
public record Quit() implements Command {}

public class Cart {
    private final java.util.List<String> items = new java.util.ArrayList<>();

    public void handle(Command c) {
        switch (c) {
            case Add(String item)    -> { items.add(item); System.out.println("added " + item); }
            case Remove(String item) -> { items.remove(item); System.out.println("removed " + item); }
            case List_()             -> System.out.println(items);
            case Quit()              -> System.out.println("bye");
        }
    }
}

That switch is exhaustive - add a fifth Command type and the compiler will tell you exactly where to update.

Exercise

In a new file Shapes.java:

  1. Define sealed interface Shape permits Circle, Square, Triangle.
  2. Define records Circle(double radius), Square(double side), Triangle(double base, double height) - each implements Shape.
  3. Write a static method area(Shape s) using a switch expression with record patterns (no default).
  4. In main, create a list of three shapes (one of each) and print each shape's area.

Expected output:

Circle[radius=2.0] -> 12.566370614359172
Square[side=3.0] -> 9.0
Triangle[base=4.0, height=5.0] -> 10.0

Stretch: add a fourth shape type (e.g., Pentagon). Remove the default case from your switch and try to compile. You should get a clear "non-exhaustive" error pointing at the switch. Add the Pentagon case; recompile.

What you might wonder

"Why no default in the switch?" Because the sealed hierarchy is closed. The compiler can prove you handled every possibility. Adding default defeats the exhaustiveness check - you want the compile error when you add a new subtype, not silent fallback.

"Can I extend a record?" No - records are implicitly final. The whole point is "this represents data; nothing should be in the way."

"What about Lombok?" Lombok is a third-party library that generates equals/hashCode/toString/constructors via annotations (@Data, @Builder). Records cover most of what Lombok's @Value and @Data did, without a third-party tool. New code: use records. Existing Lombok code: leave it.

"Are there other patterns?" Yes - null patterns, var patterns, and guard conditions (case Circle c when c.radius() > 100 -> ...). Look these up when you encounter them; they extend the pattern-matching story.

"Does this slow things down?" Records compile to regular classes; switch-with-patterns compiles to efficient bytecode (invokedynamic under the hood). No runtime overhead vs hand-written equivalents.

Done

You can now: - Define records for immutable data bundles (replacing 20-line classes). - Add validation via compact constructors. - Define sealed hierarchies for closed sets of types. - Use pattern matching in instanceof and switch. - Use record patterns to destructure values. - Recognize when the compiler is checking exhaustiveness for you.

This is one of the most productivity-changing modern Java features. Use it heavily.

Next page: generics - the type-parameter machinery you've been using implicitly with List<String>, explained properly.

Next: Generics →

09 - Generics

What this session is

About an hour. You'll learn how Java's generics work - the <T> syntax you've already used with List<String> and Map<String, Integer>. Generics let you write code that works on any type, with the compiler checking types at compile time.

The problem generics solve

Before Java 5 (which added generics), a list was just a List - it held Object, and you had to cast on every access:

List items = new ArrayList();
items.add("hello");
items.add(42);                 // legal - list takes Object
String s = (String) items.get(0);
String wrong = (String) items.get(1);    // ClassCastException at runtime

Mistakes weren't caught until you actually ran the bad cast. Generics fix that:

List<String> items = new ArrayList<>();
items.add("hello");
items.add(42);                 // COMPILE ERROR - int is not String
String s = items.get(0);       // no cast needed; compiler knows it's a String

The compiler tracks the type parameter through every operation.

Type parameters: the basics

A type parameter is a placeholder for a type, written in angle brackets:

public class Box<T> {
    private T value;

    public Box(T value) {
        this.value = value;
    }

    public T get() {
        return value;
    }

    public void set(T value) {
        this.value = value;
    }
}

<T> is the type parameter. Inside the class, T stands in for whatever type the caller specifies.

Use it:

Box<String> nameBox = new Box<>("Alice");
String name = nameBox.get();          // T is String here

Box<Integer> ageBox = new Box<>(30);
int age = ageBox.get();               // T is Integer here

// nameBox.set(42);                   // COMPILE ERROR - wrong type

The compiler enforces type consistency per instance.

By convention, type parameters are single uppercase letters: - T - type (generic). - E - element (used in collections). - K, V - key, value (used in maps). - R - return type.

Generic methods

Just classes - methods can have type parameters too:

public static <T> T first(List<T> list) {
    return list.get(0);
}

The <T> before the return type declares the type parameter. The compiler infers T from the argument:

List<String> names = List.of("Alice", "Bob");
String firstName = first(names);    // T inferred as String

List<Integer> nums = List.of(1, 2, 3);
int firstNum = first(nums);          // T inferred as Integer

Useful for utility methods that work uniformly across types.

Bounded type parameters: extends

Sometimes you want T to be something specific (or one of its subtypes). Use extends:

public static <T extends Number> double sumAll(List<T> list) {
    double total = 0;
    for (T n : list) {
        total += n.doubleValue();      // OK - Number has doubleValue
    }
    return total;
}

<T extends Number> says "T is some subtype of Number." Inside the method, you can call any Number method on T. The caller is free to pass List<Integer>, List<Double>, List<Long> - anything that's-a Number.

Try to call sumAll(List.of("hello", "world")) and the compiler refuses: String is not a Number.

Wildcards: ?, ? extends T, ? super T

You'll see wildcards in real Java code. Briefly:

  • List<?> - "a list of unknown type." Read-only (you can't add to it; the compiler doesn't know what type would be safe). Useful for "I just want to iterate or count."
  • List<? extends Number> - "a list of some subtype of Number." Read-only-ish: you can read items as Number; you can't add (you don't know if Integer would be accepted by a List<Double>).
  • List<? super Integer> - "a list of some supertype of Integer." Write-only-ish: you can add Integers; reading gives you Object.

The mnemonic: PECS - "Producer Extends, Consumer Super." A list you read from (producer) uses extends; a list you write to (consumer) uses super.

You'll mostly use simple <T> and <T extends X>. Wildcards appear in library APIs (Collections.sort etc.); recognize them when you see them.

Generics and runtime: type erasure

Java's generics are a compile-time feature. At runtime, all generic types are erased to their bounds (or Object). A List<String> and a List<Integer> are both just List at runtime.

Practical consequence: you cannot do this:

public <T> T create() {
    return new T();         // COMPILE ERROR - can't instantiate T
}

Or this:

if (x instanceof List<String>) { ... }   // COMPILE ERROR - can't check generic type

You can do instanceof List<?> (the wildcard is fine; the specific type isn't checkable).

Workarounds (you'll see them in real code): - Pass Class<T> as a parameter: <T> T create(Class<T> cls) { return cls.getDeclaredConstructor().newInstance(); }. - Use specialized libraries (Gson, Jackson) that handle generic type info via TypeToken/TypeReference.

For now, just know: generics disappear at runtime; if you need type info at runtime, pass Class<T> explicitly.

A practical example: a generic stack

import java.util.ArrayList;
import java.util.List;

public class Stack<T> {
    private final List<T> items = new ArrayList<>();

    public void push(T item) {
        items.add(item);
    }

    public T pop() {
        if (items.isEmpty()) {
            throw new IllegalStateException("stack is empty");
        }
        return items.remove(items.size() - 1);
    }

    public T peek() {
        if (items.isEmpty()) {
            throw new IllegalStateException("stack is empty");
        }
        return items.get(items.size() - 1);
    }

    public int size() { return items.size(); }
    public boolean isEmpty() { return items.isEmpty(); }
}

Use it for any type:

Stack<String> messages = new Stack<>();
messages.push("first");
messages.push("second");
System.out.println(messages.pop());    // second

Stack<Integer> nums = new Stack<>();
nums.push(1);
nums.push(2);
System.out.println(nums.peek());       // 2

One class, type-safe usage for any T.

(In real code, prefer Deque<T> via ArrayDeque over rolling your own stack - but this is a nice generics exercise.)

Records with generics

Records support type parameters too:

public record Pair<A, B>(A first, B second) {}

var nameAge = new Pair<>("Alice", 30);     // Pair<String, Integer>
System.out.println(nameAge.first());        // Alice
System.out.println(nameAge.second());       // 30

Useful for "two related things" when you don't want to define a class for it. (For more named structure, define a real record with named components.)

Exercise

In a new file Generics.java:

  1. Write a generic class Box<T> with set(T), get(), and toString() that prints Box{value=...}.

  2. Write a generic method static <T> List<T> repeat(T value, int times) that returns a list with value repeated times times. (Use a for loop and List.add.)

  3. In main:

    Box<String> b = new Box<>("hi");
    System.out.println(b);
    
    List<Integer> ones = repeat(1, 5);
    System.out.println(ones);     // [1, 1, 1, 1, 1]
    
    List<String> hellos = repeat("hello", 3);
    System.out.println(hellos);   // [hello, hello, hello]
    

  4. Stretch: write a generic record Result<T> with two cases. Use a sealed interface and two implementing records:

    public sealed interface Result<T> permits Success, Failure {}
    public record Success<T>(T value) implements Result<T> {}
    public record Failure<T>(String error) implements Result<T> {}
    
    Write a method that processes a Result<String> with a switch expression and pattern matching, printing either the value or the error.

What you might wonder

"Why all the angle brackets?" Generics are a powerful feature for type safety. The cost is verbosity at declaration sites - but most use sites benefit from inference (new ArrayList<>()).

"Can primitives be type arguments?" No - only reference types. List<int> is illegal; you write List<Integer> and rely on autoboxing. Project Valhalla (in development) aims to add value types that would let primitives work as type arguments, but it's not in 25 yet.

"What's the difference between List<Object> and List<?>?" - List<Object> is "a list whose element type is Object" - strict; not assignable from List<String>. - List<?> is "a list of unknown type" - permissive; assignable from any List<T>. Read-only.

"What about Kotlin's generics being 'better'?" Kotlin keeps generic type info at runtime for inline functions (reified type parameters). Java doesn't. It's a real ergonomic win in Kotlin; in Java you carry Class<T> around when you need it. Live with it.

"Why are wildcards confusing?" Because they encode subtype variance, which is genuinely subtle. Read PECS, write code that uses <T> without wildcards where you can, accept that wildcards exist in real APIs.

Done

You can now: - Read and write generic classes and methods. - Use bounded type parameters (<T extends X>). - Recognize wildcards (?, ? extends, ? super). - Understand the basics of type erasure and its workarounds. - Use generic records.

Generics are one of Java's foundational type-system features. You now have the vocabulary to read any real Java code.

Next page: testing your own code with JUnit 5.

Next: Tests →

10 - Tests

What this session is

About an hour. You'll learn how to write tests for your Java code with JUnit 5 - the standard test framework. We'll also touch AssertJ (a much nicer assertion library) and the basics of running tests via Maven (which we'll cover properly in page 11).

By the end you can verify your own code works, watch it fail when you break it, and read the test files in any Java OSS project.

Why tests

When you change code, you might break something that used to work. Without tests, you find out when a user does.

A test is a small program that calls your code with known inputs and checks the outputs. Run them after every change. If they pass, keep going. If one fails, you know what broke.

This sounds obvious. Beginner programmers skip it. Don't.

Setting up a project with Maven

Real Java projects use a build tool. The two main ones are Maven (XML-based, mature, ubiquitous) and Gradle (Groovy/Kotlin DSL, faster, more flexible). For learning, we'll use Maven - it's slightly more verbose but more predictable.

If Maven isn't installed: - macOS: brew install maven. - Linux: sudo apt install maven or sudo dnf install maven. - Windows: download from maven.apache.org, unzip, add bin/ to PATH.

Verify: mvn --version.

Create a Maven project:

mvn archetype:generate -DgroupId=com.example -DartifactId=mathutils \
    -DarchetypeArtifactId=maven-archetype-quickstart \
    -DarchetypeVersion=1.4 -DinteractiveMode=false
cd mathutils

This creates a project skeleton:

mathutils/
├── pom.xml
└── src/
    ├── main/java/com/example/
    │   └── App.java
    └── test/java/com/example/
        └── AppTest.java
  • src/main/java/ - your code.
  • src/test/java/ - your tests.
  • pom.xml - Maven's project file (dependencies, build config).

Update pom.xml for JUnit 5 and modern Java

Open pom.xml. Find the <dependencies> section. Replace its content with:

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.11.0</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.26.3</version>
        <scope>test</scope>
    </dependency>
</dependencies>

Also add a <properties> section near the top to pin Java 21+ (adjust to your installed version):

<properties>
    <maven.compiler.source>21</maven.compiler.source>
    <maven.compiler.target>21</maven.compiler.target>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

mvn test should now work.

Your first test

Replace src/main/java/com/example/App.java with:

package com.example;

public class MathUtils {
    public static int add(int a, int b) {
        return a + b;
    }

    public static boolean isEven(int n) {
        return n % 2 == 0;
    }
}

Replace src/test/java/com/example/AppTest.java with:

package com.example;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;

class MathUtilsTest {

    @Test
    void addsTwoNumbers() {
        assertThat(MathUtils.add(2, 3)).isEqualTo(5);
    }

    @Test
    void isEvenForEvenNumbers() {
        assertThat(MathUtils.isEven(4)).isTrue();
    }

    @Test
    void isOddForOddNumbers() {
        assertThat(MathUtils.isEven(7)).isFalse();
    }
}

What's new:

  • @Test - JUnit 5 annotation marking a test method. The method takes no arguments and returns void.
  • assertThat(actual).isEqualTo(expected) - AssertJ assertion. Reads like English: "assert that the result is equal to 5." On failure, the error message is precise.

The test method is void and package-private (no public/private). JUnit 5 doesn't require public for test methods (Junit 4 did).

Run:

mvn test

You should see, near the end:

[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] ----------------------------------------
[INFO] BUILD SUCCESS

Three green tests.

Watching a test fail (do this)

Open MathUtils.java. Change add to return a - b. Run mvn test.

You should see something like:

[ERROR] Failures:
[ERROR]   MathUtilsTest.addsTwoNumbers:11
expected: 5
 but was: -1
[INFO] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0
[INFO] BUILD FAILURE

The test caught the bug. Notice how informative: the line, the expected, the actual. Change add back, re-run, green.

Parameterized tests: many cases, one method

When you have many cases for the same method, don't write test1, test2. Parameterize:

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

@ParameterizedTest
@CsvSource({
    "0, true",
    "1, false",
    "2, true",
    "-4, true",
    "-7, false",
    "1000, true",
})
void isEvenChecksParity(int n, boolean expected) {
    assertThat(MathUtils.isEven(n)).isEqualTo(expected);
}

@CsvSource provides comma-separated values; each line becomes one test case. JUnit generates a distinct test for each case with a useful name. Add parameterized-tests dependency to your pom.xml if it isn't already (it comes with junit-jupiter aggregate).

This is the idiomatic Java testing shape. You'll see it in 80% of test files.

Testing exceptions

import static org.junit.jupiter.api.Assertions.assertThrows;

@Test
void divideByZeroThrows() {
    assertThrows(ArithmeticException.class, () -> {
        int x = 10 / 0;
    });
}

assertThrows(ExceptionClass.class, lambda) runs the lambda and asserts that it throws an exception of the given type. The lambda lets you pass code without executing it immediately.

AssertJ has a fluent alternative:

import static org.assertj.core.api.Assertions.assertThatThrownBy;

@Test
void divideByZeroThrowsAj() {
    assertThatThrownBy(() -> {
        int x = 10 / 0;
    }).isInstanceOf(ArithmeticException.class)
      .hasMessageContaining("zero");
}

Lifecycle: setup and teardown

import org.junit.jupiter.api.BeforeEach;

class CalculatorTest {
    private Calculator calc;

    @BeforeEach
    void setUp() {
        calc = new Calculator();
    }

    @Test
    void addsCorrectly() {
        assertThat(calc.add(2, 3)).isEqualTo(5);
    }

    @Test
    void subtractsCorrectly() {
        assertThat(calc.sub(5, 3)).isEqualTo(2);
    }
}

@BeforeEach runs before every test method - fresh state per test. There's also @AfterEach, @BeforeAll, @AfterAll (the "All" ones run once for the whole class; must be static unless you add @TestInstance(PER_CLASS)).

Running tests

From the project root:

mvn test                          # run all tests
mvn test -Dtest=MathUtilsTest     # run one class
mvn test -Dtest=MathUtilsTest#addsTwoNumbers   # one method

Inside IntelliJ or VS Code, you can right-click a test and "Run" - gives you the same output in the IDE.

A note on assertions

You'll see three assertion styles in real Java code:

  1. JUnit's built-in (assertEquals, assertTrue) - works, ugly failure messages.
  2. Hamcrest (assertThat(x, is(equalTo(5)))) - older fluent API.
  3. AssertJ (assertThat(x).isEqualTo(5)) - modern, best error messages, most expressive.

Prefer AssertJ for new tests. Recognize the others.

AssertJ gets really nice for collections:

assertThat(myList).hasSize(3)
    .contains("apple")
    .doesNotContain("durian")
    .first().isEqualTo("apple");

Exercise

In the same Maven project, in a new file src/main/java/com/example/WordTools.java:

package com.example;

public class WordTools {
    public static int wordCount(String s) {
        return s.trim().isEmpty() ? 0 : s.trim().split("\\s+").length;
    }

    public static boolean isPalindrome(String s) {
        s = s.toLowerCase();
        int i = 0, j = s.length() - 1;
        while (i < j) {
            if (s.charAt(i) != s.charAt(j)) return false;
            i++;
            j--;
        }
        return true;
    }
}

In src/test/java/com/example/WordToolsTest.java, write parameterized tests:

  • wordCount: "" → 0, "hello" → 1, "hello world" → 2, " many spaces here " → 3.
  • isPalindrome: "" → true, "a" → true, "racecar" → true, "hello" → false, "Racecar" → true.

Run mvn test. All should pass.

Then break each method on purpose, watch the relevant test fail, fix it, watch it pass.

Stretch: add a mostCommonWord(String s) method returning the most-frequent word. Write parameterized tests including a tie-breaking case.

What you might wonder

"Where do tests live in real projects?" Always in src/test/java/ (or src/test/kotlin/ for Kotlin) by Maven/Gradle convention. The test source set is separate from the main source set; tests can't be packaged into the production JAR.

"What about JUnit 4 vs 5?" Junit 5 is the modern version (released 2017). Junit 4 still exists in older codebases. Differences: @Test import path (org.junit.Test vs org.junit.jupiter.api.Test), annotation names (@Before vs @BeforeEach), extension model. Use Junit 5 in new code; recognize Junit 4 when you see it.

"What about Mockito? Should I learn it now?" Mockito is for replacing real dependencies (databases, network) with fakes during a test. Useful in real projects but adds a learning curve. Skip for now; learn it when you need it (you'll know).

"What about Testcontainers?" For tests that need a real database/queue/etc., Testcontainers starts a Docker container during the test. Powerful but heavy setup. Out of scope here; mentioned for awareness.

Done

You can now: - Set up a Maven project with JUnit 5 and AssertJ. - Write tests in @Test methods using assertThat. - Use @ParameterizedTest with @CsvSource for table-driven tests. - Test exceptions with assertThrows / assertThatThrownBy. - Use @BeforeEach for fresh-state setup. - Run tests with mvn test from the command line.

You can now verify your own code. More importantly, you can read test files in any real Java project.

Next page: packages, modules, Maven, and pulling in code other people wrote.

Next: Packages, modules, Maven →

11 - Packages, Modules, Maven

What this session is

About an hour. You'll learn how Java code is organized - packages (folder of related classes), modules (collection of packages with controlled visibility), and Maven (the build tool that manages your dependencies, compiles your code, and runs your tests). You'll publish a small library to your local Maven cache and consume it from another project.

Packages: organizing classes into folders

A package is a folder of classes that work together. Every Java file declares which package it belongs to:

package com.example.greet;

public class Hello {
    public static String say(String name) {
        return "Hello, " + name;
    }
}

That file lives at src/main/java/com/example/greet/Hello.java - the folder structure must match the package name.

To use it from another package:

package com.example.app;

import com.example.greet.Hello;

public class Main {
    public static void main(String[] args) {
        System.out.println(Hello.say("Alice"));
    }
}

Convention: package names are lowercase, dotted, reverse-domain-style (com.companyname.product.subsystem). Avoids name collisions across libraries from different organizations.

Access modifiers (revisited)

You met public and private in page 05. The full list:

Modifier Visible from
public Anywhere
protected Same package + subclasses (even in other packages)
(none - "package-private") Same package only
private Same class only

Package-private (no modifier) is the default and underused. Use it for helpers that should only be visible within a single package - internal collaboration, not the public API.

Modules: introduced in Java 9, optional

Modules group packages and control visibility between modules. They're declared in module-info.java at the root of a module:

// src/main/java/module-info.java
module com.example.greetapp {
    requires java.base;       // implicit, but you can be explicit
    requires java.sql;        // depending on the JDK's sql module
    exports com.example.greet;     // make this package visible to others
}

The honest assessment in 2026: most applications don't use modules. The Java standard library is modular (you'll see things like java.base, java.sql, java.net.http), but application code generally lives on the classpath without module-info.java. You'll meet modules when working with:

  • Custom JRE images via jlink (ship a tiny JVM with only the modules you need).
  • Native images via GraalVM.
  • Some libraries that ship as proper modules.

For your first contribution, you almost certainly don't need to write module-info.java. Recognize it when you see it.

Maven: the build tool

You set up a Maven project in page 10. Let's understand what's actually happening.

The pom.xml

The project's manifest. Key sections:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" ...>
    <modelVersion>4.0.0</modelVersion>

    <!-- Identity -->
    <groupId>com.example</groupId>
    <artifactId>mathutils</artifactId>
    <version>1.0.0</version>

    <!-- Build settings -->
    <properties>
        <maven.compiler.source>21</maven.compiler.source>
        <maven.compiler.target>21</maven.compiler.target>
    </properties>

    <!-- Dependencies -->
    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.11.0</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
</project>
  • groupId - your organization (reverse-domain). For personal projects, anything works (com.example, io.github.yourname).
  • artifactId - the project's name.
  • version - semver typically (1.0.0, 1.0.0-SNAPSHOT for in-development).
  • <dependencies> - what you depend on. Each <dependency> has groupId + artifactId + version (and optional scope).

The combination <groupId>:<artifactId>:<version> uniquely identifies an artifact in Maven Central (the public repository).

Common Maven commands

Command What it does
mvn compile Compile main code (but not tests).
mvn test Run tests.
mvn package Build a JAR in target/.
mvn install Install the JAR to your local Maven repo (~/.m2/repository/).
mvn clean Delete target/.
mvn dependency:tree Show your dependency tree (incl. transitive deps).
mvn versions:display-dependency-updates Check for newer versions of deps.

mvn package is the daily driver. mvn install lets other local projects pull your library.

Dependency scopes

A dependency's <scope> controls when it's available:

  • compile (default) - available everywhere.
  • test - only when compiling/running tests. (JUnit goes here.)
  • provided - needed to compile but supplied by the runtime (servlet APIs, for example).
  • runtime - needed at runtime but not compile time (JDBC drivers).

Stick to compile and test for most things.

Using Maven Central

Maven Central (central.sonatype.com) is the public repository of Java libraries. You declare a dependency in your pom.xml; Maven downloads it on first build.

A real example - add JSON support via Jackson:

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.18.0</version>
</dependency>

After mvn install or any build, Jackson is downloaded to ~/.m2/repository/ and available to import:

import com.fasterxml.jackson.databind.ObjectMapper;

public class JsonDemo {
    public static void main(String[] args) throws Exception {
        var mapper = new ObjectMapper();
        var json = mapper.writeValueAsString(java.util.Map.of("name", "Alice", "age", 30));
        System.out.println(json);    // {"name":"Alice","age":30}
    }
}

Search Maven Central by artifact name; copy the <dependency> snippet; paste into your pom; rebuild.

A small multi-module example

Let's build two interacting projects: a library and an app that uses it.

Project 1 - the library:

mvn archetype:generate -DgroupId=com.example -DartifactId=greetlib \
    -DarchetypeArtifactId=maven-archetype-quickstart \
    -DarchetypeVersion=1.4 -DinteractiveMode=false
cd greetlib

Edit src/main/java/com/example/Hello.java (rename if needed):

package com.example;

public class Hello {
    public static String say(String name) {
        return "Hello, " + name;
    }
}

Install to your local Maven repo:

mvn clean install

Project 2 - the app:

cd ..
mvn archetype:generate -DgroupId=com.example -DartifactId=greetapp ...
cd greetapp

Add the library as a dependency in pom.xml:

<dependency>
    <groupId>com.example</groupId>
    <artifactId>greetlib</artifactId>
    <version>1.0-SNAPSHOT</version>
</dependency>

Use it:

package com.example;

import com.example.Hello;

public class Main {
    public static void main(String[] args) {
        System.out.println(Hello.say("Alice"));
    }
}
mvn compile
mvn exec:java -Dexec.mainClass=com.example.Main

You've now consumed your own library from another project, the same way you'd consume Jackson.

Gradle (alternative to Maven)

Gradle is the other major build tool. Uses Groovy or Kotlin DSL instead of XML:

plugins { id("java") }

dependencies {
    implementation("com.fasterxml.jackson.core:jackson-databind:2.18.0")
    testImplementation("org.junit.jupiter:junit-jupiter:5.11.0")
}

Faster than Maven for incremental builds; more flexible; harder to learn. Most Android projects use Gradle. JVM server projects use either (Maven is more common in enterprise).

Pick the one your target project uses. For learning, Maven is the gentler intro.

Exercise

Set up a real Maven project that uses an external library.

  1. Start a new Maven project (mvn archetype:generate ...).
  2. Add Jackson (com.fasterxml.jackson.core:jackson-databind:2.18.0) as a dependency.
  3. Write a Main.java that:
  4. Creates a Person record (or class) with name and age.
  5. Serializes it to JSON with ObjectMapper.writeValueAsString.
  6. Deserializes the JSON back to a Person with ObjectMapper.readValue.
  7. Prints both.
  8. Run with mvn compile exec:java -Dexec.mainClass=com.example.Main.
  9. Stretch: add a list of three Persons; serialize the whole list; deserialize back. (You'll need TypeReference<List<Person>> - search Stack Overflow for "Jackson List deserialize".)

What you might wonder

"Why does Maven download so many files on the first build?" It's pulling Jackson + all of Jackson's transitive dependencies. Stored in ~/.m2/repository/; shared across all your projects.

"What's the difference between <scope>compile and <scope>provided?" compile - bundled in the final JAR. provided - present at compile time but the runtime provides it (e.g., servlet API). Use provided for things like servlet libs; use compile for everything else.

"What's a Maven parent POM?" A pom.xml that other POMs inherit from (via <parent>). Used for multi-module projects to share config. Out of scope for one-module learning.

"Should I use the archetype:generate quickstart or start from scratch?" Either works. The quickstart gives you a working POM and folder structure to mutate. Some people prefer hand-rolling - fine if you know what to type.

"What's Maven Wrapper (mvnw)?" A script that downloads the right Maven version automatically. Many projects ship mvnw so users don't need to install Maven globally. If a project has mvnw, use ./mvnw instead of mvn.

Done

You can now: - Organize classes into packages with package declarations. - Distinguish access modifiers (public, protected, package-private, private). - Recognize modules (module-info.java) without needing to use them. - Write and edit a pom.xml. - Add dependencies from Maven Central. - Install your own library locally and consume it from another project. - Recognize Gradle as the alternative.

You've covered everything you need to read and write real Java codebases. Remaining pages: applying this to OSS contribution.

Next: Reading other people's code →

12 - Reading Other People's Code

What this session is

About 45 minutes. You'll learn the strategy for reading code you didn't write. This is a different skill from writing your own. Master it and your effective competence jumps a level.

The mistake most beginners make

You open a new codebase and start reading the first file you see, trying to understand every line. By line 50 you're lost; by line 200 you've given up.

This doesn't work because real code isn't a story - it's a graph. Every method calls others. Every class is defined somewhere else. Trying to load it all into your head is impossible, even for experienced engineers.

The trick is to not try. Pick a small thread; follow only it; let the rest stay fuzzy.

The five-minute orientation

For any new Java project:

  1. Read the README. What does this project DO? If you can't answer in one sentence, the README is incomplete - try another project.

  2. List the top-level directories. Typical Java layout:

  3. src/main/java/... - the code.
  4. src/main/resources/ - config, templates, static files.
  5. src/test/java/... - tests.
  6. pom.xml (Maven) or build.gradle.kts (Gradle).
  7. .github/workflows/ - CI.
  8. docs/, examples/, LICENSE, CONTRIBUTING.md.

  9. Open pom.xml / build.gradle. What's the artifactId? Java version? Major dependencies (Spring? Jackson? Guava?)? Tells you the ecosystem.

  10. Find the entry point. For an app, look for public static void main. For a library, look at the top-level package's most-public classes (named with the project name often: Greet, Hello, etc.).

  11. Read one test file. Tests show you what the code is supposed to do with concrete examples. Often clearer than the code being tested.

After this, write a one-paragraph summary of what the project does. If you can't, repeat.

Tools for reading

  • IntelliJ IDEA's "Find Usages" and "Go to Definition" - Cmd-B / Ctrl-B to jump to definition; Alt-F7 to find all references. The single biggest productivity tool for reading Java.
  • javadoc browsing - most libraries publish their Javadoc online (search " javadoc"). Skim the package summary before diving into source.
  • grep -r 'pattern' src/ - old-school but unbeatable for finding all uses of a string.
  • mvn dependency:tree - see what depends on what.
  • mvn test -Dtest=ClassName#methodName - run one specific test to watch it execute.
  • Reading recent merged PRs on GitHub. Often the clearest way to understand a project.

A worked example

Let's read a piece of the standard library: String.indexOf.

  1. In any class, type String s = "hello"; s.indexOf and Cmd-B on indexOf (in IntelliJ). It jumps to String.indexOf(String str) in String.java.
  2. The method body delegates to a static indexOf. Cmd-B again.
  3. The static indexOf is a few dozen lines, with an SIMD-accelerated fast path. Read the top, skim the loop.
  4. Recognize: "scans the source string looking for the target; returns the position or -1." That's enough.
  5. Confirm with the JDK's tests under test/jdk/java/lang/String/. Find IndexOfTest.java. Read three cases.

Five minutes; you understood enough.

Things that look scary in Java codebases

  • Annotations (@Override, @Test, @Inject, @Autowired, framework-specific) - metadata read by frameworks. Recognize the common ones; don't worry about framework specifics unless using that framework.
  • Generics with bounds - <T extends Comparable<? super T>> reads like noise the first time; it's PECS (page 09).
  • Streams - list.stream().filter(...).map(...).collect(...). A pipeline; read left to right.
  • Lambdas and method references - x -> x.length() and String::length. Inline functions; everywhere in modern Java.
  • Reflection - clazz.getDeclaredMethod("foo").... Frameworks (Jackson, Spring) use it heavily. Recognize and skip on first read.
  • Inner classes / anonymous classes - class Outer { class Inner { ... } } or new Runnable() { public void run() { ... } }. Older style; modern code prefers lambdas where possible.
  • @FunctionalInterface - marks an interface with exactly one abstract method, usable with lambdas.

You'll hit things you don't recognize. Knowing when to dig in vs skim past is the skill.

Reading vs understanding

Two distinct things: - Reading code: following what it does, line by line. - Understanding code: knowing why it's shaped that way.

You don't need understanding to contribute. A first PR often involves reading 1000 lines, understanding 100, modifying 5.

Exercise

No new code. Reading.

Pick a small Java project on GitHub. Suggestions:

  • Netflix/Hollow - large-ish, but well-organized. Skip.
  • SeleniumHQ/htmlunit - older codebase, classic OOP Java.
  • Try smaller: apache/commons-lang3 - utility classes. Each is small and self-contained.
  • google/guava - Google's Java utility library. Big overall but each module is small.
  • pcj/orbit - distributed actor framework, mid-size.
  • hexops/dyzio - modern, smaller utility lib.

For sheer beginner-friendliness, commons-lang3 is excellent - pick one utility class (StringUtils, say) and trace it.

Apply the orientation: 1. README → what does it do? 2. Layout → standard Maven? 3. pom.xml → dependencies? 4. Pick one class, trace one method. 5. Read three tests for that method.

Write a paragraph: what does this do? How is it organized? What surprised you?

What you might wonder

"What if I don't understand something?" Write it down, skip, keep going. Often the thing that confused you on day 1 makes sense after a week. If it still doesn't, ask in the project's discussion - but try for an hour first.

"Huge projects like Spring Boot - where do I start?" A sub-module. Spring has dozens of small repos under spring-projects/. Pick one (spring-pulsar is small) rather than spring-framework itself.

Done

You can now: - Apply a five-minute orientation to any Java project. - Use IntelliJ navigation, Javadoc, and grep to read efficiently. - Distinguish reading from understanding. - Recognize common "looks scary, isn't" patterns. - Pick a small project and summarize what it does.

Next: Picking a project →

13 - Picking a Project to Contribute To

What this session is

About 30 minutes plus browsing. You'll learn what makes a Java project a good first target, and we'll list several that consistently welcome new contributors.

Why the wrong project burns you out

A first contribution to the wrong project goes:

  1. You pick Spring (or Hibernate, or Kafka) because you've used it.
  2. Three hours setting up the dev environment.
  3. Find a "good first issue" untouched in six months.
  4. Two weeks understanding enough to make a change.
  5. Submit a PR.
  6. Three weeks of silence, then "could you rebase against trunk and address these 12 review comments?"
  7. You give up.

Fix: pick smaller, more responsive projects first.

What "manageable" means

In priority order:

  1. Small enough to comprehend. Under ~10k lines of Java is great. Under ~50k is doable. Above 100k, orientation alone is a week.
  2. Active maintainers. PRs reviewed in days, not weeks.
  3. good first issue / help wanted labels. Pre-screened for approachability.
  4. CONTRIBUTING.md. Spells out conventions.
  5. mvn test (or ./gradlew test) passes on fresh clone. If it doesn't, that's a red flag.
  6. You actually understand or care about what it does.

10-minute evaluation

Signal What you want
Stars 100-50000
Last commit Within a month
Open PRs Some, not 200+
Recent PR merge time Under 14 days
good first issue count At least 5
CONTRIBUTING.md exists and is readable yes
CI green on main yes
Code of conduct yes

If a project fails several, move on. Thousands of options.

Candidates

Verify state with the 10-minute eval before committing.

Tier 1 - very small

  • junit-team/junit5 - yes, the test framework itself. Big overall, but documentation and small-bug PRs are very accessible.
  • assertj/assertj-core - assertion library. Well-organized; lots of "add an assertion for X" issues.
  • google/truth - Google's assertion library. Active maintainers.
  • spring-projects-experimental/... - various small experiments under Spring's org.

Tier 2 - small to medium

  • apache/commons-lang - Apache utility library. Decades of polish, big community, many small issues.
  • apache/commons-io - sibling project for I/O utilities.
  • fastjson/fastjson2 - JSON library.
  • micrometer-metrics/micrometer - metrics library. Excellent maintainers; smaller PRs welcome.
  • resilience4j/resilience4j - circuit breaker library.
  • vavr-io/vavr - functional library for Java.

Tier 3 - bigger but well-documented

After Tier 1-2.

  • spring-projects/spring-boot - vast, but extremely well-labeled issues.
  • testcontainers/testcontainers-java - Docker-in-tests library.
  • elastic/elasticsearch - search engine. Java-heavy.
  • apache/kafka - message broker. Some good-first-issue work.

Tier 4 - massive, don't start here

  • openjdk/jdk - Java itself. The process is slow; CLA required; high bar.

Finding issues

On the project's GitHub, Issues → Labels. Filter by: - good first issue - help wanted - documentation

Read 5-10. Look for: - Clear description. - Contained fix. - Nobody claimed it. - Not open for a year.

Comment: "I'd like to take this. Can you confirm it's still wanted?" Wait for the maintainer.

What counts as a contribution

Small ones absolutely count: - Fix a typo in README or Javadoc. - Add a missing example. - Add a test case. - Improve an error message. - Add a @param Javadoc that's missing. - Fix a small bug with a clear reproduction.

Your first PR's job is to get you through the workflow.

Exercise

  1. Browse three projects from Tier 1-2. Do the 10-minute eval on each. Take notes.
  2. Pick the most responsive with at least 3 unclaimed issues.
  3. Read its CONTRIBUTING.md.
  4. Clone:
    git clone https://github.com/<owner>/<repo>
    cd <repo>
    mvn test               # or ./gradlew test
    
    If tests fail on fresh clone, consider another project.
  5. Browse open good first issue tickets; pick two candidates. Don't claim yet.

What you might wonder

"What if my favorite project is too big?" Find a sub-project. Spring has dozens; Apache has dozens; Eclipse has dozens. Each is smaller than the umbrella.

"What if I find a bug but no issue?" File one first. Describe, wait for acknowledgement, then offer to fix.

Done

  • Articulate what makes a project a good first target.
  • Run a 10-minute evaluation.
  • Find appropriately-sized issues.
  • Avoid the most common traps.

Next: Anatomy of a Java OSS repo →

14 - Anatomy of a Java OSS Repo

What this session is

About 45 minutes. Walk through the file layout of a real Java OSS project, file by file. By the end you can predict where things live in any project.

Typical small Java project layout

.
├── README.md
├── LICENSE
├── CONTRIBUTING.md
├── CODE_OF_CONDUCT.md
├── pom.xml                          (Maven) or build.gradle.kts (Gradle)
├── mvnw / mvnw.cmd                  (Maven Wrapper - bundled mvn launcher)
├── .gitignore
├── .github/
│   ├── workflows/
│   ├── ISSUE_TEMPLATE/
│   └── PULL_REQUEST_TEMPLATE.md
├── src/
│   ├── main/
│   │   ├── java/com/example/myapp/
│   │   │   ├── App.java
│   │   │   ├── core/
│   │   │   └── util/
│   │   └── resources/
│   │       ├── application.yml
│   │       └── log4j2.xml
│   └── test/
│       ├── java/com/example/myapp/
│       │   ├── AppTest.java
│       │   └── core/
│       └── resources/
└── target/                          (Maven build output - gitignored)

Not every project has every piece. The roles are consistent.

What each piece is for

Root-level files

  • README.md - homepage. One-line description, install/usage, smallest example.
  • LICENSE - Apache 2.0, MIT, GPL, BSD most common in Java OSS.
  • CONTRIBUTING.md - most important file for you. Conventions, PR process, dev setup.
  • CODE_OF_CONDUCT.md - community standards.
  • pom.xml (Maven) or build.gradle.kts (Gradle) - build manifest.
  • mvnw / mvnw.cmd - Maven Wrapper. Use ./mvnw instead of mvn to use the project's pinned Maven version.
  • gradlew / gradlew.bat - Gradle Wrapper, same idea.
  • .editorconfig - editor settings (tabs/spaces, line endings).
  • .gitignore - files git should ignore (target/, .idea/, *.class).

.github/

  • workflows/*.yml - GitHub Actions CI. Reading them tells you exactly what your PR will be measured against.
  • ISSUE_TEMPLATE/ - issue templates.
  • PULL_REQUEST_TEMPLATE.md - what GitHub pre-fills in PR description.

src/main/java/

The production code. Subfolders match package structure (com.example.myappcom/example/myapp/).

src/main/resources/

Non-Java files bundled with the artifact: config files (application.yml, application.properties), templates, static assets, log config (log4j2.xml).

src/test/java/

Tests, mirroring main's package structure. JUnit picks them up automatically; Maven runs them during mvn test.

src/test/resources/

Test-only resources: fixture files, mock data, test config.

target/

Build output. JARs, compiled classes, generated sources, reports. Always gitignored.

Worked walkthrough: a hypothetical small library

Imagine a project string-utils you've just cloned. Five-minute orientation:

ls
README.md  LICENSE  CONTRIBUTING.md  pom.xml  src/  .github/
  1. README.md - string-utils provides a few string helpers Apache Commons doesn't include.
  2. pom.xml - open it:
    <groupId>com.example</groupId>
    <artifactId>string-utils</artifactId>
    <version>1.2.0</version>
    <properties>
        <maven.compiler.source>17</maven.compiler.source>
    </properties>
    <dependencies>
        <dependency><groupId>org.junit.jupiter</groupId>...</dependency>
    </dependencies>
    
    No third-party runtime deps - quality signal.
  3. src/main/java/ - find it:
    src/main/java/com/example/strings/
    ├── package-info.java
    ├── Pluralizer.java
    ├── Titlecase.java
    └── internal/
        └── Helpers.java
    
    Public API: Pluralizer, Titlecase. Internal: Helpers (package-private or in an internal package).
  4. src/test/java/ - same structure, with Test suffix:
    src/test/java/com/example/strings/
    ├── PluralizerTest.java
    └── TitlecaseTest.java
    
  5. .github/workflows/ - open one:
    - run: ./mvnw verify
    
    So CI runs ./mvnw verify - that's the command to replicate locally.

Five minutes; you have a map.

Conventions from CONTRIBUTING.md

Open the file. Look for:

  • Setup instructions. Usually mvn install or ./mvnw install.
  • Tests. Usually mvn test or ./mvnw test.
  • Code style. Often "run mvn spotless:apply" to auto-format. Or "use the supplied IntelliJ config in .editorconfig".
  • Commit message format. Some require Conventional Commits; most don't.
  • PR template. Address every checkbox.
  • DCO / CLA. Some projects need signed commits (git commit -s) or CLA signing.
  • Branch naming. Some require fix/issue-123, etc.

Follow them. The maintainers will be relieved.

CI configuration: what your PR will be measured against

Open a workflow under .github/workflows/. A typical Java CI:

name: CI
on: [push, pull_request]
jobs:
  test:
    strategy:
      matrix:
        java: [17, 21]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: ${{ matrix.java }}
      - run: ./mvnw verify

Read it: - Runs on every push and PR. - Tests on Java 17 and 21. - Setup: Temurin JDK. - Command: ./mvnw verify (verify = compile + test + sanity checks).

Run ./mvnw verify locally. If it passes, your PR will (probably) pass CI.

Common helper tools

You'll see these in real Java projects:

  • Spotless - auto-formatting (Google Java Format, Palantir Java Format, Eclipse Code Style). mvn spotless:apply formats; mvn spotless:check fails CI on unformatted code.
  • Checkstyle / PMD - style/correctness checkers.
  • SpotBugs - finds bugs via static analysis.
  • Jacoco - code coverage reports.
  • Maven Enforcer - fails the build on rule violations (e.g., banned dependencies, wrong Java version).

The CI workflow will mention which are used.

Exercise

Use the project you picked in page 13.

  1. Clone locally.
  2. Walk the layout. Map each file/folder to the categories above.
  3. Read CONTRIBUTING.md end to end.
  4. Open one CI workflow YAML. Identify the commands.
  5. Run those commands locally:
    ./mvnw verify       # or whatever the workflow uses
    
  6. Open the issue you tentatively picked. Identify the three files most likely involved (use grep and your guess).

Now you're ready to actually make a change.

What you might wonder

"What if a project uses Gradle, not Maven?" ./gradlew build or ./gradlew test. Same idea, different syntax in the build file.

"What's a bom artifact?" Bill of Materials - a POM whose only job is to declare versions of related artifacts. You import it in <dependencyManagement>; then you can list dependencies without versions (they come from the BOM). Common in Spring (spring-boot-dependencies), Jackson (jackson-bom).

"What's module-info.java if I see it?" JPMS module descriptor (page 11). Recognize it; rarely needs editing for a first contribution.

Done

  • Recognize the typical Java project layout.
  • Locate every common file/folder by role.
  • Read CONTRIBUTING.md and CI workflows.
  • Make a confident guess at which files a change will touch.

Next: Your first contribution →

15 - Your First Contribution

What this session is

The whole thing. Maybe two sessions. We walk through making a real contribution to a real Java OSS project end to end: fork, branch, change, test, push, PR, review, merge.

When the PR merges, you'll be an open-source contributor - a small, real one.

The workflow at a glance

  1. Fork on GitHub.
  2. Clone your fork.
  3. Add upstream as a remote.
  4. Branch off main.
  5. Set up dev environment, including any project-specific tools.
  6. Change the code; add a test.
  7. Run tests + checks locally (same commands as CI).
  8. Push to your fork; open the PR.

Takes 30 minutes the first time; 5 minutes once automatic.

Step 1: Fork

GitHub page → Fork (top right). Creates github.com/<you>/<project>.

Step 2: Clone

git clone https://github.com/<you>/<project>
cd <project>

Step 3: Add upstream

git remote add upstream https://github.com/<owner>/<project>
git fetch upstream
git remote -v

You should see origin (your fork) and upstream (the real project).

Later, to pull updates from upstream:

git fetch upstream
git checkout main
git merge upstream/main
git push origin main

Step 4: Branch

git checkout -b fix/issue-123-clarify-error-message

Hint at the change. Follow the project's branch-name convention if any.

Step 5: Set up the dev environment

Follow CONTRIBUTING.md. Usually:

./mvnw verify        # or mvn verify, or ./gradlew build

Confirms tests pass on fresh clone. If not, stop and figure out why before changing anything - your "fix" might rest on a baseline that's already broken.

If the project uses spotless or similar, run it once:

./mvnw spotless:apply

Some projects rely on IntelliJ-imported code style. Import the project's .editorconfig and/or supplied IntelliJ style XML.

Step 6: Make the change

Keep it: - Small. 5-line diff > 500-line diff for a first PR. - Focused. One issue per PR. - Tested. Any code change needs a test. Docs-only changes don't.

Run the formatter as you go (or have your IDE do it on save).

Step 7: Run the CI commands locally

Whatever your CI workflow does, do it before pushing:

./mvnw verify
./mvnw spotless:check    # if used

All green? Push.

Red? Fix locally first. Don't push red CI - it makes reviewers babysit you.

If the project's CI runs on multiple Java versions (17 + 21, say), you don't have to test both locally. CI catches version-specific issues.

Step 8: Commit and push

git add <files>
git commit -m "fix: clarify error message in App.java (#123)"

Commit message: - First line ~50 chars, imperative mood ("Add", not "Added"). - Optional blank line + body. - Reference issue: (#123) auto-links.

If DCO is required:

git commit -s -m "fix: ..."

Push:

git push origin fix/issue-123-clarify-error-message

GitHub prints a URL - click it to open the PR.

Step 9: Open the PR

On the upstream project's GitHub, you'll see a banner: "Compare & pull request." Click.

  • Title. Mirror commit message or issue title.
  • Description. What changed? Why? What you tested. Reference: "Closes #123" or "Fixes #123" - auto-closes the issue on merge.
  • Checklist. Address every item from the PR template.

Submit. CI runs. Wait. Fix anything red by pushing more commits.

If the project requires a CHANGELOG entry, add a line under "Unreleased" describing your change.

What happens next: review

A maintainer looks at it. Outcomes:

  1. "LGTM, merging." Best case.
  2. "Could you change these?" Most common. Inline comments. Address each - either change code or reply with reason. Push more commits.
  3. "Thanks, but we don't want this." Rare for good first issue work. Ask if there's something related you can pick up.
  4. Silence. After a week: "Friendly bump?" After three: ask in any community channel.

Reviews are not personal. Even senior engineers get them. Address efficiently; argue only on substance, not style.

After the merge

  • Update your fork's main (step 3 workflow).
  • Delete the branch locally + on your fork.
  • Take a screenshot - you'll be glad later.
  • Sit with the experience for a day. Re-read the merged code and review comments. The learning is in the loop.

Worked example template

For a docs fix on issue #42 in example-org/example-repo:

# 1-2. Fork on GitHub, then clone:
git clone https://github.com/<you>/example-repo
cd example-repo

# 3. Add upstream:
git remote add upstream https://github.com/example-org/example-repo
git fetch upstream

# 4. Branch:
git checkout -b docs/fix-typo-in-readme

# 5. Set up:
./mvnw verify    # baseline green

# 6. Make the change. Edit README.md.

# 7. Run CI locally:
./mvnw verify
./mvnw spotless:check

# 8. Commit and push:
git add README.md
git commit -m "docs: fix typo in installation section (#42)"
git push origin docs/fix-typo-in-readme

# 9. Open the PR. Wait. Respond.

That's the whole thing.

After your first PR: what next

  1. Pick another issue in the same project. Familiarity compounds.
  2. After 3-5 PRs, become a regular. Watch issues. Answer ones you can. Review other PRs (you don't need to be a maintainer to leave helpful comments).
  3. Branch out. Try a Tier 2-3 project.
  4. Build something of your own. Publish it. Iterate.
  5. Read the "Java Mastery" path when ready to go from "I can contribute" to "I can architect and review."

What you might wonder

"PR sits for weeks?" Polite check-in after 1 week. After 3, ask elsewhere.

"Maintainer rude?" Disengage. Thousands of projects.

"Disagree with a review?" Style: do what they say. Correctness: explain with specifics. Either way, stay polite.

"Can't make tests pass?" Re-read CONTRIBUTING.md. Stuck after an hour: ask in the issue with specifics about what you tried.

"Can I put this on my CV?" Yes. Link to specific merged PRs.

Done with the path

You've now: - Installed a JDK and written your first program. - Learned every fundamental: types, control flow, methods, classes, collections, exceptions, records, generics, packages. - Read a real Java OSS project. - Picked a project, prepared a change, submitted a PR.

What you should not do: feel like you "know Java" now. You know what you've been taught. There's much more - concurrency, the JVM internals, Spring, Spring Boot, native images, Kafka/distributed systems. Each is a path of its own.

What you should do: keep contributing. The way you become an engineer is by doing real work on real codebases over time.

Two recommended next paths on this site:

  • Java Mastery - 24-week deep dive into the JVM, JIT, GC, Loom, modern idioms. Picks up where this leaves you.
  • Container Internals + Kubernetes - most Java services run in containers under Kubernetes. Understanding the substrate makes you a much better Java engineer.

Or just go build something. Programming pays you back when you build.

Congratulations. You are no longer a beginner.