Java, Then Spring
0%

Foundations  ·  a tutorial, beginner to advanced

Java,
then Spring.

Java is two things stacked: a language with a compiler that actually says no, and a runtime that has been tuned for twenty-five years. Spring Boot is what most of the industry runs on top of both. This page is a tutorial — every grey block is meant to be typed into a terminal and run, and the service you start building in part six is the one you take to a real database, real tests and a real container by part nine.

21 · 25The LTS pairJava 21 is what production runs; 25 is the newest long-term-support release. Everything here targets 21.
1 jarThe whole serviceA Spring Boot jar carries its own web server. Deployment is java -jar — the app server died a decade ago.
0 XMLConfiguration you will writeModern Spring is annotations, one YAML file, and a build file. The XML era is a museum piece.
Parts 1–3 · L1

The language: the JVM, objects and values, records, collections, interfaces, exceptions. Nothing but a JDK required.

Parts 4–7 · L2

The craft: streams, Maven, JUnit — then Spring Boot, a running REST service, configuration, and a real Postgres behind it.

Parts 8–10 · L3

The machine and production: threads and the JVM’s memory, error contracts, HTTP clients, the test ladder, Docker, and the review lens.

How to use this page

Unlike its siblings, this page has one ladder instead of three columns per topic: part one is the ground floor and part ten is the roof, so read it in order and run everything. You need a terminal, Docker (from part seven), and about ten minutes for the setup in part one. Every command block starts with $; the grey ← lines show what success looks like.

Part one

The machine you are installing

L1 · Beginner

Java is two artefacts: a compiler that turns source into bytecode once, and a virtual machine that turns bytecode into machine code every time you run. Everything people say about the platform — portable, verbose, slow to start, fast once warm — is a property of that split. Install the toolchain, run one file three different ways, and read the bytecode with your own eyes before part two adds the language on top.

Java · 01

Install a JDK and meet the JVM

Two commands install a full JDK. The interesting part is what you just installed: a compiler, a launcher, and a virtual machine that owns your program’s memory, garbage collection and threads for its whole life.

Use sdkman, the same way the Python page used uv and the Go page used nothing: it installs JDKs side by side, switches between them per shell, and never touches your system packages. Install it, then install a JDK:

Run it

Install sdkman, then a JDK. The -tem suffix picks Temurin — the Eclipse Adoptium build of OpenJDK, the boring-good default distribution: free, TCK-certified, and what most Dockerfiles pull. Success is java --version reporting an LTS build.

$ curl -s "https://get.sdkman.io" | bash
$ source "$HOME/.sdkman/bin/sdkman-init.sh"      # or just open a new terminal
$ sdk install java 21.0.5-tem
Done installing!  Setting java 21.0.5-tem as default.
$ java --version
openjdk 21.0.5 2024-10-15 LTS        ← the LTS suffix is what you want to see
$ sdk list java
Temurin | 21.0.5 | tem | installed   ← one row per vendor and version; sdk use java ... switches

Why 21 and not the newest? Java ships a release every six months and stamps one as LTS (long-term support) every two years. The LTS line runs 8 → 11 → 17 → 21 → 25; the versions in between are real releases that stop getting updates six months later, which is why production skips them. Java 25 is the newest LTS (September 2025); 21 is what most production runs and what every line on this page is written against. Nothing here breaks on 25.

LTSYearWhy you will meet it
82014Legacy codebases; lambdas and streams arrived here
112018First LTS of the six-month cadence; java Hello.java single-file launch
172021Sealed classes; the minimum for Spring Boot 3
212023Virtual threads, record patterns — this page’s baseline
252025Newest LTS; compact source files for beginners
WHAT ACTUALLY RUNS WHEN YOU TYPE java Hello.java Hello.java source text javac Hello.class bytecode JVM classloader interpreter JIT compiler owns the heap runs the GC schedules threads profiles your code, compiles the hot 5% machine code The .class file is the portable artefact: the same bytes run on Linux, macOS, Windows, ARM, x86. The JVM is the thing that gets ported, not your program. java Hello.java runs the same pipeline with an in-memory compile first — convenient for one file, not a build system. Why a Java service starts slower than a Go binary, then catches up Go ships finished machine code; the JVM ships bytecode and compiles only the hot paths, while the program is already running. Cold start pays for classloading and a cold interpreter; after warm-up the JIT holds profile data an AOT compiler never sees.
Bytecode is the deal Java makes. Go pays compilation in full before shipping and Python interprets forever; the JVM pays a slow first minute, watches the program run, then compiles the paths that matter using live profile data. Your code never allocates a page or frees an object — the JVM owns memory, garbage collection and threads, which is precisely what makes the same .class file run anywhere a JVM exists.

Now run something. Since Java 11 the launcher compiles a single source file in memory, so a beginner never needs to see javac on day one:

public class Hello {
    public static void main(String[] args) {
        String name = args.length > 0 ? args[0] : "world";
        System.out.println("Hello, " + name);
    }
}

Run it

Save as Hello.java and launch the file directly — no compile step, no project. Pass an argument to see args filled in.

$ java Hello.java
Hello, world
$ java Hello.java Ada
Hello, Ada

For anything smaller than a file there is JShell, the REPL that ships with every JDK — the same role the python prompt played on the Python page. Statements do not need a class, a semicolon is optional at the prompt, and every result is bound to $1, $2… for reuse.

Run it

Open the REPL, evaluate two expressions, leave. Use it throughout this page whenever you want to poke at an API without creating a file.

$ jshell
jshell> 21 * 2
$1 ==> 42
jshell> "task".repeat(3)
$2 ==> "tasktasktask"
jshell> /exit

JDK, JRE, JVM — three words, one download

The JVM is the virtual machine in the figure. The JRE is the JVM plus the standard library — enough to run. The JDK is the JRE plus the tools: javac, jshell, javap, jar. You installed the JDK; production containers often carry a trimmed runtime built with jlink.

Java · 02

A Java file, dissected down to the bytecode

Every declaration in a Java file is load-bearing: the package is a directory, the public class is the file name, and public static void main(String[] args) is a signature the JVM looks up literally. Then javap shows you what the compiler actually wrote.

Here is the same program grown up: a package, an import, a constant. Read it top to bottom, because each line is a contract with either the compiler or the JVM:

// src/dev/ladder/hello/Greeter.java -- path and package MUST agree
package dev.ladder.hello;

import java.time.LocalTime;      // a compile-time name shorthand -- unlike Python's
                                 // import, it executes nothing at runtime
public class Greeter {

    private static final String FALLBACK = "world";   // a class-level constant

    public static void main(String[] args) {
        String name = args.length > 0 ? args[0] : FALLBACK;
        System.out.println("Hello, " + name + " at " + LocalTime.now());
    }
}
EVERY LINE IS A CONTRACT package dev.ladder.hello; import java.time.LocalTime; public class Greeter { public static void main(String[] args) { System.out.println(...); } } the folder address on disk: src/dev/ladder/hello/Greeter.java one public class per file, named exactly like the file the entry point: the JVM looks up exactly this signature, at runtime public static void main — four words, four contracts public: callable from outside the class · static: no object exists yet · void: nothing returned · String[] args: the command line. Bytecode has no free-standing functions. Every method lives in a class, which is why even Hello, world needs one.
Why everything lives in a class. The JVM’s unit of loading, verifying and compiling is the class file — there is nowhere else for a method to live. Go puts functions in packages and Python puts them in modules; Java’s equivalent container happens to also be its type. The ceremony is the address system: package → directory, class → file, method → entry in that file.

Dissect the one line that does something. System is a class in java.lang (the only package imported automatically); out is a static final field on it, of type PrintStream; println is an overloaded method on that stream. So System.out.println is not an incantation — it is field access followed by a method call, and by the end of part two you can read it that way.

Now compile explicitly and look at the result. javac -d out mirrors the package into a directory tree; java -cp out tells the launcher where that tree starts; javap -c disassembles what the compiler wrote.

Run it

Compile, run with an argument, then disassemble main. You are looking at the stack-machine instructions the interpreter executes and the JIT later compiles — every mystery on this page bottoms out here.

$ mkdir -p src/dev/ladder/hello out
$ javac -d out src/dev/ladder/hello/Greeter.java
$ java -cp out dev.ladder.hello.Greeter Ada
Hello, Ada at 09:14:02.113405
$ javap -c -cp out dev.ladder.hello.Greeter
public static void main(java.lang.String[]);
  Code:
     0: aload_0
     1: arraylength
     2: ifle          11        // args.length > 0 became a branch
    ...
    22: getstatic     #22       // Field java/lang/System.out
    25: invokevirtual #28       // Method java/io/PrintStream.println

Where the ceremony is going

Java 25’s compact source files let a beginner write void main() { IO.println("hi"); } — no class, no static, no args — but every production codebase still looks like the file above, so this page keeps the full form.

Part two

Objects, values, and the type system

L1 · Beginner

Java draws a line Python never drew: a handful of primitive types live directly in the variable, and everything else is a reference into the heap. Hold that line in your head and the classic surprises — == lying about equality, a key vanishing from a HashMap, a boxed 127 behaving differently from a boxed 128 — become predictable consequences instead of folklore.

Java · 03

Primitives, references, and the two meanings of ==

A primitive variable holds the value itself; a reference variable holds a pointer into the heap. == compares whatever is in the slot — which is the value for primitives and the address for objects. That one sentence is the most common beginner bug in the language.

Eight primitive types exist; four carry almost all production code. They are not objects: no methods, no null, no heap allocation — the value sits in the variable, the way a C or Go value would.

TypeSizeLiteralNotes
int32-bit42The default for whole numbers; overflows silently at 231−1
long64-bit10_000_000_000LIDs, timestamps, money-in-cents; note the L
double64-bit0.1IEEE-754 — fine for science, never for money
boolean—trueOnly true/false; no truthy values, unlike Python

byte, short, float and char complete the set; you will meet them in I/O buffers and old APIs. Everything that is not one of the eight — String, arrays, every class you write — is a reference type: the variable holds a pointer, null means the pointer points at nothing, and copying the variable copies the arrow, never the object.

ONE STACK FRAME, TWO KINDS OF SLOT stack frame: main() int count = 42 (the value) String s int[] xs heap — the GC’s territory String "tasks" literal: in the string pool int[] {1, 2, 3} Integer cache -128..127: boxed once, shared by everyone 128: a fresh box on every autoboxing A primitive slot is the value. A reference slot is a pointer — copying it copies the arrow, never the object it points at. The Python page boxed everything; Java splits the world in two == compares slot contents: values for primitives, addresses for references. .equals() is the value comparison for objects. Autoboxing crosses the line silently: List<Integer> boxes every int you add, and the cache makes == lie helpfully below 128. Strings are references that behave like values — exactly as long as you compare them with .equals, never ==.
This split is why Java is fast and why it bites. An int in a loop costs nothing on the heap; in Python even 3 is a boxed object with a refcount. The price is a two-tier system where Integer exists purely so an int can enter a generic collection — and where the compiler converts between the tiers behind your back.

Collections cannot hold primitives, so Integer, Long and friends exist as boxed wrappers and the compiler autoboxes in both directions. The trap: the JVM caches boxes for −128..127, so == on small boxed values compares two pointers to the same cached box and returns true — then your test data grows past 127 and it silently returns false. Strings have the same shape: literals are pooled and shared, so == works right up until a string arrives from a file, a request, or new String(...). Prove all of it to yourself:

public class Surprises {
    public static void main(String[] args) {
        Integer a = 127, b = 127;             // both autoboxed from the cache
        Integer c = 128, d = 128;             // two fresh boxes on the heap
        System.out.println(a == b);           // true  -- same cached object
        System.out.println(c == d);           // false -- different objects, equal values
        System.out.println(c.equals(d));      // true  -- what you nearly always meant

        String s1 = "task";
        String s2 = "task";                   // same pooled literal as s1
        String s3 = new String("task");       // new forces a fresh object
        System.out.println(s1 == s2);         // true  -- pooling, an accident you cannot rely on
        System.out.println(s1 == s3);         // false -- different objects
        System.out.println(s1.equals(s3));    // true

        String t = s1.toUpperCase();          // Strings are immutable: this is a NEW string
        System.out.println(s1 + " " + t);     // task TASK -- s1 untouched

        int[] xs = {1, 2, 3};                 // arrays are fixed-size, forever
        System.out.println(xs.length);        // 3 -- a field, not a method
    }
}

Run it

One file, six lies and truths. If any line surprises you, re-read the figure: every result is “== compared what was in the slot”.

$ java Surprises.java
true
false
true
true
false
true
task TASK
3

The rule that survives this topic

Compare primitives with ==. Compare objects — every String, every Integer, everything you write — with .equals(), or with Objects.equals(a, b) when either side may be null. An == between two reference types is a code-review flag unless identity is genuinely the question.

Java · 04

Classes the long way, then records

Write one value class by hand — fields, constructor, accessors, equals, hashCode, toString — so you know exactly what a record generates when it replaces all thirty lines with one.

A Money type, the classical way. Note the shape: every field private final (set once, in the constructor, never again), accessors instead of public fields, and the three methods every value type owes the rest of the ecosystem:

import java.util.Objects;

public final class Money {                       // final class: cannot be subclassed
    private final long cents;                    // final field: assigned once, in the constructor
    private final String currency;

    public Money(long cents, String currency) {
        if (cents < 0) throw new IllegalArgumentException("negative amount");
        this.cents = cents;
        this.currency = Objects.requireNonNull(currency);
    }

    public long cents()       { return cents; }
    public String currency()  { return currency; }

    public Money plus(Money other) {             // no mutation: arithmetic returns a NEW Money
        if (!currency.equals(other.currency))
            throw new IllegalArgumentException("currency mismatch");
        return new Money(cents + other.cents, currency);
    }

    @Override public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Money m)) return false;
        return cents == m.cents && currency.equals(m.currency);
    }

    @Override public int hashCode() { return Objects.hash(cents, currency); }

    @Override public String toString() { return cents + " " + currency; }
}

Thirty lines, and every one of them mechanical. Since Java 16 the language writes them for you:

public record Money(long cents, String currency) {}   // the same class, generated

A record declares its state in the header, and the compiler generates the final fields, the canonical constructor, accessors named after the components (cents(), not getCents()), and value-based equals, hashCode and toString. You add back only what is genuinely yours — validation goes in a compact constructor, domain logic is an ordinary method:

THE SAME VALUE TYPE, TWICE class Money — you typed every line private final long cents; private final String currency; Money(long, String) constructor cents(), currency() accessors plus(Money) — domain logic equals(Object) — value equality hashCode() — must match equals toString() — readable output record Money(long cents, String currency) {} the compiler writes, at compile time final fields + canonical constructor cents() and currency() accessors equals / hashCode by component value toString: Money[cents=.., currency=..] only plus() and validation stay yours Records are for values; classes are for state Write a class when the object must change in place, hide its representation, or satisfy a framework that demands a no-arg constructor and setters — the JPA entities of part seven.
A record is a value with a name, not a bag of setters. It is still a class underneath — Money.class on disk, methods, the lot — but its components are final and its equality is by value, the same guarantees the handwritten version spent thirty lines promising. That is also exactly the equality contract HashMap will demand in the next topic.
record Money(long cents, String currency) {
    Money {                                      // compact constructor: validate, then fields assign
        if (cents < 0) throw new IllegalArgumentException("negative amount");
    }
    Money plus(Money other) {
        if (!currency.equals(other.currency))
            throw new IllegalArgumentException("currency mismatch");
        return new Money(cents + other.cents, currency);
    }
}

public class MoneyDemo {
    static final Money FREE = new Money(0, "GBP");    // static: one per class, shared

    public static void main(String[] args) {
        Money a = new Money(12_50, "GBP");            // instance state: one per object
        Money b = new Money(4_99, "GBP");
        System.out.println(a.plus(b));
        System.out.println(a.equals(new Money(12_50, "GBP")));
        System.out.println(new Money(-1, "GBP"));     // validation fires
    }
}

Run it

Both classes sit in one file (only MoneyDemo is public), so the single-file launcher compiles them together. Value equality works with no equals written, and the compact constructor rejects the bad amount.

$ java MoneyDemo.java
Money[cents=1749, currency=GBP]
true
Exception in thread "main" java.lang.IllegalArgumentException: negative amount

static versus instance is the other axis worth fixing now: a static member belongs to the class (one FREE, shared; Math.max, Instant.now()), an instance member to each object (each Money has its own cents). main is static because when the JVM calls it, no object of your class exists yet. And final means “assigned once” for a field or variable, “no subclassing” for a class — it does not make the referenced object immutable.

Real money is BigDecimal or cents, never double

0.1 + 0.2 == 0.30000000000000004 in Java exactly as on the Python page. Hold money as long cents (as Money does) or use new BigDecimal("19.99") — constructed from a String, because new BigDecimal(0.1) faithfully preserves the binary error you were escaping.

Java · 05

Collections, generics, and the equals/hashCode contract

Arrays are fixed; real code uses List, Map and Set, parameterised with the type they hold. The compiler enforces the element type; the collection enforces a contract on you — and HashMap quietly loses data when you break it.

Three interfaces carry the standard library: List<T> (ordered, indexed, duplicates allowed — usually ArrayList), Map<K, V> (key to value — usually HashMap), and Set<T> (no duplicates — usually HashSet). The angle brackets are generics: List<Task> is a list that only accepts and only returns Task, checked at compile time, where Python’s list[Task] is a hint mypy checks and the runtime ignores. The diamond <> on the right-hand side asks the compiler to repeat the type parameter so you do not have to.

import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

record Task(String title, int priority, boolean done) {}

public class TaskListDemo {
    public static void main(String[] args) {
        List<Task> tasks = new ArrayList<>();          // diamond: compiler fills in <Task>
        tasks.add(new Task("write the Flyway migration", 1, false));
        tasks.add(new Task("wire the /tasks endpoint", 2, false));
        tasks.add(new Task("install a JDK", 1, true));

        tasks.sort(Comparator.comparing(Task::priority)   // sort by priority,
                             .thenComparing(Task::title)); // ties broken by title

        for (Task t : tasks) {                             // for-each: no index bookkeeping
            System.out.println((t.done() ? "[x] " : "[ ] ") + t.title());
        }

        Map<Integer, Long> perPriority = new HashMap<>();
        for (Task t : tasks) {
            perPriority.merge(t.priority(), 1L, Long::sum); // count per key, no if-absent dance
        }
        System.out.println(perPriority);

        Set<String> owners = new HashSet<>(List.of("ana", "ben", "ana"));
        System.out.println(owners.size());                 // deduplicated by equals/hashCode
    }
}

Run it

Sorting mutates the list in place; the map counts tasks per priority; the set swallows the duplicate. All three behaviours lean on Task’s record-generated equals and hashCode.

$ java TaskListDemo.java
[x] install a JDK
[ ] write the Flyway migration
[ ] wire the /tasks endpoint
{1=2, 2=1}
2

List.of(...), Map.of(...) and Set.of(...) build compact immutable literals — ideal for constants and test data, with one trap: they reject mutation at runtime, not compile time. The type system cannot see the difference between an ArrayList and an immutable list behind the same List interface:

List<String> tags = List.of("api", "db");   // immutable literal
tags.add("ui");                             // compiles fine...
// Exception in thread "main" java.lang.UnsupportedOperationException

List<String> mutable = new ArrayList<>(List.of("api", "db"));   // the copy that grows
HashMap: hashCode PICKS THE BUCKET, equals FINDS THE ENTRY put(key, v): n = 11 hash 11 → bucket 3 key.n = 14 (mutated) hash 14 → bucket 6 0 1 2 3 4 5 6 7 entry get(key) hashes to bucket 6, finds it empty, returns null — the entry still sits in bucket 3. size() is still 1 and iteration still shows the entry: reachable memory, unreachable data. equals and hashCode travel together, and a key must never move Equal objects must return equal hash codes, and a key’s hash must not change while it sits in a map or a set. Records and final fields give you the contract for free — half the reason the previous topic pushed you towards them. The Python page had the same rule: __eq__ without __hash__ makes a type unhashable there. Java compiles anyway — and loses the entry.
A bad key does not break the map; it breaks your access to it. HashMap stores the entry in the bucket its hash selected at put time and never rehashes on your behalf. Mutate the key — or override equals without hashCode so equal keys land in different buckets — and lookups miss while the data sits there. Python raises TypeError up front; Java trusts you and says null.

Watch it happen. This key overrides both methods correctly — the bug is purely that it mutates after insertion:

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

class MutableKey {
    int n;
    MutableKey(int n) { this.n = n; }
    @Override public boolean equals(Object o) {
        return o instanceof MutableKey k && k.n == n;
    }
    @Override public int hashCode() { return Integer.hashCode(n); }
}

public class LostEntry {
    public static void main(String[] args) {
        Map<MutableKey, String> m = new HashMap<>();
        MutableKey k = new MutableKey(11);
        m.put(k, "hello");
        k.n = 14;                                       // the key moves AFTER insertion
        System.out.println(m.get(k));                   // null -- looked in the wrong bucket
        System.out.println(m.get(new MutableKey(11)));  // null -- right bucket, equals now fails
        System.out.println(m.size());                   // 1  -- the entry is still in there
    }
}

Run it

Both lookups miss while size() insists the entry exists. Fix is not code — it is policy: keys are immutable, ideally records.

$ java LostEntry.java
null
null
1
Part three

Contracts, hierarchy, and failure

L1 · Beginner

Interfaces are how Java code stays swappable, and sealed hierarchies are how modern Java makes the compiler check what Go delegates to a linter and Python to mypy. Then the part every service is judged on: what happens when things fail — which exceptions to catch, which to wrap, and which to let fly.

Java · 06

Interfaces, and sealed hierarchies the compiler checks

An interface is a contract with no state: methods a type promises to provide. Program to it and implementations become swappable. Seal it, and the compiler knows the full set of implementations — and starts proving your switch handles every one.

The classic half first. An interface declares what, a class declares how, and implements connects them. Two habits are non-negotiable from day one: declare variables as the interface type, and write @Override on every implementing method so the compiler catches the typo’d signature that would otherwise become a brand-new method:

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

record Task(String title, int priority, boolean done) {}

interface TaskStore {
    void add(Task task);
    List<Task> all();

    default boolean isEmpty() {                // a default method: behaviour ON the contract,
        return all().isEmpty();                // inherited by every implementation for free
    }
}

class InMemoryTaskStore implements TaskStore {
    private final List<Task> tasks = new ArrayList<>();   // composition: HAS a list,
                                                          // does not EXTEND ArrayList
    @Override public void add(Task task) { tasks.add(task); }
    @Override public List<Task> all()    { return List.copyOf(tasks); }
}

public class StoreDemo {
    public static void main(String[] args) {
        TaskStore store = new InMemoryTaskStore();   // the variable is the interface;
        store.add(new Task("learn interfaces", 1, false));   // swap in a JdbcTaskStore later
        System.out.println(store.isEmpty());         // and this line does not change
        List<String> xs = new ArrayList<>();         // the same habit, stdlib edition
        xs.add("program to the interface");
        System.out.println(xs);
    }
}

Run it

The main never mentions InMemoryTaskStore after the new. In part seven the same idea lets a Postgres-backed repository replace the in-memory store, and the calling code does not move.

$ java StoreDemo.java
false
[program to the interface]

InMemoryTaskStore has a list rather than being one — composition over inheritance. Java has single inheritance (extends, one superclass) and it is the most over-used feature in the language’s history: a deep extends chain welds subclasses to a parent’s implementation details, and any hierarchy more than two levels deep is a code-review flag. Interfaces impose no such coupling — a class can implement many — and default methods cover the “shared behaviour” excuse that used to justify base classes.

Now the modern half. By default an interface is open — anyone, anywhere, can implement it. A sealed interface names its complete set of implementations, and that closedness is what turns switch into a proof:

A CLOSED SET OF SHAPES, AND A SWITCH THE COMPILER PROVES COMPLETE sealed interface Payment permits Card, Cash, Transfer record Card(last4, cents) implements Payment record Cash(cents) implements Payment record Transfer(iban, cents) implements Payment String s = switch (p) { case Card c -> ...; case Cash c -> ...; case Transfer t -> ...; }; no default branch the compiler proved the set is closed Add record Voucher implements Payment, and every such switch stops compiling until the new case is handled. Go’s exhaustiveness linter and mypy’s assert_never, moved into the compiler sealed + records + switch patterns is Java’s algebraic data type: the closed set of cases lives in the type system itself. No lint to configure, no discipline to remember — a missing case is a compile error, in every build, forever.
A default branch here would be a bug you paid to keep. Write default and the compiler stops checking; leave it off and adding Voucher breaks the build at every site that must now decide what a voucher means. The Go page did this with a linter and the Python page with assert_never — Java 21 does it in javac, with no opt-in.
import java.util.List;

sealed interface Payment permits Card, Cash, Transfer {}
record Card(String last4, long cents)    implements Payment {}
record Cash(long cents)                  implements Payment {}
record Transfer(String iban, long cents) implements Payment {}

public class PaymentDemo {
    static String describe(Payment p) {
        return switch (p) {                       // switch as an EXPRESSION: it returns a value
            case Card c     -> "card *" + c.last4() + ", " + c.cents() + " cents";
            case Cash c     -> "cash, " + c.cents() + " cents";
            case Transfer t -> "transfer to " + t.iban();
        };                                        // no default: exhaustiveness is checked
    }

    public static void main(String[] args) {
        List<Payment> day = List.of(
            new Card("4242", 19_99), new Cash(5_00), new Transfer("DE89 3704", 120_00));
        for (Payment p : day) System.out.println(describe(p));
    }
}

Run it

Then do the experiment that matters: add record Voucher(long cents) implements Payment {} to the permits list and rerun — the compile error names the switch that is no longer complete.

$ java PaymentDemo.java
card *4242, 1999 cents
cash, 500 cents
transfer to DE89 3704
# after adding Voucher:
PaymentDemo.java: error: the switch expression does not cover all possible input values
Java · 07

Exceptions: the hierarchy, the resources, and Optional

Java splits failure into three families and treats one of them — checked exceptions — as part of a method’s type. Knowing which family you are holding tells you the correct reaction: fix, handle, wrap, or stand clear.

THE THROWABLE FAMILY, AND THE CORRECT REACTION TO EACH Throwable Error OutOfMemoryError, StackOverflow do not catch: the JVM is dying Exception — checked IOException, SQLException declare it or handle it: compiler checks RuntimeException — unchecked NPE, IllegalArgumentException a bug in the code: fix it, do not catch it Files.readString(path); // error: unreported IOException // must catch, or declare throws Catch what you can handle, wrap what you cannot, let the rest fly Wrapping keeps the evidence: throw new TaskStoreException("could not read tasks.csv", e) logs a Caused by chain, not amnesia. Modern style keeps most exceptions unchecked, reserving checked ones for boundaries you genuinely recover at: I/O, remote calls.
Checked exceptions are the compiler asking “and what is your plan?” That is what they buy: no I/O failure is silently ignorable, the way an unread Go err or a bare Python except: pass can be. What they cost is signature contagion — every caller must catch or re-declare — which is why modern style wraps them into unchecked exceptions at the boundary and lets everything above stay clean.

The reactions, family by family. Error means the JVM itself is in trouble — there is no catch block that un-runs-out memory. RuntimeException means your code is wrong: a NullPointerException is fixed by the null check you forgot, not by a catch. Checked exceptions — everything else under Exception — mark environmental failure that a correct program still meets: the file missing, the socket closed. The compiler makes you acknowledge those, and the idiomatic acknowledgement at a boundary is: handle it if you genuinely can, otherwise wrap it in your own unchecked exception with the cause attached and let it fly.

Resource cleanup has one modern answer. Anything AutoCloseable goes in the parentheses of try, and the JVM closes it — on success, on exception, in reverse order for multiple resources. The old try/finally dance, with its own bug where close() throws and eats the real exception, is legacy code you will read but should not write:

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;

class TaskStoreException extends RuntimeException {      // unchecked: extends RuntimeException
    TaskStoreException(String message, Throwable cause) { super(message, cause); }
}

public class ExceptionsDemo {

    static List<String> loadLines(Path file) {
        try (var reader = Files.newBufferedReader(file)) {   // try-with-resources: closed always,
            return reader.lines().toList();                  // even when an exception flies
        } catch (IOException e) {                            // the checked boundary...
            throw new TaskStoreException("could not read " + file, e);   // ...wrapped WITH cause
        }
    }

    static Optional<String> firstMatching(List<String> lines, String needle) {
        return lines.stream()                                // Optional: "maybe absent", in the
                    .filter(l -> l.contains(needle))         // return type where callers see it
                    .findFirst();
    }

    public static void main(String[] args) {
        List<String> lines = List.of("install jdk", "learn records");
        System.out.println(firstMatching(lines, "rec").orElse("no match"));
        System.out.println(firstMatching(lines, "zzz").orElse("no match"));
        loadLines(Path.of("missing.csv"));                   // watch the Caused by chain
    }
}

Run it

Two clean Optional results, then the wrapped failure. The stack trace is the payoff: your exception on top, the original NoSuchFileException preserved underneath as Caused by — both stacks, nothing lost.

$ java ExceptionsDemo.java
learn records
no match
Exception in thread "main" TaskStoreException: could not read missing.csv
        at ExceptionsDemo.loadLines(ExceptionsDemo.java:16)
        at ExceptionsDemo.main(ExceptionsDemo.java:30)
Caused by: java.nio.file.NoSuchFileException: missing.csv
        ...

Optional<T> deserves its own rules, because it is routinely misused. It exists to make “this might be absent” visible in a return type, so the caller is forced to decide — orElse, orElseThrow, map, ifPresent. That is the whole job. Never make it a field (serialisation and JPA both choke, and it solves nothing there), never a parameter (callers end up writing Optional.of(x) noise — overload instead), and never call bare .get() — that is the old NullPointerException wearing a fancier name.

Do

store.findByTitle("ship it").map(Task::title).orElse("—") — absence handled in one chain, at the call site, visibly.

Do not

opt.get() without isPresent(), Optional fields, Optional parameters, or catch (Exception e) {} — the empty catch block is the same bug as Python’s bare except: pass, and it will cost someone a weekend.

Part four

Lambdas, streams, and the functional layer

L2 · Intermediate

Java bolted a functional layer onto a language of classes, and it shows — in a good way. A lambda is not a new kind of value; it is a terse way to make an instance of an interface you already understand. Once that clicks, streams stop being magic syntax and become what they are: a pull-based pipeline over the collections you built in the earlier parts.

Java · 08

Functional interfaces and lambdas

There is no function type in Java. A lambda is an instance of an interface with exactly one abstract method, and the compiler picks which interface from the position you use it in. Everything else — composition, method references, capture rules — follows from that one fact.

A LAMBDA IS AN INSTANCE OF A ONE-METHOD INTERFACE // what you write Predicate<Task> open = t -> !t.done(); // the shape it must fit @FunctionalInterface interface Predicate<T> { boolean test(T t); } becomes // what the runtime holds: an object new Predicate<Task>() { public boolean test(Task t) { return !t.done(); // your body } } no anonymous-class file on disk: the JVM spins the instance up at first use, via invokedynamic Capture copies the value, once Java: a captured local must be effectively final — the lambda owns a copy taken when the lambda is created. Python (sibling page): the closure reads the variable when it is called — the late-binding loop trap you met there. Java refuses to compile the trap instead of running it wrongly. Annoying on Tuesday, correct on Friday.
The desugaring is the whole model. Any interface with one abstract method accepts a lambda — your own interfaces included. @FunctionalInterface only makes the compiler enforce the one-method rule; the four interfaces below cover almost every signature you will actually need.
InterfaceThe one methodReads asOn our Task
Function<T, R>R apply(T t)turn a T into an RTask::title is a Function<Task, String>
Predicate<T>boolean test(T t)a yes/no about a Tt -> !t.done()
Supplier<T>T get()produce a T on demand() -> Task.of("untitled", false)
Consumer<T>void accept(T t)do something with a TSystem.out::println

Two-argument variants exist (BiFunction, BinaryOperator), and the primitive specialisations (IntPredicate, LongFunction) avoid boxing in hot paths — you will meet those again inside streams. Here is the whole family working over tasks; note the composition methods and, or, negate and andThen, which are just default methods on the interfaces.

// Lambdas.java — one file, runnable directly (single-file launch, Java 11+)
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import java.util.function.*;

record Task(UUID id, String title, boolean done, Instant createdAt) {
    static Task of(String title, boolean done) {
        return new Task(UUID.randomUUID(), title, done, Instant.now());
    }
}

public class Lambdas {
    public static void main(String[] args) {
        var tasks = List.of(
            Task.of("write the docs", false),
            Task.of("ship v2", true),
            Task.of("fix the flaky test", false));

        Predicate<Task> open        = t -> !t.done();
        Function<Task, String> name = Task::title;         // method reference
        Supplier<Task> blank        = () -> Task.of("untitled", false);
        Consumer<String> print      = System.out::println;

        Predicate<Task> openAndUrgent =
            open.and(t -> t.title().contains("fix"));      // composition

        for (var t : tasks) {
            if (openAndUrgent.test(t)) print.accept(name.apply(t));
        }
        print.accept(blank.get().title());
    }
}

Run it

Save as Lambdas.java and run it directly — no compile step needed for a single file. Only the open task whose title contains “fix” survives the composed predicate.

$ java Lambdas.java
fix the flaky test
untitled

A method reference is a lambda the compiler writes for you, and there are exactly four shapes. Read the left column as “the thing before ::”:

Task::title           // instance method, receiver comes later:  t -> t.title()
System.out::println   // instance method on a captured object:   s -> System.out.println(s)
Task::of              // static method:                          (t, d) -> Task.of(t, d)
ArrayList::new        // constructor:                            () -> new ArrayList<>()

Capture is where Java and Python genuinely differ, not just in syntax. A Java lambda may read local variables from the enclosing method, but only if they are effectively final — assigned exactly once. The lambda gets a copy at creation time, so there is nothing to bind late and nothing to race on. The compile error below is the Python late-binding loop trap, refused at build time:

int seen = 0;
Consumer<Task> count = t -> seen++;
// error: local variables referenced from a lambda expression
// must be final or effectively final

var cell = new int[] { 0 };            // the classic dodge: a mutable box
Consumer<Task> c2 = t -> cell[0]++;    // compiles — and is a design smell;
                                       // prefer a collector or a plain loop

// Python, for contrast: fns = [lambda: i for i in range(3)] gives 2, 2, 2
// because the closure reads i when CALLED. Java cannot express that bug:
// each lambda would need its own effectively-final copy of i.

Finally, var. It is local type inference, not dynamic typing: the compiler writes the exact static type in for you, and it never changes. Use it when the type is already visible on the right-hand side; avoid it when the reader would have to open another file to know what you have.

var tasks = new ArrayList<Task>();   // fine: the type is right there
var byId  = new HashMap<UUID, Task>();

var result = service.process(id);    // avoid: what is result? go look it up
var xs = new ArrayList<>();          // trap: infers ArrayList<Object> — the
                                     // diamond has nothing to infer FROM
// var is locals-only: never fields, parameters or return types.

Where Go readers land

If you came via the Go page: a Java functional interface plays the role of a one-method Go interface, and a lambda is the literal that satisfies it. The difference is inference direction — Go infers the interface from the concrete type; Java infers the lambda’s type from the interface expected at that position (the target type). That is why a bare t -> t.done() on its own line does not compile: with no target, there is no type.

Java · 09

Streams: a lazy pipeline over your collections

stream() builds a description of work; nothing touches an element until a terminal operation starts pulling. If you internalised generators on the Python page, you already have the model — the terminal operation is the for-loop that drives the pipeline.

A · NOTHING RUNS UNTIL THE TERMINAL OPERATION PULLS List<Task> 5 elements, at rest .filter( t -> !t.done()) .map( Task::title) .collect(joining(", ")) terminal — the engine demand flows backwards: one element at a time, pulled from the end B · THE TRACE, PER ELEMENT filter "write the docs" open → map → joined filter "ship v2" done → dropped; map never sees it filter "fix the flaky test" open → map → joined The stages interleave per element — the remaining two of the five follow the same paths. No intermediate list is ever built. C · WITHOUT A TERMINAL, IT IS ONLY A PLAN var s = tasks.stream().filter(open); nothing filtered, nothing printed — a value of type Stream s.toList(); the terminal operation starts the pull and drains the source Same engine as the Python page’s generators — and the reason .limit(1) can stop after one element.
Laziness buys short-circuiting. Because elements are pulled on demand, findFirst(), anyMatch() and limit(n) stop the moment they have an answer — over a five-element list that is a curiosity; over a stream of lines from a two-gigabyte file it is the difference between milliseconds and minutes. A stream is also single-use: once a terminal operation drains it, a second one throws IllegalStateException.

The collectors are where streams earn their keep for service code: grouping, counting and joining are the three you will write weekly once the task-tracker has an API. All three appear here, plus toMap and a primitive stream for the numeric tail:

// Pipeline.java — run with: java Pipeline.java
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;

record Task(UUID id, String title, boolean done, Instant createdAt) {
    static Task of(String title, boolean done) {
        return new Task(UUID.randomUUID(), title, done, Instant.now());
    }
}

public class Pipeline {
    public static void main(String[] args) {
        var tasks = List.of(
            Task.of("write the docs", false),
            Task.of("ship v2", true),
            Task.of("fix the flaky test", false),
            Task.of("review PR 41", false),
            Task.of("rotate the API keys", true));

        Map<Boolean, Long> counts = tasks.stream()
            .collect(Collectors.groupingBy(Task::done, Collectors.counting()));

        String open = tasks.stream()
            .filter(t -> !t.done())
            .map(Task::title)
            .sorted()
            .collect(Collectors.joining(", ", "[", "]"));

        Map<UUID, String> titleById = tasks.stream()
            .collect(Collectors.toMap(Task::id, Task::title));
            // duplicate keys throw IllegalStateException — pass a merge fn if possible

        long titleChars = tasks.stream()
            .mapToLong(t -> t.title().length())   // LongStream: no boxing
            .sum();

        System.out.println(counts);
        System.out.println(open);
        System.out.println("title characters: " + titleChars);
    }
}

Run it

The grouping map counts two done and three open tasks; the join sorts the open titles and brackets them; the primitive stream sums character counts without creating five Long objects.

$ java Pipeline.java
{false=3, true=2}
[fix the flaky test, review PR 41, write the docs]
title characters: 70

To see the laziness rather than take it on faith, put a print inside the intermediate stages. Nothing appears until the terminal operation, and with limit(1) only the first element is ever touched:

var s = tasks.stream()
    .filter(t -> { System.out.println("filter " + t.title()); return !t.done(); })
    .map(t -> { System.out.println("map    " + t.title()); return t.title(); });
// nothing printed yet — the pipeline is a plan, not a loop

var first = s.limit(1).toList();
// filter write the docs
// map    write the docs
// — and it stops. The other four tasks are never examined.

When a plain for-loop is clearer

Side effects (writing to a file per element), early exit with a value plus an index, and anything that throws a checked exception — a lambda cannot throw IOException through Function, so people smuggle it out in a RuntimeException wrapper, which is worse than the loop. Streams are for transformation; loops are for procedure. The Go page’s position — just write the loop — is the right default whenever the pipeline stops reading as a sentence.

parallelStream()

It splits the work across the common ForkJoinPool — one pool shared by the whole JVM — so in a web service it steals threads from every other request, and for the list sizes a request handles the coordination costs more than it saves. Reach for it only for CPU-bound work over large in-memory data, measured before and after.

Part five

Maven, JUnit, and the project skeleton

L2 · Intermediate

Everything so far ran as single files. Real Java is a project: a directory layout every tool agrees on, a pom.xml that names your dependencies, and a lifecycle that compiles, tests and packages in one command. Maven is convention-over-configuration taken seriously — learn the conventions and the configuration nearly disappears.

Java · 10

Maven: the pom, the layout, the lifecycle

A pom declares what your project is — coordinates, dependencies, Java version — and Maven derives how to build it from a fixed lifecycle. You do not write build steps; you name a phase and get every phase before it.

NAME A PHASE, GET EVERY PHASE BEFORE IT validate pom is sane compile main → classes test surefire runs tests package jar in target/ verify integration checks install copy into ~/.m2 mvn package runs all of these, in order mvn test = validate + compile + test mvn package = the above + package (the jar you ship) mvn install = the above + verify + install (usable by other local projects) ~/.m2/repository/ org/junit/jupiter/junit-jupiter/5.11.4/*.jar org/assertj/assertj-core/3.27.3/*.jar downloaded once, cached, shared by every project you build two versions of one library requested? Maven keeps the NEAREST in the dependency tree — not the newest. Silent downgrades come from here; pin winners in dependencyManagement.
The lifecycle is the contract. Every Maven project on earth builds with the same commands, which is why CI configuration for a Java service is three lines. Plugins bind work to phases — surefire binds to test, the jar plugin to package — and the pom only appears when you need a version newer than the default.

Install Maven with the same tool that installed your JDK, then lay out a small library — tasklib — by hand once, so the conventions stick. The layout is not a suggestion: the compiler plugin looks in src/main/java, surefire looks in src/test/java, and neither is configurable in any project you should work on.

$ sdk install maven
$ mvn -v
Apache Maven 3.9.9   ← any 3.9.x is fine

tasklib/
  pom.xml
  src/
    main/java/dev/ladder/tasklib/Titles.java    # production code
    test/java/dev/ladder/tasklib/TitlesTest.java # tests, same package, never shipped

The pom below is complete — it builds. Read it top to bottom: the GAV coordinates (group, artifact, version) name the project uniquely; properties hold versions in one place; each dependency carries a scope saying which classpaths it joins; plugins are pinned so the build does not change under you.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
                             http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>dev.ladder</groupId>        <!-- the GAV: who owns it -->
  <artifactId>tasklib</artifactId>     <!-- what it is called -->
  <version>0.1.0</version>             <!-- which build of it -->
  <packaging>jar</packaging>

  <properties>
    <maven.compiler.release>21</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <junit.version>5.11.4</junit.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>${junit.version}</version>
      <scope>test</scope>              <!-- test classpath only -->
    </dependency>
    <dependency>
      <groupId>org.assertj</groupId>
      <artifactId>assertj-core</artifactId>
      <version>3.27.3</version>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.5.2</version>       <!-- pin: old defaults miss JUnit 5 -->
      </plugin>
    </plugins>
  </build>
</project>
ScopeCompiling main codeInside the shipped artifactTypical use
compile (default)yesneeded at runtimeyour actual libraries
testno — tests onlyneverjunit, assertj, mockito
providedyesno — the platform supplies itservlet API, Lombok
runtimenoyesa JDBC driver: you code to the interface

The library itself is one class, small enough that the point stays the build, not the code:

// src/main/java/dev/ladder/tasklib/Titles.java
package dev.ladder.tasklib;

import java.util.List;

public final class Titles {
    private Titles() {}

    public static String summary(List<String> titles) {
        return titles.size() + " tasks: " + String.join(", ", titles);
    }

    public static void main(String[] args) {
        System.out.println(summary(List.of("write the docs", "ship v2")));
    }
}

Run it

Create the tree, drop in the pom and the class, package, and run the jar off the classpath. The first build downloads the world into ~/.m2; every later build is quiet and quick.

$ mkdir -p tasklib/src/main/java/dev/ladder/tasklib
$ mkdir -p tasklib/src/test/java/dev/ladder/tasklib
$ cd tasklib                       # pom.xml from above at the root
$ mvn -q package
$ java -cp target/tasklib-0.1.0.jar dev.ladder.tasklib.Titles
2 tasks: write the docs, ship v2

When a build behaves oddly, the first question is always “what is actually on the classpath?”, and the answer is one command:

$ mvn dependency:tree
[INFO] dev.ladder:tasklib:jar:0.1.0
[INFO] +- org.junit.jupiter:junit-jupiter:jar:5.11.4:test
[INFO] |  +- org.junit.jupiter:junit-jupiter-api:jar:5.11.4:test
[INFO] |  \- org.junit.jupiter:junit-jupiter-engine:jar:5.11.4:test
[INFO] \- org.assertj:assertj-core:jar:3.27.3:test

Version conflicts, in one paragraph

Your dependencies have dependencies, and two of them will eventually want different versions of the same library. Maven resolves by nearest wins — the version declared closest to your pom in the tree — which is deterministic but not “newest”. When something fails with a NoSuchMethodError at runtime, run dependency:tree, find the loser marked omitted for conflict, and pin the version you need in a dependencyManagement block. Spring Boot’s parent pom, coming in the next part, is exactly that: several hundred pre-pinned versions that are tested together.

Java · 11

JUnit 5 and AssertJ: tests that read as behaviour

Surefire finds every class ending in Test, and JUnit runs each @Test method on a fresh instance of the class. That one design decision is why JUnit tests cannot leak state through fields the way a careless pytest module can.

ONE NEW TEST-CLASS INSTANCE PER @Test new TitlesTest() // instance #1 @BeforeEach freshTitles() @Test summary_counts_and_joins() @AfterEach // then #1 is garbage new TitlesTest() // instance #2 @BeforeEach freshTitles() @Test summary_of_nothing_says_no_tasks() @AfterEach Why fields cannot leak between tests Anything a test writes to an instance field dies with instance #1. Instance #2 is constructed from scratch, then its own @BeforeEach runs. pytest, for contrast, happily shares module-level state across tests unless a fixture recreates it — in JUnit the isolation is the default and sharing (static fields) is the mistake.
The lifecycle explains the annotations. @BeforeEach exists because the constructor runs before JUnit can inject anything and cannot be shared with subclasses cleanly; @AfterEach exists for resources the garbage collector will not close for you. If you truly need one instance across tests — expensive setup — that is @TestInstance(Lifecycle.PER_CLASS), an explicit opt-in you will rarely want.

Test names are documentation that cannot go stale, so write them as behaviour: summary_of_nothing_says_no_tasks reads as a sentence in the failure report, where testSummary2 reads as homework. Inside each test, keep the three phases visible — arrange, act, assert — and let AssertJ’s assertThat(actual).isEqualTo(expected) carry the assertions: it reads left to right, it cannot get expected and actual backwards, and its failure messages show diffs instead of expected: true but was: false.

// src/test/java/dev/ladder/tasklib/TitlesTest.java
package dev.ladder.tasklib;

import java.util.Collections;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

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

class TitlesTest {

    private List<String> titles;

    @BeforeEach
    void freshTitles() {                       // arrange, shared
        titles = List.of("write the docs", "ship v2");
    }

    @Test
    void summary_counts_and_joins() {
        String s = Titles.summary(titles);     // act
        assertThat(s).isEqualTo("2 tasks: write the docs, ship v2");  // assert
    }

    @Test
    void summary_of_nothing_says_no_tasks() {
        assertThat(Titles.summary(List.of())).isEqualTo("no tasks");
    }

    @ParameterizedTest                         // one method, many cases —
    @CsvSource({                               // each row is its own test
        "1, 1 tasks",
        "3, 3 tasks",
        "9, 9 tasks",
    })
    void summary_starts_with_the_count(int n, String prefix) {
        var many = Collections.nCopies(n, "x");
        assertThat(Titles.summary(many)).startsWith(prefix);
    }
}

Run it

mvn test compiles main, compiles tests, and hands both to surefire. One test fails on purpose — the empty-list behaviour does not exist yet — and the report tells you precisely how:

$ mvn test
[INFO] Running dev.ladder.tasklib.TitlesTest
[ERROR] TitlesTest.summary_of_nothing_says_no_tasks -- Time elapsed: 0.031 s <<< FAILURE!
org.opentest4j.AssertionFailedError:
expected: "no tasks"
 but was: "0 tasks: "
[ERROR] Tests run: 5, Failures: 1, Errors: 0, Skipped: 0
[INFO] BUILD FAILURE

The failure is the specification. Make it pass with the smallest honest change to Titles.summary:

public static String summary(List<String> titles) {
    if (titles.isEmpty()) return "no tasks";     // the fix the test demanded
    return titles.size() + " tasks: " + String.join(", ", titles);
}
$ mvn test
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

The assertions worth memorising

isEqualTo, isNull/isNotNull, startsWith/contains for strings, containsExactly(…) and hasSize(n) for collections, and assertThatThrownBy(() -> svc.byId(bad)).isInstanceOf(NotFound.class) for the unhappy path. Plain JUnit assertEquals(expected, actual) works everywhere AssertJ does — it is simply easier to get the argument order wrong, and its collection failures are harder to read.

Part six

Spring Boot: a service that runs

L2 · Intermediate

Spring is two ideas wearing one framework: a container that constructs and wires your objects so your classes stop doing it themselves, and auto-configuration that sets up the boring infrastructure by looking at what is on the classpath. This part generates the real tasks service, gives it the four endpoints, and teaches it to read configuration like a production citizen.

Java · 12

The container: why Spring exists at all

Dependency injection is a compile-visible discipline, not magic: every class declares what it needs as constructor parameters, and exactly one piece of the system — the container — decides what gets constructed, in what order, once.

First the disease, then the cure. Without a container, every class constructs its own dependencies, which welds the object graph shut:

// Without a container: each class builds its own world
class TaskController {
    private final TaskService service = new TaskService(new InMemoryStore());
    // - a test cannot hand in a fake TaskService or a fake store
    // - every change to TaskService's constructor edits every class that news it
    // - two controllers construct two stores: two copies of the truth
}

Inversion of control flips the arrow: TaskController says “I need a TaskService” and stops caring where it comes from. The container scans your package for stereotyped classes, works out the dependency graph from constructor signatures, and instantiates each bean exactly once, in dependency order.

COMPONENT SCAN FINDS CLASSES; THE CONTAINER WIRES OBJECTS scan of dev.ladder.tasks TaskController @RestController TaskService @Service TimeConfig @Configuration classes carrying a stereotype become bean definitions ApplicationContext one object of each, wired once taskController taskService clock (Clock, from TimeConfig) constructor parameters ARE the arrows dispatcherServlet tomcatWebServer objectMapper from auto-config classpath starter-web starter-validation starter-actuator each starter is only a curated dependency list auto-configuration reacts to what is present: web on the classpath → an embedded Tomcat bean appears Inversion, in one sentence Your classes declare what they need in their constructors; the container — not your code — decides construction order and calls new exactly once per bean. Tests call the same constructors by hand, passing fakes.
Every arrow in the graph is a constructor parameter. That is why constructor injection is the house rule: the dependency graph is visible in plain Java, a bean cannot exist half-wired, and a class with seven constructor parameters announces its own design problem. Field injection (@Autowired on a private field) hides all three and is the review flag.

Run it

Generate the real project from start.spring.io — the same generator the web UI uses — and boot it. First run downloads dependencies; then the banner, then Tomcat announcing port 8080, then your application name and startup time. Ctrl-C stops it.

$ curl https://start.spring.io/starter.tgz \
    -d dependencies=web,validation,actuator \
    -d javaVersion=21 -d type=maven-project \
    -d groupId=dev.ladder -d artifactId=tasks -d baseDir=tasks | tar -xzf -
$ cd tasks
$ ./mvnw spring-boot:run          # mvnw: the project carries its own Maven
  .   ____          _            __ _ _
 :: Spring Boot ::                (v3.5.5)
...Tomcat started on port 8080 (http) with context path '/'
...Started TasksApplication in 1.84 seconds (process running for 2.1)

Those two log lines are the contract: an embedded servlet container is listening — there is no separate application server to install, the jar is the server — and the context started, meaning every bean constructed and wired without error. The whole application bootstrap is one class:

// src/main/java/dev/ladder/tasks/TasksApplication.java — generated for you
package dev.ladder.tasks;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication      // = @Configuration           (this class may declare beans)
                            // + @EnableAutoConfiguration (react to the classpath)
                            // + @ComponentScan           (scan THIS package and below)
public class TasksApplication {
    public static void main(String[] args) {
        SpringApplication.run(TasksApplication.class, args);
    }
}

The scan starts at the package of this class, which is why your code lives in dev.ladder.tasks or below it — a class in a sibling package is silently invisible to the container, a classic first-week bug. There are three ways a bean comes to exist, and you will use all three:

@Service                                  // stereotype: "scan me in" — @Component
public class TaskService {                // with a role name; @Repository is the third
    private final Clock clock;            // one final field per dependency

    public TaskService(Clock clock) {     // single constructor: no @Autowired needed,
        this.clock = clock;               // Spring uses it automatically
    }
}

@Configuration
class TimeConfig {
    @Bean                                 // @Bean: for types you don't own and can't
    Clock clock() {                       // annotate — the method body is the recipe
        return Clock.systemUTC();
    }
}

The review flag, spelled out

@Autowired private TaskService service; on a field means: the field cannot be final, the class can be constructed in an invalid half-empty state, and a plain unit test needs reflection to install a fake. Constructor injection costs four lines and removes all three problems. If a constructor grows past four or five parameters, that is the design talking — split the class, do not reach for field injection to hide the count.

If you came from the Go or Python pages: this is the same “accept interfaces at the edges, wire in main” discipline you already know — FastAPI’s Depends plays the same role — except the wiring code you wrote by hand in Go’s main() is derived from constructor signatures here. The trade: you write less plumbing, and in exchange the construction order lives in a framework you must trust and occasionally debug.

Java · 13

The Task API: records in, records out

A controller is a thin translation layer: HTTP in, domain call, HTTP out. Records make both edges honest — the request record says exactly what a client may send, the response record exactly what it will get back, and validation runs before your method is even called.

ONE POST /tasks, HOP BY HOP curl POST /tasks {"title":"write the docs"} DispatcherServlet route: POST /tasks Jackson: body → record, @Valid TaskController .create(req) validation already passed TaskService store.put(id, task) Task(id, title, false, now) and back returns Task the immutable record ResponseEntity .created(uri) status 201 + Location Jackson TaskResponse → JSON body HTTP/1.1 201 Location: /tasks/{id} {"id":"...","done":false} title blank → @Valid throws before create() runs MethodArgumentNotValidException → HTTP 400 with a JSON error body The controller method never sees a request that failed validation — the same edge-validation doctrine as pydantic on the Python page.
Why two records instead of exposing Task? The request record exists so a client cannot set id, done or createdAt — fields the server owns. The response record exists so the wire format can stay stable when the domain type later grows fields the API should not leak. The pairs of ten-second classes are the cheapest API-stability insurance you will ever buy.

Four small files, all in dev.ladder.tasks. The domain record and the two API records first:

// Task.java — the domain record: immutable, equality by value
package dev.ladder.tasks;

import java.time.Instant;
import java.util.UUID;

public record Task(UUID id, String title, boolean done, Instant createdAt) {}

// CreateTaskRequest.java — what a client MAY send
package dev.ladder.tasks;

import jakarta.validation.constraints.NotBlank;

public record CreateTaskRequest(@NotBlank String title) {}

// TaskResponse.java — what a client WILL receive
package dev.ladder.tasks;

import java.time.Instant;
import java.util.UUID;

public record TaskResponse(UUID id, String title, boolean done, Instant createdAt) {
    static TaskResponse from(Task t) {
        return new TaskResponse(t.id(), t.title(), t.done(), t.createdAt());
    }
}

The service owns the store — a ConcurrentHashMap for now; part seven of the series swaps it for Postgres behind the same method signatures, which is the whole reason the controller never touches the map directly. Note markDone replaces the record rather than mutating it: records have no setters, so “update” means “new value, same key”, and computeIfPresent makes the read-modify-write atomic:

// TaskService.java
package dev.ladder.tasks;

import java.time.Instant;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Service;

@Service
public class TaskService {
    private final Map<UUID, Task> store = new ConcurrentHashMap<>();

    public Task create(String title) {
        var task = new Task(UUID.randomUUID(), title, false, Instant.now());
        store.put(task.id(), task);
        return task;
    }

    public List<Task> all() {
        return store.values().stream()
            .sorted(Comparator.comparing(Task::createdAt))   // part two, earning rent
            .toList();
    }

    public Optional<Task> byId(UUID id) {
        return Optional.ofNullable(store.get(id));
    }

    public Optional<Task> markDone(UUID id) {
        return Optional.ofNullable(store.computeIfPresent(id,
            (k, t) -> new Task(t.id(), t.title(), true, t.createdAt())));
    }
}

The controller maps the four endpoints. Where the happy path is the only path (GET /tasks), return the value and let Spring produce the 200; where the status code carries meaning — 201 with a Location, 404 for a missing id — return ResponseEntity and say so explicitly:

// TaskController.java
package dev.ladder.tasks;

import jakarta.validation.Valid;
import java.net.URI;
import java.util.List;
import java.util.UUID;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController                      // @Controller + @ResponseBody: returns become JSON
@RequestMapping("/tasks")
public class TaskController {
    private final TaskService service;

    public TaskController(TaskService service) {   // constructor injection, again
        this.service = service;
    }

    @PostMapping
    ResponseEntity<TaskResponse> create(@Valid @RequestBody CreateTaskRequest req) {
        var task = service.create(req.title());
        return ResponseEntity
            .created(URI.create("/tasks/" + task.id()))   // 201 + Location header
            .body(TaskResponse.from(task));
    }

    @GetMapping
    List<TaskResponse> all() {
        return service.all().stream().map(TaskResponse::from).toList();
    }

    @GetMapping("/{id}")
    ResponseEntity<TaskResponse> byId(@PathVariable UUID id) {
        return service.byId(id)
            .map(TaskResponse::from)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());   // empty Optional → 404
    }

    @PatchMapping("/{id}/done")
    ResponseEntity<TaskResponse> done(@PathVariable UUID id) {
        return service.markDone(id)
            .map(TaskResponse::from)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }
}

Run it

Restart ./mvnw spring-boot:run, then exercise all four endpoints from a second terminal. Copy the id from the first response into the later calls. The final request proves @Valid is on duty: a blank title never reaches your code.

$ curl -si -X POST localhost:8080/tasks \
    -H 'Content-Type: application/json' -d '{"title":"write the docs"}'
HTTP/1.1 201
Location: /tasks/8b6c2f1a-6f9d-4c3e-9b1a-2d7e5a4c9f10
{"id":"8b6c2f1a-...","title":"write the docs","done":false,"createdAt":"2026-08-24T09:14:02.113Z"}

$ curl -s localhost:8080/tasks
[{"id":"8b6c2f1a-...","title":"write the docs","done":false,"createdAt":"..."}]

$ curl -s -X PATCH localhost:8080/tasks/8b6c2f1a-6f9d-4c3e-9b1a-2d7e5a4c9f10/done
{"id":"8b6c2f1a-...","title":"write the docs","done":true,"createdAt":"..."}

$ curl -si -X POST localhost:8080/tasks \
    -H 'Content-Type: application/json' -d '{"title":""}'
HTTP/1.1 400
{"timestamp":"...","status":400,"error":"Bad Request","path":"/tasks"}

Where the 400 body comes from

You wrote no error handler; Spring’s default one caught the validation exception and rendered that JSON. When you want your own shape — field names and messages per violation — that is a @RestControllerAdvice class with an @ExceptionHandler(MethodArgumentNotValidException.class) method: one place, every controller, same doctrine as FastAPI’s exception handlers on the Python page.

Java · 14

Configuration, profiles and the actuator

Configuration is layered, and the layers have a fixed precedence: a YAML default, a profile file over it, an environment variable over both, a command-line flag over everything. Bind the result to a typed record once, at start-up — a bad value should kill the boot, not the 3 a.m. request.

ONE PROPERTY, FIVE SOURCES — THE LAST WORD WINS 1 · code default new TasksProperties(100, ...) max-open: 100 2 · application.yml tasks.max-open: 50 max-open: 50 3 · application-prod.yml only when the prod profile is active max-open: 20 4 · env var TASKS_MAX_OPEN=10 max-open: 10 5 · command line --tasks.max-open=5 max-open: 5 wins later sources override Relaxed binding: how an env var finds a property Upper-case the name, turn dots into underscores, drop or underscore the dashes: tasks.max-open binds from TASKS_MAX_OPEN and from TASKS_MAXOPEN alike. Deployment scripts stop caring about your key style.
The ladder is why the same jar runs everywhere. Committed YAML holds the defaults and the per-environment shape; the environment injects secrets and machine-specific values at the last minute; a flag wins for one-off debugging. Nothing is rebuilt between laptop and production — the same idea the Python page reached with pydantic-settings, with the same rule: resolve and validate once, at start-up.

YAML over properties files, for one reason: nesting reads as structure instead of repeated prefixes. Replace the generated application.properties with application.yml, and add a prod overlay next to it:

# src/main/resources/application.yml — the defaults, committed
tasks:
  max-open: 50
  banner: "task-tracker (dev)"

management:
  endpoints:
    web:
      exposure:
        include: health,info    # expose deliberately — never "*" in prod

# src/main/resources/application-prod.yml — only the differences
tasks:
  max-open: 20
  banner: "task-tracker"
management:
  server:
    port: 9090                  # actuator on its own port, off the public LB

Now the binding. A record plus one annotation turns that YAML subtree into a typed, immutable object; the container constructs it at start-up and fails the boot — with the offending key in the message — if a value is missing or the wrong type. Inject it like any other bean; never read System.getenv from business code:

// TasksProperties.java — kebab-case keys bind to camelCase components
package dev.ladder.tasks;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "tasks")
public record TasksProperties(int maxOpen, String banner) {}

// TasksApplication.java — register it (or use @ConfigurationPropertiesScan)
@SpringBootApplication
@EnableConfigurationProperties(TasksProperties.class)
public class TasksApplication { ... }

// TaskService.java — configuration arrives by constructor, like everything else
@Service
public class TaskService {
    private final Map<UUID, Task> store = new ConcurrentHashMap<>();
    private final TasksProperties props;

    public TaskService(TasksProperties props) {
        this.props = props;
    }

    public Task create(String title) {
        long open = store.values().stream().filter(t -> !t.done()).count();
        if (open >= props.maxOpen()) {
            throw new IllegalStateException("open-task limit reached: " + props.maxOpen());
        }
        var task = new Task(UUID.randomUUID(), title, false, Instant.now());
        store.put(task.id(), task);
        return task;
    }
    // all(), byId(), markDone() unchanged
}

Run it

Activate the prod profile through the environment and watch the log confirm it; then hit the actuator. With the prod overlay above, management moved to 9090 — a health check the public load balancer cannot reach by accident.

$ SPRING_PROFILES_ACTIVE=prod TASKS_MAX_OPEN=10 ./mvnw spring-boot:run
...The following 1 profile is active: "prod"
...Tomcat started on port 8080 (http)
...Exposing 2 endpoints beneath base path '/actuator'

$ curl -s localhost:9090/actuator/health
{"status":"UP"}
$ curl -s localhost:9090/actuator/info
{}                                  # empty until you add info.* properties
$ curl -s localhost:8080/actuator/health
{"timestamp":"...","status":404,"error":"Not Found","path":"/actuator/health"}
                                  # 8080 still serves the API, but actuator moved — good

Which value of tasks.max-open won? The env var: 10 beats the prod file’s 20, which beat the default file’s 50 — rungs four, three, two of the ladder. Add --tasks.max-open=5 after spring-boot:run (as -Dspring-boot.run.arguments) and rung five wins instead.

What the actuator is for

/actuator/health is what Kubernetes probes and load balancers poll — it aggregates checks (disk, and later your database) into one UP/DOWN. /actuator/info publishes build metadata — git commit, version — so you can ask a running instance what it is. Both come from the starter you added at generation time; you wrote none of it.

Exposure is opt-in for a reason

The actuator also offers /env, /heapdump and /threaddump — a gift to an attacker if left on the public port. Keep exposure.include to the endpoints you actually poll, and put management on its own port in prod, as above. Treat any deployment with include: "*" on 8080 as an incident waiting for its date.

Part seven

The database, done properly

L2 · Intermediate

The task service from part six keeps everything in a ConcurrentHashMap, which is a polite way of saying it forgets everything on restart. This part gives it Postgres: Docker Compose runs the database, Flyway owns the schema, Spring Data JPA writes the queries. Then the two places JPA actually hurts people — transactions that silently are not transactions, and the N+1 query — because both will find you within a month of shipping.

Java · 15

Postgres, Flyway, and Spring Data JPA

You add one entity class, one repository interface and one SQL file, and Spring Data generates the query code when the context starts. The decision that matters is the boundary: entities stay inside the service; the API keeps speaking the records from part six.

ONE REQUEST, TOP TO BOTTOM WHO WRITES IT TaskController — records in, records out you — part six TaskService — the rules live here you — rewired today, same API interface TaskRepository extends JpaRepository you — one method name per query your code stops here Spring Data proxy — findByDoneFalse() becomes SQL nobody — generated at startup Hibernate — entities to SQL, dirty checking, caching a dependency — the ORM itself JDBC driver (org.postgresql) + Hikari pool a dependency — the wire protocol PostgreSQL 16 — a separate process, in Docker schema owned by Flyway migrations, never by Hibernate Below the interface line you write no code at all — the method name is parsed into a query when the context starts, so a typo in it fails the boot, not the request.
Four of the seven layers arrive as dependencies, which is the trade. You write three thin layers and inherit a million lines of ORM underneath — productive on day one, and opaque on the day a query is slow. The rest of this part is about seeing through the two opaque layers.

Three additions to the pom.xml from part six. The JPA starter brings Hibernate and Spring Data; the driver is runtime scope because you compile against JDBC interfaces, not against Postgres; Flyway needs its Postgres module since Flyway 10 split databases out of core.

<!-- pom.xml, inside <dependencies> -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
  <groupId>org.postgresql</groupId>
  <artifactId>postgresql</artifactId>
  <scope>runtime</scope>
</dependency>
<dependency>
  <groupId>org.flywaydb</groupId>
  <artifactId>flyway-core</artifactId>
</dependency>
<dependency>
  <groupId>org.flywaydb</groupId>
  <artifactId>flyway-database-postgresql</artifactId>
</dependency>

The database itself is a compose.yaml next to the pom. The healthcheck matters: without it, “the container is up” and “Postgres accepts connections” are two different moments, and your app can boot into the gap.

# compose.yaml, in the project root
services:
  db:
    image: postgres:16
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: tasks
      POSTGRES_PASSWORD: tasks
      POSTGRES_DB: tasks
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U tasks"]
      interval: 2s
      timeout: 2s
      retries: 15

Run it

Start the database and wait for the healthcheck to flip. Everything from here to part ten assumes this container is running.

$ docker compose up -d
$ docker compose ps
NAME       IMAGE         STATUS
tasks-db-1 postgres:16   Up 8 seconds (healthy)   ← healthy, not just Up

The hands-free alternative

Add the spring-boot-docker-compose dependency and Boot runs docker compose up for you on spring-boot:run, reads the container’s ports and injects the datasource properties itself. Handy locally; this page wires it by hand once so you know what the module is doing.

The datasource goes into application.yml. Two lines here are load-bearing: ddl-auto: validate means Hibernate checks the schema against the entities and refuses to start on a mismatch — it never modifies the database — and open-in-view: false is explained in the next topic.

# src/main/resources/application.yml
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/tasks
    username: tasks
    password: tasks
  jpa:
    hibernate:
      ddl-auto: validate
    open-in-view: false

So who creates the table? Flyway. On startup it scans db/migration, compares against its own history table, and applies whatever is new, in version order, each in a transaction. Schema changes become files in git that review like code, because they are code.

-- src/main/resources/db/migration/V1__create_tasks.sql
create table tasks (
  id         uuid primary key,
  title      text        not null,
  done       boolean     not null default false,
  created_at timestamptz not null
);

create index tasks_done_idx on tasks (done);

Never ddl-auto: update outside a demo

update lets Hibernate alter the schema to match your classes at boot. It only adds, never drops or narrows, so the schema drifts from what any migration says; two instances racing at deploy time can both try the DDL; and a refactor that renames a field silently creates a second column and strands your data in the first. validate plus Flyway gives you the same convenience with a written history and a place to say no.

Now the entity. It is deliberately a class, not a record: JPA instantiates it through a no-argument constructor and mutates its fields when it loads rows — a record’s finality and single constructor fight that contract. Keep the record Task as the API type and translate at the edge.

// src/main/java/dev/ladder/tasks/TaskEntity.java
package dev.ladder.tasks;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;

@Entity
@Table(name = "tasks")
class TaskEntity {

  @Id
  private UUID id;

  @Column(nullable = false)
  private String title;

  @Column(nullable = false)
  private boolean done;

  @Column(name = "created_at", nullable = false)
  private Instant createdAt;

  protected TaskEntity() { }               // for JPA only; nobody else calls it

  TaskEntity(UUID id, String title, boolean done, Instant createdAt) {
    this.id = id;
    this.title = title;
    this.done = done;
    this.createdAt = createdAt;
  }

  Task toTask() { return new Task(id, title, done, createdAt); }

  UUID id()            { return id; }
  boolean done()       { return done; }
  void markDone()      { this.done = true; }
}

The repository is the whole data-access layer, and you only write its signature. Method names in the findByDoneFalse style are parsed into queries at startup; anything the naming scheme cannot say cleanly gets an explicit @Query later.

// src/main/java/dev/ladder/tasks/TaskRepository.java
package dev.ladder.tasks;

import java.util.List;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;

interface TaskRepository extends JpaRepository<TaskEntity, UUID> {

  List<TaskEntity> findByDoneFalse();      // select ... where done = false

  long countByDone(boolean done);          // select count(*) ... where done = ?
}

Rewiring TaskService is mechanical: the map becomes the repository, and the entity-to-record mapping happens in exactly one class. The DTO records and the create/list endpoints from part six do not change — that is the boundary paying for itself. The two lookup methods do change, and get shorter; they are below.

// TaskService.java -- the ConcurrentHashMap is gone
@Service
public class TaskService {

  private final TaskRepository repo;

  public TaskService(TaskRepository repo) {   // constructor injection, as always
    this.repo = repo;
  }

  public Task create(String title) {
    var e = new TaskEntity(UUID.randomUUID(), title, false, Instant.now());
    return repo.save(e).toTask();
  }

  public List<Task> all() {
    return repo.findAll().stream().map(TaskEntity::toTask).toList();
  }

  public Task get(UUID id) {
    return repo.findById(id).map(TaskEntity::toTask)
               .orElseThrow(() -> new TaskNotFoundException(id));
  }

  public Task markDone(UUID id) {
    var e = repo.findById(id).orElseThrow(() -> new TaskNotFoundException(id));
    e.markDone();
    return repo.save(e).toTask();
  }
}

TaskNotFoundException is new — the domain’s way of saying “no such row”, deliberately a RuntimeException so it can cross the service boundary uncaught. Part nine maps it to a proper 404 problem document; until then it surfaces as a 500, which is honest enough for now:

// TaskNotFoundException.java -- two lines, whole file
class TaskNotFoundException extends RuntimeException {
  TaskNotFoundException(UUID id) { super("No task with id " + id); }
}

And the two controller lookup methods stop juggling an Optional — call the service, let the exception fly:

// TaskController.java -- byId and done, after the rewire
@GetMapping("/{id}")
TaskResponse byId(@PathVariable UUID id) {
    return TaskResponse.from(service.get(id));
}

@PatchMapping("/{id}/done")
TaskResponse done(@PathVariable UUID id) {
    return TaskResponse.from(service.markDone(id));
}

Run it

Boot the app, create a task over HTTP, then look at the actual row with psql inside the container. Restart the app and GET /tasks still returns it — the map never did.

$ ./mvnw spring-boot:run
... Flyway ... Successfully applied 1 migration to schema "public"
... Tomcat started on port 8080
$ curl -s -X POST localhost:8080/tasks -H 'content-type: application/json' \
       -d '{"title":"wire up postgres"}'
{"id":"9c7b...","title":"wire up postgres","done":false,"createdAt":"2026-08-24T..."}
$ docker compose exec db psql -U tasks -c 'select title, done from tasks'
      title       | done
 wire up postgres | f          ← a real row; it survives restarts
Java · 16

@Transactional is a proxy, and N+1 is a lazy list

The container does not change your method; it wraps your bean in a generated subclass that opens a transaction before the call and commits after. Everything surprising about @Transactional — and about lazy loading — follows from where that wrapper does and does not sit.

THROUGH THE PROXY SELF-INVOCATION SKIPS IT controller proxy — a generated subclass 1. get connection, begin TaskService — your code 2. markDone(id) runs entity mutated in memory (dirty checking sees it) 3. flush + commit on return, roll back on unchecked throw @Cacheable, @Async and @Retryable are the same trick: behaviour lives in the wrapper. proxy — entered once, for nightly() SweepService — the real object nightly() { this.sweep(); } @Transactional sweep() { ... } plain Java call The call never reaches the wrapper: no transaction, no rollback — and no error. The fix is structural, not an annotation: move the transactional method onto another bean, so the call has to cross a proxy to reach it.
Half of Spring is this one picture. Annotations that promise behaviour — transactions, caching, async — are implemented by a wrapper object standing in front of yours. Any call that does not pass through the wrapper gets none of the behaviour, and self-invocation is the common way to not pass through it.

The working rules, applied to the service. Reads get readOnly = true — it lets Hibernate skip dirty-checking snapshots and hints the driver. Writes get the default. And note markDone no longer calls save: inside a transaction the loaded entity is managed, so mutating it is enough — the flush at commit writes the UPDATE.

@Service
public class TaskService {
  // ... constructor as before ...

  @Transactional(readOnly = true)
  public List<Task> open() {
    return repo.findByDoneFalse().stream().map(TaskEntity::toTask).toList();
  }

  @Transactional
  public Task markDone(UUID id) {
    var e = repo.findById(id).orElseThrow(() -> new TaskNotFoundException(id));
    e.markDone();          // managed entity: the commit flushes the UPDATE
    return e.toTask();     // no save() needed inside a transaction
  }
}

Three rules that are not visible in that code. Rollback happens for unchecked exceptions only — a checked exception commits unless you say @Transactional(rollbackFor = Exception.class). A transaction holds a pooled connection from first query to commit, so keep it short: with Hikari’s default pool of 10, one HTTP call made inside a transaction means ten slow requests can hold every connection while they wait on someone else’s API. And the classic silent failure:

@Service
public class SweepService {

  public void nightly() {
    this.sweep();          // same object, plain call: the proxy never sees it
  }                        // sweep() runs with NO transaction, silently

  @Transactional
  public void sweep() { /* delete old done tasks */ }
}

Now the other trap. Give tasks some tags — a second table, a lazy collection — and watch what a list endpoint does.

-- src/main/resources/db/migration/V2__task_tags.sql
create table task_tags (
  task_id uuid not null references tasks (id),
  tag     text not null
);

// added to TaskEntity -- collections are LAZY by default, correctly
@ElementCollection
@CollectionTable(name = "task_tags", joinColumns = @JoinColumn(name = "task_id"))
@Column(name = "tag")
private List<String> tags = new ArrayList<>();

Lazy means the tags are not loaded until touched. Touch them while mapping 100 tasks to records and you issue 1 query for the tasks plus 100 for the tags — the N+1 problem, invisible at 100 rows on localhost and fatal at 100,000 in production. The log below is the diagnostic; the fix is to tell the one query to fetch the relation.

-- logging.level.org.hibernate.SQL: DEBUG, then GET /tasks:
select t1_0.id,t1_0.created_at,t1_0.done,t1_0.title from tasks t1_0
select t1_0.tag from task_tags t1_0 where t1_0.task_id=?
select t1_0.tag from task_tags t1_0 where t1_0.task_id=?
select t1_0.tag from task_tags t1_0 where t1_0.task_id=?
-- ... once per task. 100 tasks = 101 round trips.
// fix 1: say the join in JPQL
@Query("select distinct t from TaskEntity t left join fetch t.tags where t.done = false")
List<TaskEntity> findOpenWithTags();

// fix 2: keep the derived query, declare what to fetch
@EntityGraph(attributePaths = "tags")
List<TaskEntity> findByDoneFalse();

// fix 3: skip entities entirely when the caller wants two fields
public record TaskSummary(UUID id, String title) { }

@Query("""
    select new dev.ladder.tasks.TaskSummary(t.id, t.title)
    from TaskEntity t where t.done = false""")
List<TaskSummary> openSummaries();       // one query, no entities, nothing lazy

open-in-view: the default that hides all of this

Boot’s default (true, with a warning at startup) keeps the Hibernate session open while the JSON response is serialised, so a lazy collection touched by Jackson quietly loads mid-response. That makes N+1 invisible in development, moves query errors into the middle of a half-written response, and holds a pooled connection for the whole request. You already set open-in-view: false in the datasource YAML of the previous topic; with it, touching an unfetched lazy relation outside the service throws LazyInitializationException — loud, early, and exactly what you want.

Run it

Turn on SQL logging, hit the list endpoint with a few tasks in the table, and count the selects. Then switch all() to the join fetch query and watch 101 queries become 1.

$ ./mvnw spring-boot:run -Dspring-boot.run.arguments=--logging.level.org.hibernate.SQL=DEBUG
$ curl -s localhost:8080/tasks > /dev/null
org.hibernate.SQL : select t1_0.id, ... from tasks t1_0
org.hibernate.SQL : select t1_0.tag from task_tags ... where task_id=?   ← xN
# after the join fetch:
org.hibernate.SQL : select ... from tasks t1_0 left join task_tags ...   ← one query
Part eight

Concurrency and the machine underneath

L3 · Advanced

The service works. This part is about the machine it runs on: how the JVM schedules threads and why Java 21 changed that story, and how it manages memory and why your container limit is not your heap size. Both topics decide whether the service survives contact with load.

Java · 17

Executors, shared state, and virtual threads

You never create threads by hand in a service; you hand tasks to an executor that owns a bounded pool. Java 21 then quietly removed the reason the pool had to be small.

PLATFORM THREADS: 1 = 1 OS THREAD VIRTUAL THREADS: MOUNTED WHILE RUNNING request 1 — running — 1 MB stack, 1 OS thread request 2 — blocked on JDBC — OS thread idle request 3 — blocked on HTTP — OS thread idle request 4 — running Tomcat’s default: 200 of these. Under slow I/O the pool is exhausted by threads doing nothing at all. each square: one virtual thread — shaded = currently mounted carrier 1 carrier 2 carrier 3 carriers: about CPU count. A blocked virtual thread is unmounted: a few hundred bytes on the heap, no OS thread. The event loop from the Python page, without the event loop. asyncio bought this throughput by rewriting every function around await. The JVM moved the same trick under the scheduler instead: your code stays plain blocking Java, and the blocking simply stops costing an OS thread. 100,000 virtual threads in one JVM is routine; 100,000 platform threads is an outage.
Virtual threads change capacity, not correctness. Races, locks and visibility work exactly as before — a million cheap threads mutating one long lose updates just as enthusiastically as two expensive ones. The shared-state rules below matter more after Java 21, not less.

First the classic toolkit, because most codebases still run on it. An ExecutorService owns the threads; you submit work and hold a Future. CompletableFuture adds composition — run two things, combine the results — and every blocking wait gets a timeout, the same reflex the Go page drilled with contexts.

ExecutorService pool = Executors.newFixedThreadPool(8);  // bounded, always

// submit + Future: fine for one-off background work
Future<Long> open = pool.submit(() -> repo.countByDone(false));
long n = open.get(2, TimeUnit.SECONDS);        // never get() without a timeout

// CompletableFuture: run both counts in parallel, combine, bound the whole thing
var openF = CompletableFuture.supplyAsync(() -> repo.countByDone(false), pool);
var doneF = CompletableFuture.supplyAsync(() -> repo.countByDone(true),  pool);

long total = openF.thenCombine(doneF, Long::sum)
                  .orTimeout(2, TimeUnit.SECONDS)
                  .join();

pool.shutdown();   // in Spring: expose the pool as a @Bean so the container closes it

Shared state, in one pass. The demo below is the whole problem: counter++ is a read, an add and a write, and two threads interleaving those three steps overwrite each other’s updates. No exception, no warning — just a wrong number.

// Race.java -- run it, the count is wrong, and differently wrong each time
public class Race {
  static long counter = 0;                      // plain shared field

  public static void main(String[] args) throws InterruptedException {
    Runnable inc = () -> { for (int i = 0; i < 1_000_000; i++) counter++; };
    Thread a = new Thread(inc);
    Thread b = new Thread(inc);
    a.start(); b.start();
    a.join();  b.join();
    System.out.println(counter);                // almost never 2000000
  }
}
// the fixes, smallest hammer first
static final AtomicLong counter = new AtomicLong();
counter.incrementAndGet();                       // lock-free read-modify-write

// per-key atomic updates on a shared map -- no lock around the whole map
ConcurrentHashMap<String, Long> hits = new ConcurrentHashMap<>();
hits.compute("tasks", (k, v) -> v == null ? 1 : v + 1);

// synchronized: mutual exclusion AND visibility, for multi-field invariants
synchronized (lock) { balance -= amount; ledger.add(entry); }

// volatile: visibility only -- a flag one thread writes, others read.
// It does NOT make ++ atomic; that mistake is the race above with extra steps.
volatile boolean shuttingDown = false;

Run it

Run the race twice, then swap the long for an AtomicLong and run again. Wrong, differently wrong, then exactly right.

$ javac Race.java && java Race
1274832        ← lost about 725,000 increments
$ java Race
1391246        ← a different wrong answer -- the signature of a race
# after AtomicLong:
$ java Race
2000000        ← every time

Now the Java 21 headline. A virtual thread is a thread whose stack lives on the heap; the JVM mounts it on a carrier (an OS thread) only while it is actually running, and unmounts it the moment it blocks. Creating one is cheap enough that pooling them is an anti-pattern — you create one per task and throw it away.

Thread.ofVirtual().name("enrich-", 0).start(() -> enrich(taskId));

// one virtual thread per task; close() waits for all of them
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
  for (UUID id : ids) {
    exec.submit(() -> enrich(id));     // 10,000 submissions: fine
  }
}

# and in Spring Boot -- one line in application.yml:
spring:
  threads:
    virtual:
      enabled: true   # Tomcat serves each request on a virtual thread

With that property on, the blocking repository calls this page just wrote stop costing platform threads while they wait on Postgres — the thread-pool ceiling on concurrent requests effectively disappears, and the next limit is the database pool, which is where you want it.

The pinning caveat

On Java 21, a virtual thread that blocks inside a synchronized block pins its carrier — the OS thread waits after all. Old drivers and libraries that guard I/O with synchronized can quietly re-create the platform-thread ceiling. Prefer ReentrantLock around blocking calls, and run with -Djdk.tracePinnedThreads=short to see pinning happen. (JDK 24 removed this limitation; on 21 it is real.)

Java · 18

The heap, the collector, and the container limit

Allocation in Java is a pointer bump — nearly free. The bill arrives as garbage-collection pauses, and in containers, as the gap between what you told the JVM (-Xmx) and what you told Kubernetes (the memory limit). Those are two different numbers and the kernel enforces the second.

A 512 MiB POD, CONFIGURED TWO WAYS container limit 512 MiB — past this line the kernel kills the pod: exit 137 -Xmx512m heap — all 512 MiB of budget spent here metaspace + stacks + buffers land past the limit → OOMKilled -XX:MaxRAMPercentage=75 heap — 384 MiB (75% of the cgroup limit) metaspace stacks the unlabelled slice: direct buffers + code cache; the gap before the line is headroom that absorbs a spike The JVM never reads your Deployment yaml. MaxRAMPercentage sizes the heap from the cgroup limit it actually runs under, leaving room for all the memory that is not heap — which is the memory -Xmx pretends does not exist.
This is the OOMKilled mystery from the cloud page, solved. A pod with a 512Mi limit and -Xmx512m is a promise the process cannot keep: the JVM’s footprint is heap plus metaspace (class metadata), thread stacks (about 1 MB each), direct buffers and the JIT’s code cache. The kernel counts all of it; -Xmx caps only the first slice.

What the collector is doing with that heap: nearly all objects die young, so the heap is split into a young generation — collected often and cheaply, because collecting means copying the few survivors — and an old generation for whatever survives repeatedly, collected rarely and more expensively. Allocation itself is a thread-local pointer bump, which is why idiomatic Java allocates freely and why the cost model is the opposite of C’s: creating garbage is cheap, keeping objects alive is what costs.

G1, the default collector, does this region by region against a pause target (200 ms by default) and is the right answer until measured otherwise. ZGC (-XX:+UseZGC) trades some throughput for sub-millisecond pauses on heaps up to terabytes — one flag, when p99 latency says so.

# the flags that matter, and the ones to stop using
java -Xms256m -Xmx256m -jar app.jar        # fixed heap: fine on a VM you own

# in a container, size from the cgroup limit instead of hard-coding:
java -XX:MaxRAMPercentage=75 \
     -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/tmp/heap.hprof \
     -jar app.jar

# flight recording: always-on production profiling, ~1% overhead
java -XX:StartFlightRecording=duration=60s,filename=rec.jfr -jar app.jar
jfr print --events jdk.GCPhasePause rec.jfr   # or open rec.jfr in JDK Mission Control

When the heap does run out you get java.lang.OutOfMemoryError — an Error, not an Exception, and not one to catch: the useful move is the flag above, which writes a heap dump on the way down so you can see what filled the heap rather than guessing. And for a JVM that is alive but suspicious, jcmd asks it questions directly — no restart, no agent, nothing installed.

Run it

Package the task service, start it with an absurdly small heap and watch it die; start it properly and interrogate it with jcmd. The thread dump is the one to remember — it is how you diagnose a hung service at 3am.

$ ./mvnw -q package -DskipTests
$ java -Xmx16m -jar target/tasks-0.0.1-SNAPSHOT.jar
java.lang.OutOfMemoryError: Java heap space   ← dies during startup
$ java -XX:MaxRAMPercentage=75 -jar target/tasks-0.0.1-SNAPSHOT.jar &
$ jcmd $(pgrep -f tasks-0.0.1) GC.heap_info
 garbage-first heap   total 129024K, used 54012K   ← live heap, right now
$ jcmd $(pgrep -f tasks-0.0.1) Thread.print | grep -c '^"'
41                                            ← every thread, with full stacks
$ jcmd $(pgrep -f tasks-0.0.1) JFR.start duration=60s filename=rec.jfr
Started recording 1.                          ← JFR can also start after the fact
Part nine

Spring in production

L3 · Advanced

Everything left between the working service and a deployable one: errors that clients can parse, outbound calls that cannot hang, tests that prove the whole stack against a real database, and an image that a cluster can drain and kill without dropping requests. Each topic mirrors something you have already met on the Go or cloud pages — here is the Java spelling.

Java · 19

One error shape: @RestControllerAdvice and ProblemDetail

Controllers never catch anything. The service throws domain exceptions; one advice class catches everything that escapes and translates it into RFC 9457 application/problem+json — the same three-case split every API ends up needing.

ESCAPES THE CONTROLLER ONE ADVICE CLASS WHAT THE CLIENT SEES TaskNotFoundException thrown by the service MethodArgumentNotValid @Valid failed before the service everything else SQL down, NPE, the bug you shipped @RestControllerAdvice one class, whole API notFound() → 404 invalid() → 400 + fields internal() → 500 log.error(full trace) 404 problem+json detail names the missing id 400 problem+json errors: [{field, message}, ...] 500 problem+json title only — no trace, no classes The asymmetry in the third lane is the design: the log gets the whole stack trace, the response gets a status and a title. Never both ways round.
Three lanes cover every exception the API will ever throw. Domain exceptions map to specific statuses, validation failures explain themselves field by field, and everything unforeseen becomes an opaque 500 — because a stack trace in a response body is an information leak wearing a helpful face.

Recall from part six that CreateTaskRequest carries @NotBlank String title and the controller takes @Valid @RequestBody CreateTaskRequest req — that is what raises the validation exception in lane two. The domain exception in lane one is TaskNotFoundException, the two-line class you wrote in part seven. The whole advice class, complete:

// src/main/java/dev/ladder/tasks/ApiExceptionHandler.java
package dev.ladder.tasks;

import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
class ApiExceptionHandler {

  private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);

  @ExceptionHandler(TaskNotFoundException.class)
  ProblemDetail notFound(TaskNotFoundException ex) {
    var p = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    p.setTitle("Task not found");
    return p;                          // Spring serialises it as problem+json
  }

  @ExceptionHandler(MethodArgumentNotValidException.class)
  ProblemDetail invalid(MethodArgumentNotValidException ex) {
    var p = ProblemDetail.forStatusAndDetail(
        HttpStatus.BAD_REQUEST, "Request body failed validation");
    p.setTitle("Validation failed");
    p.setProperty("errors", ex.getFieldErrors().stream()
        .map(f -> Map.of("field", f.getField(),
                         "message", String.valueOf(f.getDefaultMessage())))
        .toList());                    // extra members are allowed by RFC 9457
    return p;
  }

  @ExceptionHandler(Exception.class)   // the safety net -- keep it LAST in your head
  ProblemDetail internal(Exception ex) {
    log.error("unhandled exception", ex);          // full trace -> the log
    var p = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
    p.setTitle("Internal error");                  // nothing else -> the client
    return p;
  }
}

Run it

Ask for a task that does not exist, then post a blank title. Two different problems, one shape — a client can parse every error this API will ever return with one struct.

$ curl -s localhost:8080/tasks/00000000-0000-0000-0000-000000000000
{"type":"about:blank","title":"Task not found","status":404,
 "detail":"No task with id 00000000-0000-0000-0000-000000000000",
 "instance":"/tasks/00000000-0000-0000-0000-000000000000"}
$ curl -s -X POST localhost:8080/tasks -H 'content-type: application/json' -d '{"title":""}'
{"type":"about:blank","title":"Validation failed","status":400,
 "detail":"Request body failed validation",
 "errors":[{"field":"title","message":"must not be blank"}]}
Java · 20

Calling another service: RestClient, built once

The rules are the ones every sibling page drilled: every call has a timeout, 4xx and 5xx are different kinds of failure, and you retry only what is safe to repeat. The Java-specific part is where those rules live — in one @Bean, not sprinkled across call sites.

ONE BEAN CARRIES THE RULES FOR EVERY CALLER TaskService RestClient — the @Bean baseUrl audit.internal:8080 connect ≤ 2s read ≤ 3s status handlers: 4xx / 5xx audit service 2xx body decodes into a record — the only path the caller’s happy code sees 5xx, timeout AuditUnavailable (transient) — retry with backoff, only if the call is idempotent 4xx AuditRejected (terminal) — the request is wrong; retrying it is a loop, not a fix A timeout configured at the call site is a timeout somebody’s next call site forgets. Configured in the bean, forgetting is impossible.
The transient/terminal split is the same one the Go page made with error types. Everything downstream — retries, circuit breaking, alerting — keys off which of the two exceptions crossed the boundary, so the mapping happens once, at the client, not in every caller’s catch block.
// src/main/java/dev/ladder/tasks/HttpClients.java
package dev.ladder.tasks;

import java.time.Duration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder;
import org.springframework.boot.http.client.ClientHttpRequestFactorySettings;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatusCode;
import org.springframework.web.client.RestClient;

@Configuration
class HttpClients {

  @Bean
  RestClient auditClient(RestClient.Builder builder,
                         @Value("${audit.base-url}") String baseUrl) {
    var settings = ClientHttpRequestFactorySettings.defaults()
        .withConnectTimeout(Duration.ofSeconds(2))    // reaching the host
        .withReadTimeout(Duration.ofSeconds(3));      // waiting for bytes

    return builder
        .baseUrl(baseUrl)
        .requestFactory(ClientHttpRequestFactoryBuilder.detect().build(settings))
        .defaultStatusHandler(HttpStatusCode::is5xxServerError, (req, res) -> {
          throw new AuditUnavailable(res.getStatusCode().value());   // transient
        })
        .defaultStatusHandler(HttpStatusCode::is4xxClientError, (req, res) -> {
          throw new AuditRejected(res.getStatusCode().value());      // terminal
        })
        .build();
  }
}

class AuditUnavailable extends RuntimeException {   // their problem: 5xx, timeouts
  AuditUnavailable(int status) { super("audit unavailable: " + status); }
}
class AuditRejected extends RuntimeException {      // our problem: 4xx
  AuditRejected(int status) { super("audit rejected the request: " + status); }
}

Using it is deliberately boring — and note the idempotency thinking baked into the payload: the event carries the task id and type, so if a retry does happen after an ambiguous timeout, the audit service can deduplicate. The same reasoning as idempotency keys on the backend-foundations page, spelled in Java.

@Service
public class AuditService {

  private final RestClient client;

  AuditService(RestClient auditClient) { this.client = auditClient; }

  public void recordDone(Task task) {
    client.post()
        .uri("/events")
        .contentType(MediaType.APPLICATION_JSON)
        .body(new AuditEvent("task.done", task.id(), Instant.now()))
        .retrieve()
        .toBodilessEntity();     // throws AuditUnavailable / AuditRejected on error
  }
}

record AuditEvent(String type, UUID taskId, Instant at) { }

Wire it in with one field and one line — TaskService takes an AuditService through its constructor next to the repository, and markDone reports the event after the save:

// TaskService.java -- markDone, after wiring in the audit client
public Task markDone(UUID id) {
    var e = repo.findById(id).orElseThrow(() -> new TaskNotFoundException(id));
    e.markDone();
    var task = repo.save(e).toTask();
    audit.recordDone(task);     // audit is the constructor-injected AuditService
    return task;
}

The three HTTP clients, one line each

RestClient (Boot 3.2+) is the default choice: fluent, synchronous, plays perfectly with virtual threads. WebClient is the reactive one — you take it when you are already in WebFlux, not for a timeout. RestTemplate is the legacy one: maintained, not evolving, fine to leave in old code and wrong to start new code with.

Run it

No audit service exists, which is exactly the test. Point audit.base-url at the JDK’s built-in static server — it answers POST with 501 — then restart it aimed at a non-routable address. One exercises the 5xx handler, the other the connect timeout; neither hangs. (A dead local port would not do: the kernel refuses instantly, and no timeout ever fires.)

$ jwebserver -p 9090 &
$ ./mvnw spring-boot:run -Dspring-boot.run.arguments=--audit.base-url=http://localhost:9090
$ curl -s -X PATCH localhost:8080/tasks/<id>/done
... AuditUnavailable: audit unavailable: 501    ← the 5xx handler fired
$ kill %1                         # done with the static server; Ctrl-C the app too
$ ./mvnw spring-boot:run -Dspring-boot.run.arguments=--audit.base-url=http://10.255.255.1:9090
$ time curl -s -X PATCH localhost:8080/tasks/<id>/done
real  0m2.1s    ← packets into a black hole: the 2s connect timeout, not an infinite hang
Java · 21

The test ladder: JUnit, a web slice, real Postgres

Three rungs, each loading more and costing more: plain JUnit for logic, @WebMvcTest for the HTTP layer with the service mocked out, and @SpringBootTest with Testcontainers for the full path against a real database. Push every test as far down the ladder as it will go.

THE RUNG WHAT IT LOADS WHAT IT COSTS plain JUnit your class. No Spring, no context, no annotations beyond @Test milliseconds each — write hundreds @WebMvcTest + MockMvc + @MockitoBean the MVC layer: one controller, the advice, validation, JSON — service mocked, no DB, no real server socket about 2 s once for the slice, then milliseconds — write dozens @SpringBootTest + Testcontainers + @ServiceConnection the whole context, Flyway migrations, Hibernate, and a REAL postgres:16 started in Docker for the test run about 15 s to start, then hundreds of ms each — write a handful Every test wants the lowest rung that can catch its bug. Mapping logic never needs a context; a 404-shape check never needs Postgres; and whether the SQL really works needs Postgres, not H2 pretending.
The rung people skip is the top one, and the excuse used to be real. Testing against an in-memory H2 database proves your SQL works on H2; Testcontainers made the honest version — the same postgres:16 image production runs — a two-annotation cost, which retired the excuse.

Two test dependencies join the pom, and then one complete test per rung.

<!-- pom.xml, test scope -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-testcontainers</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>postgresql</artifactId>
  <scope>test</scope>
</dependency>
// rung 1 -- plain JUnit: no Spring anywhere on the classpath of this test’s mind
class TaskEntityTest {

  @Test
  void entityAndRecordRoundTrip() {
    var id = UUID.randomUUID();
    var e = new TaskEntity(id, "buy milk", false, Instant.EPOCH);

    assertEquals(new Task(id, "buy milk", false, Instant.EPOCH), e.toTask());
  }

  @Test
  void markDoneFlipsTheFlagOnly() {
    var e = new TaskEntity(UUID.randomUUID(), "t", false, Instant.EPOCH);
    e.markDone();
    assertTrue(e.done());
  }
}
// rung 2 -- the web slice: controller + advice + validation, service mocked
@WebMvcTest(TaskController.class)
class TaskControllerTest {

  @Autowired MockMvc mvc;
  @MockitoBean TaskService service;      // replaces the real bean in the slice

  @Test
  void missingTaskBecomesA404Problem() throws Exception {
    var id = UUID.randomUUID();
    when(service.get(id)).thenThrow(new TaskNotFoundException(id));

    mvc.perform(get("/tasks/{id}", id))
       .andExpect(status().isNotFound())
       .andExpect(jsonPath("$.title").value("Task not found"));
  }

  @Test
  void blankTitleNeverReachesTheService() throws Exception {
    mvc.perform(post("/tasks")
           .contentType(MediaType.APPLICATION_JSON)
           .content("{\"title\":\"\"}"))
       .andExpect(status().isBadRequest())
       .andExpect(jsonPath("$.errors[0].field").value("title"));

    verifyNoInteractions(service);       // validation rejected it at the door
  }
}
// rung 3 -- the full path, against the same postgres:16 production runs
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class TasksEndToEndTest {

  @Container
  @ServiceConnection                     // injects url/user/password automatically
  static PostgreSQLContainer<?> db = new PostgreSQLContainer<>("postgres:16");

  @Autowired TestRestTemplate http;

  @Test
  void createThenFetchAgainstRealPostgres() {
    var created = http.postForObject("/tasks",
        Map.of("title", "prove the whole stack"), Task.class);

    var fetched = http.getForObject("/tasks/" + created.id(), Task.class);

    assertEquals(created, fetched);      // through Flyway, Hibernate, and back
  }
}

@ServiceConnection is the piece worth pausing on: it replaces the datasource properties with the container’s coordinates before the context starts, so Flyway runs your real migrations against the throwaway database. The test proves V1, the entity mapping, the derived queries and the JSON boundary in one pass — which is why a handful of these carries so much weight.

Run it

With Docker running, verify executes all three rungs. The first Testcontainers run pulls the image; after that the whole suite is tens of seconds.

$ ./mvnw verify
[INFO] Tests run: 2 ... TaskEntityTest        ← milliseconds
[INFO] Tests run: 2 ... TaskControllerTest    ← the web slice
[INFO] Tests run: 1 ... TasksEndToEndTest     ← real Postgres, via Docker
[INFO] BUILD SUCCESS
Java · 22

Packaging, probes, and dying politely

The fat jar is self-contained but a Docker image built from it re-ships 40 MB of unchanged dependencies on every one-line fix. Boot’s layered extraction fixes the caching; graceful shutdown and the actuator probes fix everything the cloud page said about pods being killed mid-request.

KUBERNETES THE JVM — server.shutdown=graceful pod Terminating SIGTERM sent to PID 1 endpoint removed from Service no new traffic routed here still alive at 30 s? SIGKILL — no cleanup SIGTERM caught by Boot readiness → refusing new requests: connection reset in-flight requests drain up to timeout-per-shutdown: 20s context closes, pools shut down — exit 0 t = 0 t = 20 s t = 30 s (grace period) The same dance the Go page did by hand with srv.Shutdown(ctx): stop taking work, finish the work you have, then exit — all before the 30-second deadline turns into SIGKILL. Boot does the whole sequence from one property.
Without server.shutdown=graceful the default is immediate: SIGTERM stops the connector and in-flight requests die mid-body on every single deploy — a low, steady error rate that correlates perfectly with your release calendar and gets blamed on the network for months.

First the production settings, then the image. The probes property gives the health endpoint two sub-paths that mean exactly what the cloud page’s liveness and readiness probes need them to mean — and Boot flips readiness during startup and shutdown automatically.

# application.yml -- the production additions
server:
  shutdown: graceful              # drain in-flight requests on SIGTERM
spring:
  lifecycle:
    timeout-per-shutdown-phase: 20s   # stay under the pod’s 30s grace period
management:
  endpoint:
    health:
      probes:
        enabled: true             # exposes /liveness and /readiness
  endpoints:
    web:
      exposure:
        include: health           # and nothing else, on purpose

# and in the Deployment, from the cloud page:
#   livenessProbe:  httpGet /actuator/health/liveness  :8080
#   readinessProbe: httpGet /actuator/health/readiness :8080

(That block assumes spring-boot-starter-actuator is in the pom — one dependency, no version.) Now the Dockerfile. ./mvnw package produces one fat jar, and the trick is to not copy it into the image as one blob: jarmode=tools extracts it into layers so the 40 MB of dependencies — which change rarely — sit in a different image layer from your classes, which change every commit. Rebuilds and pulls then move kilobytes, not the world.

# Dockerfile
FROM eclipse-temurin:21-jre AS extract
WORKDIR /app
COPY target/tasks-0.0.1-SNAPSHOT.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted

FROM eclipse-temurin:21-jre
WORKDIR /app
RUN useradd --system app
USER app                                  # never root; nothing here needs it

# rarely-changing layers first: cache hits on every rebuild
COPY --from=extract /app/extracted/dependencies/          ./
COPY --from=extract /app/extracted/spring-boot-loader/    ./
COPY --from=extract /app/extracted/snapshot-dependencies/ ./
COPY --from=extract /app/extracted/application/           ./

# the memory lesson from the JVM part, applied
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

ExitOnOutOfMemoryError deserves its one sentence: a JVM that has thrown OutOfMemoryError on some thread is a zombie that still answers liveness probes, and it is better to die and be restarted by the kubelet than to limp.

Run it

Build, run against the compose network, and curl the probes. Then touch a source file, rebuild, and read the build output: every layer above application/ reports CACHED.

$ ./mvnw -q package -DskipTests
$ docker build -t tasks:1 .
$ docker run --rm --network host --memory 512m tasks:1
$ curl -s localhost:8080/actuator/health/readiness
{"status":"UP"}                              ← K8s would route traffic now
$ curl -s localhost:8080/actuator/health/liveness
{"status":"UP"}
# change one line of TaskService, package, rebuild:
$ docker build -t tasks:2 .
=> CACHED [stage-1 4/8] COPY --from=extract /app/extracted/dependencies/ ./
=> [stage-1 7/8] COPY --from=extract /app/extracted/application/ ./   ← only this moved
Part ten

The review lens

L3 · Advanced

Everything before this was building. This part is what an experienced Java reviewer scans for in a diff — the idioms that signal care, the traps that recur in every codebase, and the tools that check half of it so humans can argue about the other half.

Java · 23

What “idiomatic” means in a Java review

Idiom in Java is mostly about narrowing what code is allowed to do: make it immutable, make it final, make the type say it. Each rewrite below deletes a state the wrong version could reach — the same argument the Python and Go pages made, wearing a suit.

WHAT GETS FLAGGED WHAT IS WANTED @Autowired private TaskService svc; public List<Task> tasks; // mutable, shared catch (Exception e) { } if (opt.isPresent()) { opt.get()… } return taskEntity; // JPA entity to JSON constructor injection immutability by default catch what you handle Optional has methods DTO at the boundary private final TaskService svc; // via ctor private final List<Task> tasks = List.of(…); catch (TaskStoreException e) { …log, map… } opt.map(Task::title).orElseThrow(…) return TaskResponse.from(entity); None of these are style. Each right-hand version has fewer reachable states: nothing can reassign the field, mutate the list, swallow the error, or leak the column.
Idiom as state reduction. The compiler is Java’s best reviewer — final, sealed types, records and checked visibility all move bugs from run time to compile time, which is the whole personality of the language.
Review flagWhy it bitesThe idiomatic form
Field injection (@Autowired on a field)Untestable without the container; hides the dependency countOne constructor, final fields — Spring wires it with no annotation
equals without hashCodeThe object silently misbehaves as a map key — part two’s buckets diagramGenerate both together, or use a record and get them for free
Returning null for “not found”The caller’s NPE fires far from the causeOptional<T> as the return type; orElseThrow at the edge
String concatenation in a loopQuadratic — each + copies the whole accumulated stringStringBuilder, or a stream with Collectors.joining
Mutable static stateA hidden global shared by every thread and every testInstance state owned by a bean; constants static final and immutable
Catching Exception broadly mid-stackSwallows bugs and cancellation alikeCatch the specific type you can handle; let the advice map the rest
Raw types (List tasks)Silently disables the type checker for that variableAlways parameterise: List<Task>; the diamond does the rest
Business logic in the controllerUntestable without HTTP; unusable from a queue consumerControllers translate; services decide — the same split as every sibling page
@Transactional on a private or self-called methodThe proxy never sees the call — part seven’s diagramPublic method, called from another bean
New ObjectMapper/RestClient per requestThrows away pooling and warmed cachesOne bean, injected — the same rule as Go’s http.Client

The toolchain that argues for you

Spotless with google-java-format ends formatting debate the way black did for Python. Error Prone runs inside javac and catches real bug patterns (a missing @Override, a self-assignment, an ignored return). SpotBugs reads the bytecode for null-flow and resource leaks. JaCoCo measures coverage in mvn verify. Wire all four into the build and a class of review comments disappears.

Idioms that have quietly expired

new Date() → java.time.Instant. Getters/setters on a data holder → a record. synchronized collections → ConcurrentHashMap or immutability. XML config and web.xml → annotations and application.yml. RestTemplate → RestClient. Application servers → the embedded server in the jar. If a tutorial shows any of the left column, check its date.

Run it

Add Spotless to the tasks project’s pom.xml plugins block, then let it rewrite your formatting and fail the build when someone skips it.

$ ./mvnw spotless:check
[ERROR] The following files had format violations: TaskService.java   ← caught
$ ./mvnw spotless:apply && ./mvnw spotless:check
[INFO] Spotless check succeeded.
<!-- pom.xml, inside <build><plugins> -->
<plugin>
  <groupId>com.diffplug.spotless</groupId>
  <artifactId>spotless-maven-plugin</artifactId>
  <version>2.44.0</version>
  <configuration>
    <java><googleJavaFormat/></java>
  </configuration>
  <executions>
    <execution><goals><goal>check</goal></goals></execution>  <!-- fails mvn verify -->
  </executions>
</plugin>
Java · 24

The checklist, and where to go next

Twelve things worth a second look in any Java diff, the sixty seconds of commands before a pull request, and the map of what this page deliberately left for your second month.

CORRECTNESS SPRING WIRING RESOURCES & BOUNDARIES equals and hashCode together, or a record == only for primitives; .equals for objects no raw types; generics everywhere BigDecimal or long cents for money constructor injection, final fields @Transactional public, no self-invocation entities stop at the service; DTOs cross config bound and validated at start-up try-with-resources on every Closeable every client call has a timeout executors bounded, named, shut down no secrets in yml; env or vault only About half of this list is Spotless, Error Prone and SpotBugs’ job. Put them in mvn verify and spend the review on the half no tool can check: does the transaction boundary match the business operation, does the error contract tell the caller what to do, and would this class survive two concurrent requests. The three questions, one per rung: is it correct alone · is it wired so the container helps you · does it fail well in company
Read it as three questions, not twelve rules. Correctness is the language’s half, wiring is Spring’s half, and the resource column is the same discipline every page in this series ends on: timeouts, bounds, and owned lifecycles.

Run it

The sixty seconds before you open the pull request — from the tasks project root, with Docker running for the Testcontainers rung.

$ ./mvnw spotless:apply                  # format — never argue about it again
$ ./mvnw verify                          # compiles, unit + slice + container tests, coverage
$ ./mvnw versions:display-dependency-updates | grep -A2 spring-boot
# then read your own diff — these catch what the tools do not:
$ git diff --cached | grep -nE '@Autowired|System\.out|printStackTrace'
$ git diff --cached | grep -nE 'catch \(Exception|\.get\(\)|new Thread\('
$ git diff --cached | grep -nE 'password|secret|apikey' # nothing hard-coded, ever

What this page deliberately left out

Spring Security (authentication filters, JWT resource servers) deserves its own page rather than a cameo. So do messaging (Kafka via spring-kafka, the listener/acknowledgement model), caching (@Cacheable and its invalidation problem — the foundations page’s caching section applies verbatim), GraalVM native images, and the module system. Learn them in that order; each builds on parts you now have.

Where to go next

Effective Java (Bloch) is the review lens of this part, expanded to ninety items — read it after a month of writing, not before. Java Concurrency in Practice is part eight’s missing depth; still correct, virtual threads notwithstanding. For Spring, the reference documentation is genuinely good — read the chapter for each starter you add, and read the auto-configuration classes when behaviour surprises you: they are short, and they are the truth.

Part eleven

A whole service, file by file

Everything before this was a mechanism, a snippet, or one slice of Spring. This is the repository — one small service, built in the order you would actually build it, with every file that has to exist before anyone will let you deploy it. The domain is a link shortener, chosen because it is small enough to hold in your head and still needs every production concern there is: validation, a database and a migration, idempotency under a race, authentication, rate limiting, structured logs, two different health checks, tests that run in CI, and an image that starts as a non-root user. It is the same service the Python page builds, in the same order, so you can read the two side by side and see what the language actually changes.

Java · 25

Where the files go, and why

Layout is the first architectural decision and the one nobody revisits. Maven fixes half of it for you — src/main/java, src/test/java, src/main/resources, and no arguments — which leaves the half that matters: what the packages are, and which way an import is allowed to point.

shortlink/
  pom.xml                 the one file in the repository that names a version
  mvnw                    the Maven wrapper. Committed, so CI needs no Maven.
  .mvn/
    wrapper/              the wrapper's properties: which Maven, from where
  .env.example            every variable the app reads, with safe placeholders
  compose.yaml            Postgres and Redis for local work, nothing else
  Dockerfile              multi-stage; the runtime stage has no compiler in it
  Makefile                the four commands everyone needs, named the same way

  src/
    main/
      java/
        info/jaspreet/shortlink/
          ShortlinkApplication.java   the entry point. Three lines, no logic.
          config/
            ShortlinkProperties.java  typed settings, validated at boot
            SecurityConfig.java       the filter chain, and what is public
          domain/
            ShortCode.java            a value object that cannot be malformed
            TargetUrl.java            the parse that keeps javascript: out
            Link.java                 the thing itself. No annotations at all.
            CodeGenerator.java        SecureRandom over a confusable-free set
          persistence/
            LinkStore.java            the four methods the service actually uses
            LinkEntity.java           the table. A class, because JPA needs one.
            LinkRecordRepository.java Spring Data, and two deletes in JPQL
            LinkWriter.java           the insert, in its own transaction
            ApiKeyStore.java          one lookup, by hash, and nothing else
          service/
            LinkService.java          the use cases. Knows no HTTP.
          web/
            LinkController.java       /v1/links: create, read, delete
            RedirectController.java   GET /{code}: the only public route
            LinkDtos.java             the wire contract, with the constraints
            ApiExceptionHandler.java  one advice, one problem+json shape
            RequestIdFilter.java      the id every log line carries
          error/
            ShortlinkException.java   a sealed hierarchy the compiler checks
          security/
            ApiKey.java               who is calling: the principal, a record
            ApiKeyFilter.java         hash, look up, compare in constant time
            RateLimiter.java          Redis, because a HashMap is not one
      resources/
        application.yaml              defaults and the shape of every setting
        application-local.yaml        the profile compose.yaml matches
        db/migration/
          V1__create_links.sql        Flyway. One file per schema change.
    test/
      java/
        info/jaspreet/shortlink/
          ArchitectureTest.java       the layering rule, as a failing build
          domain/ShortCodeTest.java   pure, fast, no Spring context at all
          IntegrationTest.java        the base class that owns the containers
          web/LinkApiTest.java        through the real app, real Postgres
          web/ErrorContractTest.java  the failure shape, which is also an API

  .github/workflows/ci.yaml

Two things in that tree are opinions rather than conventions, and both are worth defending in a review. The packages are named for layers, not for features, because this service has exactly one feature; the moment it has four, the top level becomes links/, owners/, billing/ and each of those gets this same inside. And the domain package sits at the bottom with nothing under it, because the direction of every import in the tree points inward toward it.

PackageMay importMust never import
domainThe JDK, and nothing elseSpring, Jakarta, Hibernate. It is the part you can test without a context.
errordomainAnything with Http in the name. An exception that knows its status code cannot be reused by a worker.
persistencedomain, Spring Data, Hibernateweb. A repository that throws ResponseStatusException is a repository you can only call from a controller.
servicepersistence, domain, errorweb, and HttpServletRequest above all.
webEverythingNothing — but it should hold no branching that is not about HTTP.

That table is the whole architecture, and unlike a diagram it can fail a pull request: ArchitectureTest in the tests topic is those five rows, written once, checked on every build. Now the two files that make the tree buildable.

<!-- pom.xml -- the only place a version number appears. -->
<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>

  <!-- The parent supplies a curated version for every Spring dependency
       and for a hundred others. Its version is the single upgrade lever
       for this repository: bump it deliberately, on purpose, and read the
       release notes. Nothing below states a version of its own. -->
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.5.5</version>
    <relativePath/>
  </parent>

  <groupId>info.jaspreet</groupId>
  <artifactId>shortlink</artifactId>
  <version>0.1.0</version>

  <properties>
    <java.version>21</java.version>
    <archunit.version>1.3.0</archunit.version>
  </properties>

  <dependencies>
    <!-- The web starter brings Tomcat, Jackson and the MVC stack. -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!-- Validation is a separate starter since Boot 2.3. Without it,
         @Valid on a controller argument silently does nothing. -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    <dependency>
      <groupId>org.flywaydb</groupId>
      <artifactId>flyway-database-postgresql</artifactId>
    </dependency>
    <dependency>
      <groupId>org.postgresql</groupId>
      <artifactId>postgresql</artifactId>
      <scope>runtime</scope>
    </dependency>
    <dependency>
      <groupId>io.micrometer</groupId>
      <artifactId>micrometer-registry-prometheus</artifactId>
      <scope>runtime</scope>
    </dependency>

    <!-- Test scope. None of this is in the shipped jar. -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.springframework.security</groupId>
      <artifactId>spring-security-test</artifactId>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-testcontainers</artifactId>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.testcontainers</groupId>
      <artifactId>postgresql</artifactId>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>com.tngtech.archunit</groupId>
      <artifactId>archunit-junit5</artifactId>
      <version>${archunit.version}</version>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
      <!-- Surefire runs *Test; failsafe runs *IT in the integration-test
           phase, after the app is packaged. Keeping them apart is what
           lets `mvn test` stay fast enough to run on every save. -->
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-failsafe-plugin</artifactId>
        <executions>
          <execution>
            <goals><goal>integration-test</goal><goal>verify</goal></goals>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>
</project>

A version range is not a dependency, it is a promise you cannot keep

Nothing in that pom except the parent and ArchUnit states a version, and neither states a range. Maven resolves ranges at build time, which means the artifact your CI produces on Tuesday is not the one it produced on Monday, and the difference is invisible in the diff. Pin exactly, upgrade in a commit that says so, and let a bot open that commit for you. The parent doing the pinning for forty transitive libraries is the reason Spring Boot is worth using at all — it is a curated dependency set with a starter API on top, not just a framework.

# Makefile -- four verbs, the same four in every repository you own.
# CI runs these exact targets, so "works on my machine" has one less place to hide.
.PHONY: up check test run image

up:                     ## Postgres and Redis, nothing else
	docker compose up -d

check:                  ## compile, and fail on anything a human would comment on
	./mvnw -B -ntp verify -DskipTests

test:                   ## unit tests, then the ones that start containers
	./mvnw -B -ntp verify

run: up                 ## the local profile, against the compose services
	SPRING_PROFILES_ACTIVE=local ./mvnw -B -ntp spring-boot:run

image:                  ## exactly what CI builds, byte for byte
	docker build -t shortlink:$(shell git rev-parse --short HEAD) .
Java · 26

Configuration, typed and validated at boot

A service reads its environment exactly once, on the way up, into objects whose types are checked. The failure mode you are designing against is the one where a mistyped variable is discovered on the tenth request in production instead of in the first second of the deploy — and Spring will do that for you, but only if you stop reading settings with @Value.

// src/main/java/info/jaspreet/shortlink/config/ShortlinkProperties.java
package info.jaspreet.shortlink.config;

import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import java.net.URI;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;

/**
 * Every setting this service has, in one type. A record gets constructor
 * binding for free, which means the object is immutable and complete or it
 * does not exist -- there is no half-configured instance for a bean to hold.
 *
 * @Validated is what turns a bad value into a failed startup rather than a
 * NullPointerException at 3am. Boot fails the context, prints the property
 * name and the constraint that rejected it, and exits non-zero, so the
 * orchestrator never routes traffic here at all.
 */
@Validated
@ConfigurationProperties(prefix = "shortlink")
public record ShortlinkProperties(

    /** Where the short links point. Used to build the Location header. */
    @NotNull URI publicBaseUrl,

    /** Characters in a generated code. Seven is ~34 billion codes. */
    @Min(4) @Max(32) int codeLength,

    /** How long a link lives when the caller does not say. */
    @NotNull Duration defaultTtl,

    @Valid @NotNull RateLimit rateLimit) {

  public record RateLimit(@Min(1) int perMinute, @NotNull Duration window) {}
}

// Relaxed binding means public-base-url, PUBLIC_BASE_URL and publicBaseUrl
// all reach publicBaseUrl. Write kebab-case in YAML and SCREAMING_SNAKE in
// the environment; both are conventional and both arrive at the same field.
//
// Nothing enables this by hand: @ConfigurationPropertiesScan on the
// application class finds every @ConfigurationProperties in the package tree.
// Inject the record where you need it. Never inject Environment.
# application.yaml -- the defaults, and the shape of every setting.
# Anything genuinely secret has no default at all: the ${...} placeholder
# with no fallback is deliberate, so an unset variable fails the context
# instead of quietly connecting to something that happens to be listening.
spring:
  application:
    name: shortlink
  threads:
    virtual:
      enabled: true             # Boot 3.2+: one virtual thread per request
  datasource:
    url: ${DATABASE_URL}
    username: ${DATABASE_USER}
    password: ${DATABASE_PASSWORD}
    hikari:
      maximum-pool-size: 10     # the real concurrency limit. See the note.
      minimum-idle: 10          # same as max: a fixed pool has no warm-up
      connection-timeout: 2000  # milliseconds. HikariConfig takes longs,
      validation-timeout: 1000  # not Durations, so "2s" will not bind here.
      max-lifetime: 1500000     # 25 min, under any proxy's idle timeout
  jpa:
    open-in-view: false         # see the warning. Default is true. It is wrong.
    hibernate:
      ddl-auto: validate        # Flyway owns the schema. Hibernate checks it.
    properties:
      hibernate:
        jdbc:
          batch_size: 50
  flyway:
    enabled: true
    validate-on-migrate: true
  data:
    redis:
      url: ${REDIS_URL}
      timeout: 250ms            # a slow limiter must not become a slow API
  mvc:
    problemdetails:
      enabled: true             # Spring's own 4xx become application/problem+json

server:
  shutdown: graceful
  forward-headers-strategy: framework   # honour X-Forwarded-* behind the LB

shortlink:
  public-base-url: ${SHORTLINK_PUBLIC_BASE_URL}
  code-length: 7
  default-ttl: 90d
  rate-limit:
    per-minute: 60
    window: 1m

management:
  endpoints:
    web:
      exposure:
        include: health,prometheus     # and nothing else, on purpose
  endpoint:
    health:
      probes:
        enabled: true
      show-details: never              # a health page is not a debug console

logging:
  structured:
    format:
      console: ecs                     # Boot 3.4+: JSON logs, no encoder XML

Virtual threads did not raise your concurrency limit; the pool did not move

Turning on spring.threads.virtual.enabled means Tomcat stops handing requests to a fixed pool of 200 platform threads, so ten thousand concurrent requests no longer queue at the connector. What it emphatically does not do is let ten thousand of them talk to Postgres: maximum-pool-size: 10 is still ten, and requests 11 and up block for a connection with a two-second timeout. That is the correct design — a database has a real concurrency ceiling and a pool is how you respect it — but it moves the bottleneck rather than removing it, and it moves it somewhere you have to size on purpose. Load-test the pool, not the thread model.

open-in-view is on by default and it should not be

Boot leaves the Hibernate session open for the whole request so that a lazy association touched inside a Jackson serializer still resolves. That is convenient exactly once and expensive forever: it holds a database connection for the duration of view rendering, it turns an N+1 into something that works rather than something that throws, and it means the transaction boundary you carefully drew in the service is not where the database work actually stops. Set it to false on day one, before any code depends on it. You will get a LazyInitializationException the first time it matters, and that exception is the design telling you to fetch what you need inside the transaction.

# .env.example -- committed. The real .env is not, and .gitignore says so.
# The point of this file is that a new engineer can diff it against their
# own .env and see exactly what they are missing.
DATABASE_URL=jdbc:postgresql://localhost:5432/shortlink
DATABASE_USER=shortlink
DATABASE_PASSWORD=local-only-not-a-secret
REDIS_URL=redis://localhost:6379
SHORTLINK_PUBLIC_BASE_URL=http://localhost:8080
# In production none of these come from a file. They come from the
# platform's secret store, injected as environment variables, and the
# application cannot tell the difference -- which is the whole point.
$ SHORTLINK_PUBLIC_BASE_URL= ./mvnw -q spring-boot:run
***************************
APPLICATION FAILED TO START
***************************

Description:

Binding to target info.jaspreet.shortlink.config.ShortlinkProperties failed:

    Property: shortlink.publicBaseUrl
    Value: ""
    Reason: must not be null

Action:

Update your application's configuration
$ echo $?
1
# Twelve seconds after the deploy started, not on the tenth request.
Java · 27

The core, which knows nothing about Spring

This package imports the JDK and nothing else. No annotations, no framework, no database — which is why its tests run in a millisecond and why the rules survive the day someone rewrites the web layer. The technique it leans on is the one the records topic introduced: make illegal states unrepresentable by validating in the constructor, so a ShortCode that exists is a ShortCode that is well formed.

// src/main/java/info/jaspreet/shortlink/domain/ShortCode.java
package info.jaspreet.shortlink.domain;

import java.util.Locale;
import java.util.Objects;
import java.util.regex.Pattern;

/**
 * The code in the short URL. A record over a single String, which sounds
 * like ceremony until you count the methods that take two Strings and can
 * be called with them the wrong way round.
 */
public record ShortCode(String value) {

  private static final Pattern SHAPE = Pattern.compile("[a-z0-9]{4,32}");

  // A compact constructor may reassign the parameter before it is written
  // to the field, so normalising and validating happen in one place and
  // there is no way to construct an instance that skips them.
  public ShortCode {
    Objects.requireNonNull(value, "value");
    value = value.trim().toLowerCase(Locale.ROOT);
    if (!SHAPE.matcher(value).matches()) {
      throw new IllegalArgumentException("not a short code: " + value);
    }
  }

  @Override
  public String toString() {
    return value;
  }
}
// src/main/java/info/jaspreet/shortlink/domain/TargetUrl.java
package info.jaspreet.shortlink.domain;

import java.net.URI;
import java.net.URISyntaxException;
import java.util.Locale;
import java.util.Set;

/** The URL a code points at. Parsing it is a security control, not a format check. */
public record TargetUrl(URI value) {

  private static final Set<String> SCHEMES = Set.of("http", "https");
  private static final int MAX_LENGTH = 2048;

  public static TargetUrl parse(String raw) {
    if (raw == null || raw.isBlank()) {
      throw new IllegalArgumentException("url is required");
    }
    if (raw.length() > MAX_LENGTH) {
      throw new IllegalArgumentException("url longer than " + MAX_LENGTH);
    }
    URI uri;
    try {
      uri = new URI(raw.trim()).normalize();
    } catch (URISyntaxException e) {
      throw new IllegalArgumentException("url is not a valid URI", e);
    }
    // The one that matters: without this, "javascript:alert(1)" is a link
    // you will happily hand a browser a 302 to, from your own domain.
    String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT);
    if (!uri.isAbsolute() || !SCHEMES.contains(scheme)) {
      throw new IllegalArgumentException("only http and https can be shortened");
    }
    if (uri.getHost() == null || uri.getHost().isBlank()) {
      throw new IllegalArgumentException("url has no host");
    }
    // http://user:pass@host is a credential in a database column.
    if (uri.getUserInfo() != null) {
      throw new IllegalArgumentException("credentials in a url are not stored");
    }
    return new TargetUrl(uri);
  }
}
// src/main/java/info/jaspreet/shortlink/domain/Link.java
package info.jaspreet.shortlink.domain;

import java.time.Instant;

/**
 * The thing itself. Note what is absent: no @Entity, no @Id, no getters
 * generated by a library, and no identifier column -- the database's
 * primary key is the database's business, and this record is what the rest
 * of the application passes around.
 *
 * ownerId is a String here for brevity; in a service with more than one
 * kind of id, make it a record too. Two adjacent Strings in a signature is
 * a bug waiting for someone to reorder the arguments.
 */
public record Link(
    ShortCode code,
    TargetUrl target,
    String ownerId,
    Instant createdAt,
    Instant expiresAt) {

  public boolean isExpired(Instant now) {
    return expiresAt != null && !now.isBefore(expiresAt);
  }
}
// src/main/java/info/jaspreet/shortlink/domain/CodeGenerator.java
package info.jaspreet.shortlink.domain;

import java.security.SecureRandom;

/**
 * Codes are random, not sequential. A sequential code leaks how many links
 * exist and lets anyone walk every link you have ever made by counting.
 *
 * The alphabet has 32 characters, not 36: 0/o, 1/l and their friends are
 * removed so a code read aloud or copied off a slide survives the trip.
 */
public final class CodeGenerator {

  private static final char[] ALPHABET = "23456789abcdefghijkmnpqrstuvwxyz".toCharArray();

  private final SecureRandom random = new SecureRandom();
  private final int length;

  public CodeGenerator(int length) {
    this.length = length;
  }

  public ShortCode next() {
    char[] out = new char[length];
    for (int i = 0; i < out.length; i++) {
      out[i] = ALPHABET[random.nextInt(ALPHABET.length)];
    }
    return new ShortCode(new String(out));
  }
}

// 32^7 is about 34 billion codes. With ten million links already stored, a
// fresh code collides roughly once every 3,400 inserts -- rare, but not
// never, and "rare" is the failure mode that reaches production untested.
// The unique index catches it and the repository retries; see the next
// topic. The generator does not have to be perfect, it has to be random.

Validating the host is not the same as blocking a private address

It is tempting to add a check here that refuses 127.0.0.1, 10.x and 169.254.169.254, and for a service that fetches the target — to unfurl a preview, to check liveness — you must, because otherwise your link shortener is a proxy into your own VPC and the cloud metadata endpoint is one request away. But do it at fetch time, on the resolved IP, not here on the hostname: a name that resolves to a public address when the link is created can resolve to a private one when it is followed, and a check written at creation time cannot see that. This service only ever hands the URL back to a browser, so the parse above is the whole control — and the day someone adds a preview fetcher, that is the day this note becomes code.

// src/main/java/info/jaspreet/shortlink/web/LinkDtos.java
package info.jaspreet.shortlink.web;

import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.Instant;

/**
 * The wire contract. Three shapes now exist for one concept -- this DTO,
 * the domain record, and the JPA entity -- and that is the design, not an
 * accident. They change for different reasons: the DTO when the API's
 * consumers need something, the entity when the schema does, the domain
 * record when the rules do. Collapsing them into one class couples all
 * three, and the first person to add a column discovers they have changed
 * the public API.
 */
final class LinkDtos {

  record CreateLinkRequest(
      @NotBlank @Size(max = 2048) String url,
      @Size(max = 64) String alias,          // optional: a vanity code
      Integer ttlDays) {}                    // optional: null means default

  @JsonInclude(JsonInclude.Include.NON_NULL)
  record LinkResponse(
      String code,
      String shortUrl,
      String url,
      Instant createdAt,
      Instant expiresAt) {}

  private LinkDtos() {}
}

// The constraints are on the DTO rather than in the domain because they
// are the API's promise about its inputs -- a 400 with a field name is a
// different thing from an invariant the domain refuses to break. The
// domain checks the same facts again, in the constructor, and always will:
// the service has callers that never came through HTTP.
Java · 28

The schema, the entity, and the first migration

Three lifetimes live in this topic and confusing them is the source of most Spring Data pain: the schema, which Flyway owns and Hibernate is only allowed to check; the entity, which is a mutable class because Hibernate needs one; and the session, which lasts exactly as long as the transaction and not one line longer.

-- V1__create_links.sql -- Flyway runs this once, in order, and records it.
-- Once this file has run anywhere but your laptop it is immutable. A fix
-- is V2, always, because Flyway checksums what it has already applied and
-- will refuse to start against a file that changed underneath it.

CREATE TABLE links (
    id              BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    code            TEXT        NOT NULL,
    target_url      TEXT        NOT NULL,
    owner_id        TEXT        NOT NULL,
    idempotency_key TEXT,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    expires_at      TIMESTAMPTZ,

    -- Named, not auto-generated. The service switches on this name to tell
    -- "that code is taken" apart from "that request already happened", and
    -- a name Postgres invented is a name someone will change by accident.
    CONSTRAINT links_code_key UNIQUE (code)
);

-- Idempotency, as a constraint rather than as a check in application code.
-- Partial, because most links have no key and NULLs are not equal to each
-- other -- without the WHERE clause this index would be pointless on
-- Postgres and wrong on a database where NULLs do compare equal.
CREATE UNIQUE INDEX links_owner_idempotency_key
    ON links (owner_id, idempotency_key)
    WHERE idempotency_key IS NOT NULL;

-- The list endpoint's query, in the order it reads.
CREATE INDEX links_owner_created_at ON links (owner_id, created_at DESC);

CREATE TABLE api_keys (
    id         BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    owner_id   TEXT NOT NULL,
    label      TEXT NOT NULL,
    key_hash   TEXT NOT NULL,   -- sha-256 hex. Never the key itself.
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    revoked_at TIMESTAMPTZ,

    CONSTRAINT api_keys_key_hash_key UNIQUE (key_hash)
);

TIMESTAMPTZ, every time, with no exceptions worth arguing about

Postgres’ TIMESTAMP stores a wall-clock reading with no zone, which means it is only meaningful if every writer and every reader agree about which clock they meant — and they will not, because one of them is a batch job that runs on a machine in a different region and one of them is a laptop in October. TIMESTAMPTZ stores an instant. It maps to java.time.Instant, it survives a daylight-saving boundary, and it makes “when did this happen” a question with one answer. The only cost is that you must remember it does not store the zone the event happened in — if that is business data, it is a second column.

// src/main/java/info/jaspreet/shortlink/persistence/LinkEntity.java
package info.jaspreet.shortlink.persistence;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.Objects;

/**
 * The table, as Hibernate needs to see it: a class with a no-arg
 * constructor and mutable fields. This is the one place in the service
 * where "make it a record" is not the answer -- Hibernate constructs
 * instances reflectively and populates them afterwards, and a record has
 * nothing to populate.
 *
 * It stays package-private to the persistence layer as far as the compiler
 * allows, and nothing outside this package ever sees one: the repository
 * hands back domain records.
 */
@Entity
@Table(name = "links")
public class LinkEntity {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @Column(nullable = false, updatable = false)
  private String code;

  @Column(name = "target_url", nullable = false)
  private String targetUrl;

  @Column(name = "owner_id", nullable = false, updatable = false)
  private String ownerId;

  @Column(name = "idempotency_key", updatable = false)
  private String idempotencyKey;

  @Column(name = "created_at", nullable = false, updatable = false)
  private Instant createdAt;

  @Column(name = "expires_at")
  private Instant expiresAt;

  protected LinkEntity() {
    // for Hibernate, and for nobody else
  }

  LinkEntity(String code, String targetUrl, String ownerId,
             String idempotencyKey, Instant createdAt, Instant expiresAt) {
    this.code = code;
    this.targetUrl = targetUrl;
    this.ownerId = ownerId;
    this.idempotencyKey = idempotencyKey;
    this.createdAt = createdAt;
    this.expiresAt = expiresAt;
  }

  // equals and hashCode over the natural key, not over the id. The id is
  // null until the row is flushed, so an entity added to a HashSet before
  // the flush and looked up after it would be gone -- the hash changed
  // while the object was in the bucket. code is assigned before persist
  // and never updated, so it is stable for the object's whole life, which
  // is exactly what the collections topic said the contract requires.
  @Override
  public boolean equals(Object other) {
    return other instanceof LinkEntity that && Objects.equals(code, that.code);
  }

  @Override
  public int hashCode() {
    return Objects.hashCode(code);
  }

  Long getId() { return id; }
  String getCode() { return code; }
  String getTargetUrl() { return targetUrl; }
  String getOwnerId() { return ownerId; }
  String getIdempotencyKey() { return idempotencyKey; }
  Instant getCreatedAt() { return createdAt; }
  Instant getExpiresAt() { return expiresAt; }

  void setTargetUrl(String targetUrl) { this.targetUrl = targetUrl; }
  void setExpiresAt(Instant expiresAt) { this.expiresAt = expiresAt; }
}

IDENTITY silently turns off insert batching, and the property that claims otherwise stays in your YAML

hibernate.jdbc.batch_size: 50 is in the configuration above and it is doing nothing for inserts into this table. To batch inserts, Hibernate has to hold several rows and send them together; to return the generated id of a row, it has to send that row on its own and read back what the database assigned. GenerationType.IDENTITY asks for the second, so it gets the second. For a service that writes one row per request — this one — that is free, and IDENTITY is the simpler schema. For anything that imports in bulk, switch the id to a SEQUENCE with an allocation size, let Hibernate pre-allocate a block of ids, and the batching starts working. Knowing why your batch setting is inert is the difference between tuning and guessing.

Java · 29

The repository, and the race you cannot check for

Two requests arrive at the same millisecond with the same Idempotency-Key. Both look for an existing link, both find nothing, both insert. Reading before writing does not fix that and no amount of application code can, because the gap between the read and the write is where the other request lives. The database closes the gap; the interesting part in Java is that catching the violation is not allowed to happen where you would naturally write it.

// src/main/java/info/jaspreet/shortlink/persistence/LinkRecordRepository.java
package info.jaspreet.shortlink.persistence;

import java.time.Instant;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Limit;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

interface LinkRecordRepository extends JpaRepository<LinkEntity, Long> {

  // Derived queries. The method name is the query, which means renaming
  // the method is a schema-level change and not a refactor -- one reason
  // to keep this interface small and its names boring.
  Optional<LinkEntity> findByCode(String code);

  Optional<LinkEntity> findByOwnerIdAndIdempotencyKey(String ownerId, String idempotencyKey);

  List<LinkEntity> findByOwnerIdOrderByCreatedAtDesc(String ownerId, Limit limit);

  // Written out, because the derived form of "delete only if it is yours"
  // reads worse than the JPQL and this one has to be exactly right.
  @Modifying
  @Query("delete from LinkEntity l where l.code = :code and l.ownerId = :ownerId")
  int deleteOwnedByCode(@Param("code") String code, @Param("ownerId") String ownerId);

  @Modifying
  @Query("delete from LinkEntity l where l.expiresAt < :cutoff")
  int deleteExpiredBefore(@Param("cutoff") Instant cutoff);
}
// src/main/java/info/jaspreet/shortlink/persistence/LinkWriter.java
package info.jaspreet.shortlink.persistence;

import info.jaspreet.shortlink.error.ShortlinkException.CodeTaken;
import info.jaspreet.shortlink.error.ShortlinkException.IdempotencyClash;
import java.time.Instant;
import org.hibernate.exception.ConstraintViolationException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

/**
 * The insert, alone, in a transaction of its own -- and this is the whole
 * point of the class existing rather than being a method on the service.
 *
 * A constraint violation marks the current transaction rollback-only. You
 * cannot catch it and carry on inside the same transaction: the next
 * statement fails with "Transaction silently rolled back" and the real
 * cause is three frames further back. So the attempt that might fail gets
 * its own boundary, and the caller's catch block sits outside it, where
 * there is nothing broken to continue.
 *
 * REQUIRES_NEW rather than the default, so this stays true on the day
 * somebody annotates the service. And a separate bean rather than a
 * private method, because @Transactional is a proxy and a call to this.x()
 * never touches it.
 */
@Component
public class LinkWriter {

  private static final String CODE_UNIQUE = "links_code_key";
  private static final String IDEMPOTENCY_UNIQUE = "links_owner_idempotency_key";

  private final LinkRecordRepository rows;

  LinkWriter(LinkRecordRepository rows) {
    this.rows = rows;
  }

  @Transactional(propagation = Propagation.REQUIRES_NEW)
  public LinkEntity insert(String code, String targetUrl, String ownerId,
                           String idempotencyKey, Instant createdAt, Instant expiresAt) {
    var row = new LinkEntity(code, targetUrl, ownerId, idempotencyKey, createdAt, expiresAt);
    try {
      // saveAndFlush, not save. save() only queues the insert; without the
      // flush the violation surfaces when the transaction commits, which
      // is after this method returns and outside this try block.
      return rows.saveAndFlush(row);
    } catch (DataIntegrityViolationException e) {
      // Translate infrastructure into domain here, so that nothing above
      // this package has to know Hibernate exists. Java 21's switch over
      // a String with a null label makes the three cases explicit.
      throw switch (constraintOf(e)) {
        case CODE_UNIQUE -> new CodeTaken(code);
        case IDEMPOTENCY_UNIQUE -> new IdempotencyClash(idempotencyKey);
        case null, default -> e;   // some other constraint: not ours to explain
      };
    }
  }

  private static String constraintOf(DataIntegrityViolationException e) {
    return e.getCause() instanceof ConstraintViolationException violation
        ? violation.getConstraintName()
        : null;
  }
}

Why not just check first?

Because findByOwnerIdAndIdempotencyKey followed by insert is two statements with a gap, and under READ COMMITTED — Postgres’ default and almost certainly yours — another transaction can commit inside that gap. The check is still worth doing as the fast path, because the overwhelmingly common case is a retry seconds later and a read is cheaper than a failed insert; it just cannot be the guarantee. The guarantee is the index. Everything else is an optimisation, and describing it that way in an interview is most of the answer.

// src/main/java/info/jaspreet/shortlink/service/LinkService.java
package info.jaspreet.shortlink.service;

import info.jaspreet.shortlink.domain.CodeGenerator;
import info.jaspreet.shortlink.domain.Link;
import info.jaspreet.shortlink.domain.ShortCode;
import info.jaspreet.shortlink.domain.TargetUrl;
import info.jaspreet.shortlink.error.ShortlinkException.AliasTaken;
import info.jaspreet.shortlink.error.ShortlinkException.CodeTaken;
import info.jaspreet.shortlink.error.ShortlinkException.IdempotencyClash;
import info.jaspreet.shortlink.error.ShortlinkException.LinkExpired;
import info.jaspreet.shortlink.error.ShortlinkException.LinkNotFound;
import info.jaspreet.shortlink.error.ShortlinkException.NoCodeAvailable;
import info.jaspreet.shortlink.persistence.LinkStore;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.List;

/**
 * The use cases, and the only place the business rules are sequenced.
 * No HttpServletRequest, no ResponseEntity, no status codes -- everything
 * here would work unchanged from a message consumer or a CLI, which is the
 * test for whether a service layer is worth having at all.
 *
 * There is no @Service on it either, and no Spring import in this file at
 * all. It is a plain object with four constructor arguments; the
 * configuration class decides what those are. That costs four lines there
 * and buys a rule ArchUnit can state without exceptions -- the service
 * package does not depend on the framework -- plus a constructor a test
 * can call with a fake clock and no context at all.
 */
public class LinkService {

  private static final int MAX_CODE_ATTEMPTS = 5;

  private final LinkStore links;
  private final CodeGenerator codes;
  private final Clock clock;
  private final Duration defaultTtl;

  public LinkService(LinkStore links, CodeGenerator codes, Clock clock, Duration defaultTtl) {
    this.links = links;
    this.codes = codes;
    this.clock = clock;          // injected, so a test can freeze time
    this.defaultTtl = defaultTtl;
  }

  /** Everything one create needs, so the signature cannot be called wrong. */
  public record NewLink(String ownerId, TargetUrl target,
                        ShortCode alias, Duration ttl, String idempotencyKey) {}

  public Link shorten(NewLink request) {
    // The fast path: a client retrying after a timeout, seconds later.
    if (request.idempotencyKey() != null) {
      var existing = links.findByIdempotencyKey(request.ownerId(), request.idempotencyKey());
      if (existing.isPresent()) {
        return existing.get();
      }
    }

    Instant now = clock.instant();
    Instant expiresAt = now.plus(request.ttl() == null ? defaultTtl : request.ttl());

    for (int attempt = 1; attempt <= MAX_CODE_ATTEMPTS; attempt++) {
      ShortCode code = request.alias() != null ? request.alias() : codes.next();
      try {
        return links.insert(code, request, now, expiresAt);
      } catch (IdempotencyClash clash) {
        // The slow path: a genuine race. The other request won, and its
        // link is the correct answer to both of them.
        return links.findByIdempotencyKey(request.ownerId(), request.idempotencyKey())
            .orElseThrow(() -> new IllegalStateException("clash with no winner", clash));
      } catch (CodeTaken taken) {
        // A caller's chosen alias is a 409 they can act on. A generated
        // code that collided is our problem, and the fix is another code.
        if (request.alias() != null) {
          throw new AliasTaken(code);
        }
      }
    }
    throw new NoCodeAvailable(MAX_CODE_ATTEMPTS);
  }

  public Link resolve(ShortCode code) {
    Link link = links.findByCode(code).orElseThrow(() -> new LinkNotFound(code));
    if (link.isExpired(clock.instant())) {
      // Deliberately not 404: a client that gets "gone" can stop retrying,
      // and an operator reading the logs can tell the two apart.
      throw new LinkExpired(code);
    }
    return link;
  }

  public Link readOwned(String ownerId, ShortCode code) {
    return links.findByCode(code)
        .filter(link -> link.ownerId().equals(ownerId))
        .orElseThrow(() -> new LinkNotFound(code));
  }

  public List<Link> listOwned(String ownerId, int limit) {
    return links.listOwned(ownerId, Math.clamp(limit, 1, 500));
  }

  public void delete(String ownerId, ShortCode code) {
    if (links.deleteOwned(ownerId, code) == 0) {
      // Zero rows means it does not exist or it is not yours, and the API
      // says the same thing for both. Telling an unauthorised caller that
      // a code exists is how you enumerate a database.
      throw new LinkNotFound(code);
    }
  }
}

LinkStore is a small interface in the persistence package — findByCode, findByIdempotencyKey, insert, deleteOwned — implemented by a class that owns the repository and the writer and does the entity-to-domain mapping. It exists so that the service depends on four methods it defined rather than on the twenty JpaRepository supplies, and so that ArchitectureTest can say the service package does not import Spring Data at all.

Java · 30

The HTTP edge, and the beans that wire it

The controllers are thin on purpose. Everything they do is HTTP: bind a body, resolve who is calling, pick a status code, build a Location header. Every decision that would still be a decision if this were a queue consumer has already happened one layer down.

// src/main/java/info/jaspreet/shortlink/web/LinkController.java
package info.jaspreet.shortlink.web;

import info.jaspreet.shortlink.domain.Link;
import info.jaspreet.shortlink.domain.ShortCode;
import info.jaspreet.shortlink.config.ShortlinkProperties;
import info.jaspreet.shortlink.domain.TargetUrl;
import info.jaspreet.shortlink.security.ApiKey;
import info.jaspreet.shortlink.service.LinkService;
import info.jaspreet.shortlink.service.LinkService.NewLink;
import info.jaspreet.shortlink.web.LinkDtos.CreateLinkRequest;
import info.jaspreet.shortlink.web.LinkDtos.LinkResponse;
import jakarta.validation.Valid;
import java.net.URI;
import java.time.Duration;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/v1/links")
class LinkController {

  private final LinkService links;
  private final URI publicBaseUrl;

  LinkController(LinkService links, ShortlinkProperties properties) {
    this.links = links;
    this.publicBaseUrl = properties.publicBaseUrl();
  }

  // 201 with a Location header, because the request created a resource and
  // the caller should not have to build its URL by string concatenation.
  @PostMapping
  ResponseEntity<LinkResponse> create(
      @Valid @RequestBody CreateLinkRequest body,
      @RequestHeader(name = "Idempotency-Key", required = false) String idempotencyKey,
      @AuthenticationPrincipal ApiKey caller) {

    // The two parses are the layer boundary made concrete: strings arrive,
    // value objects go on. Both throw IllegalArgumentException, which the
    // advice turns into a 400 with the message -- see the next topic.
    var request = new NewLink(
        caller.ownerId(),
        TargetUrl.parse(body.url()),
        body.alias() == null ? null : new ShortCode(body.alias()),
        body.ttlDays() == null ? null : Duration.ofDays(body.ttlDays()),
        idempotencyKey);

    Link link = links.shorten(request);
    return ResponseEntity.created(shortUrl(link)).body(toResponse(link));
  }

  @GetMapping("/{code}")
  LinkResponse read(@PathVariable String code, @AuthenticationPrincipal ApiKey caller) {
    return toResponse(links.readOwned(caller.ownerId(), new ShortCode(code)));
  }

  @GetMapping
  List<LinkResponse> list(@AuthenticationPrincipal ApiKey caller) {
    return links.listOwned(caller.ownerId(), 100).stream()
        .map(this::toResponse)
        .toList();
  }

  // 204: it is gone, there is nothing to say about it, and a body on a
  // delete is a thing clients start depending on.
  @DeleteMapping("/{code}")
  @ResponseStatus(HttpStatus.NO_CONTENT)
  void delete(@PathVariable String code, @AuthenticationPrincipal ApiKey caller) {
    links.delete(caller.ownerId(), new ShortCode(code));
  }

  private URI shortUrl(Link link) {
    return publicBaseUrl.resolve("/" + link.code().value());
  }

  private LinkResponse toResponse(Link link) {
    return new LinkResponse(
        link.code().value(),
        shortUrl(link).toString(),
        link.target().value().toString(),
        link.createdAt(),
        link.expiresAt());
  }
}
// src/main/java/info/jaspreet/shortlink/web/RedirectController.java
package info.jaspreet.shortlink.web;

import info.jaspreet.shortlink.domain.Link;
import info.jaspreet.shortlink.domain.ShortCode;
import info.jaspreet.shortlink.service.LinkService;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

/**
 * The only unauthenticated route in the service, and the only one that
 * gets any real traffic. It is a separate controller from the management
 * API because it has a different audience, a different security rule and a
 * different failure budget -- and because "/{code}" at the root would be a
 * frightening thing to find in a class also mapped to /v1/links.
 */
@RestController
class RedirectController {

  private final LinkService links;

  RedirectController(LinkService links) {
    this.links = links;
  }

  // The regex in the path variable is load-bearing: without it this maps
  // every single-segment path in the application, including ones a future
  // route wants. With it, only things shaped like a code get here.
  @GetMapping("/{code:[a-z0-9]{4,32}}")
  ResponseEntity<Void> follow(@PathVariable String code) {
    Link link = links.resolve(new ShortCode(code));
    return ResponseEntity.status(HttpStatus.FOUND)
        .location(link.target().value())
        .cacheControl(CacheControl.noStore())
        .build();
  }
}

302 and no-store, not 301 — and this is not a style preference

A 301 is permanent, and browsers treat it that way: Chrome will cache it, often for the life of the profile, and will not ask you again. Ship a 301 and you have given away the three things a link shortener exists to have. You can never re-point the link, because the browser stops asking. You can never expire or delete it, for the same reason. And your click metrics fall off a cliff, because after the first visit each user redirects locally and you never see it. The 301 is faster for the user by exactly one round trip and costs you the product. Send 302 with Cache-Control: no-store, and if the latency ever genuinely matters, buy it back at the CDN where you can still purge.

// src/main/java/info/jaspreet/shortlink/ShortlinkApplication.java
package info.jaspreet.shortlink;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;

@SpringBootApplication
@ConfigurationPropertiesScan
public class ShortlinkApplication {

  public static void main(String[] args) {
    SpringApplication.run(ShortlinkApplication.class, args);
  }
}
// src/main/java/info/jaspreet/shortlink/config/ShortlinkConfig.java
package info.jaspreet.shortlink.config;

import info.jaspreet.shortlink.domain.CodeGenerator;
import info.jaspreet.shortlink.persistence.LinkStore;
import info.jaspreet.shortlink.service.LinkService;
import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * The four lines that pay for a framework-free service package. Every bean
 * here is a plain object whose dependencies are explicit at the call site,
 * which is also the reason this file is worth reading first when you are
 * new: it is the whole object graph on one screen.
 */
@Configuration
class ShortlinkConfig {

  // Never Instant.now() inside a method. A clock you can inject is a test
  // that can be about expiry without being about sleeping.
  @Bean
  Clock clock() {
    return Clock.systemUTC();
  }

  @Bean
  CodeGenerator codeGenerator(ShortlinkProperties properties) {
    return new CodeGenerator(properties.codeLength());
  }

  @Bean
  LinkService linkService(LinkStore links, CodeGenerator codes,
                          Clock clock, ShortlinkProperties properties) {
    return new LinkService(links, codes, clock, properties.defaultTtl());
  }
}
Java · 31

Failure is part of the contract

A service has two APIs and only one of them is documented. The other is what happens when things go wrong, and it is the one a client actually integrates against: which status codes mean retry, which mean stop, and whether the body is parseable when it is not a 200. Spring gives you ProblemDetail for the shape; a sealed hierarchy gives you a compiler that will not let you forget a case.

// src/main/java/info/jaspreet/shortlink/error/ShortlinkException.java
package info.jaspreet.shortlink.error;

import info.jaspreet.shortlink.domain.ShortCode;

/**
 * Every failure this service raises on purpose, in one sealed hierarchy.
 *
 * Sealed is the point. The handler switches over this type, and because
 * the compiler knows the complete list of subclasses it will reject the
 * switch the moment somebody adds a seventh -- so a new failure mode
 * cannot reach a client as an unmapped 500 six weeks later. That is the
 * same guarantee the sealed-interfaces topic promised, applied to the one
 * place in a service where "I forgot a case" is most expensive.
 *
 * Note what is not here: no status codes, no HTTP. This package is
 * importable from a batch job.
 */
public abstract sealed class ShortlinkException extends RuntimeException {

  protected ShortlinkException(String message) {
    // No cause, no stack trace filled in for the expected ones: these are
    // control flow, not incidents, and a stack trace per 404 is a bill.
    super(message, null, false, false);
  }

  public static final class LinkNotFound extends ShortlinkException {
    public LinkNotFound(ShortCode code) {
      super("no link with code " + code.value());
    }
  }

  public static final class LinkExpired extends ShortlinkException {
    public LinkExpired(ShortCode code) {
      super("link " + code.value() + " has expired");
    }
  }

  public static final class AliasTaken extends ShortlinkException {
    public AliasTaken(ShortCode code) {
      super("the alias " + code.value() + " is already in use");
    }
  }

  public static final class CodeTaken extends ShortlinkException {
    public CodeTaken(String code) {
      super("code " + code + " is already in use");
    }
  }

  public static final class IdempotencyClash extends ShortlinkException {
    public IdempotencyClash(String key) {
      super("concurrent request with idempotency key " + key);
    }
  }

  public static final class NoCodeAvailable extends ShortlinkException {
    public NoCodeAvailable(int attempts) {
      super("no free code after " + attempts + " attempts");
    }
  }
}

A sealed class whose subclasses are nested inside it needs no permits clause — the compiler takes the enclosing file as the permitted set, which is exactly the boundary you want here. Now the one place that knows these are HTTP.

// src/main/java/info/jaspreet/shortlink/web/ApiExceptionHandler.java
package info.jaspreet.shortlink.web;

import info.jaspreet.shortlink.error.ShortlinkException;
import info.jaspreet.shortlink.error.ShortlinkException.AliasTaken;
import info.jaspreet.shortlink.error.ShortlinkException.CodeTaken;
import info.jaspreet.shortlink.error.ShortlinkException.IdempotencyClash;
import info.jaspreet.shortlink.error.ShortlinkException.LinkExpired;
import info.jaspreet.shortlink.error.ShortlinkException.LinkNotFound;
import info.jaspreet.shortlink.error.ShortlinkException.NoCodeAvailable;
import java.net.URI;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/**
 * One advice, one media type, one shape. RFC 9457 problem+json, which is
 * what Spring's ProblemDetail already produces, so the framework's own
 * 404s and 415s match the ones written here instead of being a second,
 * undocumented error format the client also has to parse.
 */
@RestControllerAdvice
class ApiExceptionHandler {

  private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);
  private static final String TYPES = "https://study.jaspreet.info/problems/";

  @ExceptionHandler(ShortlinkException.class)
  ProblemDetail onDomainFailure(ShortlinkException failure) {
    // Exhaustive over a sealed type: no default branch, and adding a
    // subclass to the hierarchy breaks this build rather than production.
    ProblemDetail problem = switch (failure) {
      case LinkNotFound e -> of(HttpStatus.NOT_FOUND, "Link not found", e, "link-not-found");
      case LinkExpired e -> of(HttpStatus.GONE, "Link expired", e, "link-expired");
      case AliasTaken e -> of(HttpStatus.CONFLICT, "Alias taken", e, "alias-taken");
      case CodeTaken e -> of(HttpStatus.CONFLICT, "Code taken", e, "code-taken");
      case IdempotencyClash e -> of(HttpStatus.CONFLICT, "Request in flight", e, "in-flight");
      case NoCodeAvailable e -> of(HttpStatus.SERVICE_UNAVAILABLE, "Try again", e, "no-code");
    };
    return problem;
  }

  // Bean validation failures: 400, and say which field, because "invalid
  // request" costs the caller an hour and costs you a support ticket.
  @ExceptionHandler(MethodArgumentNotValidException.class)
  ProblemDetail onInvalidBody(MethodArgumentNotValidException invalid) {
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.BAD_REQUEST, "One or more fields are invalid");
    problem.setTitle("Invalid request");
    problem.setType(URI.create(TYPES + "invalid-request"));
    problem.setProperty("errors", invalid.getBindingResult().getFieldErrors().stream()
        .map(field -> java.util.Map.of(
            "field", field.getField(),
            "message", String.valueOf(field.getDefaultMessage())))
        .toList());
    return problem;
  }

  // The value objects reject bad input by throwing this, and a rejected
  // URL is the caller's mistake, not ours.
  @ExceptionHandler(IllegalArgumentException.class)
  ProblemDetail onIllegalArgument(IllegalArgumentException bad) {
    ProblemDetail problem =
        ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, bad.getMessage());
    problem.setTitle("Invalid request");
    problem.setType(URI.create(TYPES + "invalid-request"));
    return problem;
  }

  // The catch-all. Log everything, tell the client nothing: a stack trace
  // in a response body is a map of your dependencies and their versions.
  @ExceptionHandler(Exception.class)
  ProblemDetail onUnexpected(Exception unexpected) {
    log.error("unhandled exception", unexpected);
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.INTERNAL_SERVER_ERROR, "Unexpected error");
    problem.setTitle("Internal server error");
    problem.setType(URI.create(TYPES + "internal"));
    return problem;
  }

  private static ProblemDetail of(HttpStatus status, String title,
                                  ShortlinkException failure, String type) {
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, failure.getMessage());
    problem.setTitle(title);
    problem.setType(URI.create(TYPES + type));
    return problem;
  }
}
$ curl -sS -i -X POST localhost:8080/v1/links \
    -H 'Authorization: Bearer sl_live_notarealkey' \
    -H 'Content-Type: application/json' \
    -d '{"url":"javascript:alert(1)"}'
HTTP/1.1 400
Content-Type: application/problem+json

{
  "type": "https://study.jaspreet.info/problems/invalid-request",
  "title": "Invalid request",
  "status": 400,
  "detail": "only http and https can be shortened",
  "instance": "/v1/links"
}

# and the one every client actually codes against:
$ curl -sS -o /dev/null -w '%{http_code}\n' localhost:8080/gone4evr
410

The catch-all advice will swallow your framework’s errors if you let it

@ExceptionHandler(Exception.class) is broad enough to catch things Spring already handles well — a 404 for an unmapped route, a 405, a 415 for the wrong content type — and turn all of them into your 500. Spring resolves the most specific handler, so the built-in ResponseEntityExceptionHandler paths still win as long as you do not also extend that class and override them badly; but the moment somebody adds a handler for RuntimeException the ordering becomes something you have to reason about. Two habits keep this safe: extend ResponseEntityExceptionHandler when you want to customise the framework’s own errors rather than shadowing them, and write a test that asserts a request to /nope is still a 404 with problem+json. That test has caught this for me more than once.

Java · 32

Who is calling, and how often

Two controls, and they fail in opposite directions. Authentication decides whether a request happens at all, so it must be impossible to skip — which is why it is a filter chain rule and not a check at the top of a method somebody will forget. Rate limiting decides how much of you one caller gets, so it must be shared across every replica — which is why it is in Redis and not in a field.

// src/main/java/info/jaspreet/shortlink/config/SecurityConfig.java
package info.jaspreet.shortlink.config;

import info.jaspreet.shortlink.security.ApiKeyFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
class SecurityConfig {

  @Bean
  SecurityFilterChain filterChain(HttpSecurity http, ApiKeyFilter apiKeys) throws Exception {
    return http
        // No cookies, no sessions, no browser form anywhere in this service,
        // so there is no credential a browser attaches automatically and
        // therefore nothing for CSRF to forge. Disabling it here is correct
        // and disabling it in a cookie-session app is a vulnerability; the
        // difference is the whole reason to write this comment rather than
        // copy the line.
        .csrf(csrf -> csrf.disable())
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .httpBasic(AbstractHttpConfigurer::disable)
        .formLogin(AbstractHttpConfigurer::disable)

        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/actuator/health/**").permitAll()
            .requestMatchers(HttpMethod.GET, "/{code:[a-z0-9]{4,32}}").permitAll()
            .requestMatchers("/v1/**").authenticated()
            // Everything not named above is denied. The alternative,
            // anyRequest().permitAll(), means the next route somebody adds
            // is public until they remember it should not be.
            .anyRequest().denyAll())

        .addFilterBefore(apiKeys, UsernamePasswordAuthenticationFilter.class)

        .exceptionHandling(e -> e
            .authenticationEntryPoint((request, response, failure) -> {
              response.setStatus(401);
              response.setHeader("WWW-Authenticate", "Bearer");
              response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
              response.getWriter().write("""
                  {"type":"https://study.jaspreet.info/problems/unauthorized",\
                  "title":"Unauthorized","status":401,\
                  "detail":"a valid API key is required"}""");
            }))

        .headers(Customizer.withDefaults())
        .build();
  }
}

Note what the filter does not do below: it never rejects a request. A missing or unusable key leaves the security context empty and the chain carries on, and the rule in authorizeHttpRequests is what produces the 401 through the entry point above. Filters that write their own 401s end up with two answers for the same question, and the one they write is always the one that forgets the WWW-Authenticate header.

// src/main/java/info/jaspreet/shortlink/security/ApiKey.java
package info.jaspreet.shortlink.security;

/** Who is calling. A record, so nothing downstream can edit it. */
public record ApiKey(String ownerId, String label) {}


// src/main/java/info/jaspreet/shortlink/security/ApiKeyFilter.java
package info.jaspreet.shortlink.security;

import info.jaspreet.shortlink.persistence.ApiKeyStore;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.List;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

@Component
public class ApiKeyFilter extends OncePerRequestFilter {

  private static final String SCHEME = "Bearer ";

  private final ApiKeyStore keys;
  private final RateLimiter limiter;

  ApiKeyFilter(ApiKeyStore keys, RateLimiter limiter) {
    this.keys = keys;
    this.limiter = limiter;
  }

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                  FilterChain chain) throws ServletException, IOException {
    String header = request.getHeader("Authorization");
    if (header != null && header.startsWith(SCHEME)) {
      String presented = header.substring(SCHEME.length()).trim();
      String digest = sha256Hex(presented);

      // The lookup is by hash, so a database that leaks tells an attacker
      // nothing usable: they get the digests, and a 256-bit random key is
      // not coming back out of one.
      keys.findActiveByHash(digest)
          // Redundant on its face -- we found the row by this hash -- and
          // kept anyway, because it is the line that stays correct if the
          // lookup ever becomes a prefix scan or a cache. Constant time,
          // so no attacker learns anything from how long a miss took.
          .filter(stored -> MessageDigest.isEqual(
              stored.keyHash().getBytes(StandardCharsets.UTF_8),
              digest.getBytes(StandardCharsets.UTF_8)))
          .ifPresent(stored -> authenticate(new ApiKey(stored.ownerId(), stored.label())));
    }

    // No key, or a key we do not know: the context stays empty and the
    // authorization rules decide. This filter has no opinion about 401.
    var caller = SecurityContextHolder.getContext().getAuthentication();
    if (caller != null && caller.getPrincipal() instanceof ApiKey key
        && !limiter.tryAcquire(key.ownerId())) {
      tooManyRequests(response);
      return;
    }
    try {
      chain.doFilter(request, response);
    } finally {
      // A pooled thread outlives the request. Clearing is not optional.
      SecurityContextHolder.clearContext();
    }
  }

  private static void authenticate(ApiKey key) {
    var token = new UsernamePasswordAuthenticationToken(
        key, null, List.of(new SimpleGrantedAuthority("ROLE_API")));
    SecurityContextHolder.getContext().setAuthentication(token);
  }

  private void tooManyRequests(HttpServletResponse response) throws IOException {
    response.setStatus(429);
    response.setHeader("Retry-After", "60");   // seconds, and mean it
    response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
    response.getWriter().write("""
        {"type":"https://study.jaspreet.info/problems/rate-limited",\
        "title":"Too many requests","status":429,\
        "detail":"the per-minute quota for this key is spent"}""");
  }

  private static String sha256Hex(String value) {
    try {
      var sha256 = MessageDigest.getInstance("SHA-256");
      return HexFormat.of().formatHex(sha256.digest(value.getBytes(StandardCharsets.UTF_8)));
    } catch (NoSuchAlgorithmException impossible) {
      throw new IllegalStateException("every JVM has SHA-256", impossible);
    }
  }
}

SHA-256 for an API key, bcrypt for a password — and swapping them is a bug both ways

A password is a low-entropy secret a human chose, so the defence against a stolen hash table is to make each guess expensive: bcrypt, scrypt or argon2, deliberately slow, with a per-row salt. An API key is 256 bits from SecureRandom; there is no dictionary to run against it and no amount of GPU that gets through the search space, so a plain SHA-256 is enough. It is also necessary — put bcrypt on the API key path and you have added a hundred milliseconds of CPU to every single request, which at any real rate is a fleet of pods spent hashing. Use a fast hash for high-entropy secrets and a slow one for human-chosen ones, and be able to say why in one sentence, because this is asked constantly.

The other half of the rule is the key’s life: generate it with SecureRandom, show the plaintext to the user exactly once at creation, store only the digest, and give every key a revoked_at so revocation is an update rather than a delete you cannot audit. A prefix like sl_live_ costs nothing and lets a secret scanner find it in a public repository before an attacker does.

// src/main/java/info/jaspreet/shortlink/security/RateLimiter.java
package info.jaspreet.shortlink.security;

import info.jaspreet.shortlink.config.ShortlinkProperties;
import java.time.Duration;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Component;

/**
 * A fixed window counter in Redis. Not a HashMap: four replicas each with
 * their own map give a caller four times the quota, and a deploy resets
 * everyone's limit to zero -- which is exactly when you least want it.
 */
@Component
public class RateLimiter {

  private static final Logger log = LoggerFactory.getLogger(RateLimiter.class);

  // Two commands as one round trip and one atomic step. INCR then EXPIRE
  // as separate calls has a window where the process dies in between and
  // leaves a key with no TTL -- which bans that caller permanently.
  private static final RedisScript<Long> INCREMENT = new DefaultRedisScript<>("""
      local hits = redis.call('INCR', KEYS[1])
      if hits == 1 then
        redis.call('PEXPIRE', KEYS[1], ARGV[1])
      end
      return hits
      """, Long.class);

  private final StringRedisTemplate redis;
  private final int permitted;
  private final Duration window;

  RateLimiter(StringRedisTemplate redis, ShortlinkProperties properties) {
    this.redis = redis;
    this.permitted = properties.rateLimit().perMinute();
    this.window = properties.rateLimit().window();
  }

  public boolean tryAcquire(String ownerId) {
    // The key carries the window, so the old window's key expires on its
    // own and there is nothing to clean up.
    long bucket = System.currentTimeMillis() / window.toMillis();
    String key = "rl:" + ownerId + ":" + bucket;
    try {
      Long hits = redis.execute(INCREMENT, List.of(key), String.valueOf(window.toMillis()));
      return hits == null || hits <= permitted;
    } catch (RuntimeException redisIsDown) {
      // Fail open, and say so loudly. A limiter that fails closed turns a
      // Redis blip into a total outage, which is a worse day than a minute
      // of unlimited traffic. If the limit is protecting something that
      // cannot survive that -- a payment endpoint, a third-party quota --
      // then fail closed instead, on purpose, and write that down.
      log.warn("rate limiter unavailable, allowing request", redisIsDown);
      return true;
    }
  }
}

// A fixed window lets a caller spend 2x the quota across a boundary: sixty
// at 11:59:59 and sixty more at 12:00:00. For an API key that is fine and
// the simplicity is worth it. When it is not, the next step is a sliding
// window log or a token bucket -- both are the same INCR-in-Lua shape with
// more arithmetic, and both still live in Redis for the same reason.
Java · 33

Logs, metrics, and the two health checks

The question you are building for is “a customer says their request failed at 14:32, what happened”, and it has to be answerable from a laptop in under a minute. That needs three things: every log line from one request carrying the same id, metrics you did not have to invent, and two health endpoints that answer genuinely different questions.

// src/main/java/info/jaspreet/shortlink/web/RequestIdFilter.java
package info.jaspreet.shortlink.web;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.UUID;
import java.util.regex.Pattern;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RequestIdFilter extends OncePerRequestFilter {

  private static final String HEADER = "X-Request-Id";
  // An id from outside goes into every log line. Unvalidated, that is a
  // log injection: a newline in the header writes a fake log entry.
  private static final Pattern SAFE = Pattern.compile("[A-Za-z0-9._-]{1,64}");

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                  FilterChain chain) throws ServletException, IOException {
    String inbound = request.getHeader(HEADER);
    String requestId = inbound != null && SAFE.matcher(inbound).matches()
        ? inbound
        : UUID.randomUUID().toString();

    MDC.put("requestId", requestId);
    // Echo it, so support can paste the header from a customer's HAR file
    // straight into the log search.
    response.setHeader(HEADER, requestId);
    try {
      chain.doFilter(request, response);
    } finally {
      // Mandatory. MDC is a ThreadLocal, threads are reused, and an id left
      // behind attaches somebody else's request to this customer's trace.
      MDC.remove("requestId");
    }
  }
}

MDC does not follow work you hand to another thread

With spring.threads.virtual.enabled every request gets a fresh virtual thread, so the MDC set here is visible to everything that request does inline — and nothing else. The moment you submit to an ExecutorService, the pool’s thread has an empty context and those log lines lose the id, which is precisely when you needed it most. Logback’s MDC.getCopyOfContextMap() copied into the task is the manual fix, Micrometer’s ContextSnapshot is the framework one, and structured concurrency’s ScopedValue is where this is going. Whichever you pick, decide it once, in one wrapper, and never call executor.submit directly again.

# application.yaml -- the observability half, and the one distinction
# in this file that people get wrong more often than any other.
management:
  endpoint:
    health:
      probes:
        enabled: true
      group:
        # Liveness answers: is this JVM broken beyond recovery? Only a
        # restart fixes that, and a restart is what a failed liveness probe
        # causes. So it checks nothing external. Put the database in here
        # and a thirty-second Postgres failover restarts every pod you own,
        # simultaneously, which turns a blip into an outage.
        liveness:
          include: livenessState
        # Readiness answers: should traffic come here right now? A pod with
        # no database cannot serve, but it will recover on its own, so the
        # right response is to stop routing to it -- not to kill it.
        readiness:
          include: readinessState,db,redis
  endpoints:
    web:
      exposure:
        include: health,prometheus
  metrics:
    tags:
      application: shortlink        # every metric, one tag, free grouping
{
  "@timestamp": "2026-08-31T14:32:07.418Z",
  "log.level": "WARN",
  "service.name": "shortlink",
  "process.thread.name": "VirtualThread[#742]/runnable",
  "log.logger": "info.jaspreet.shortlink.security.RateLimiter",
  "message": "rate limiter unavailable, allowing request",
  "requestId": "6f1c2a94-3d55-4c1e-9f0a-2b7e6d4a1c33",
  "trace.id": "0af7651916cd43dd8448eb211c80319c",
  "span.id": "b7ad6b7169203331"
}

That is one line of logging.structured.format.console: ecs from the configuration topic — Boot 3.4 and later emit JSON with no encoder XML and no logback-spring.xml at all. requestId is there because the filter put it in the MDC; trace.id and span.id are there because Micrometer Tracing is on the classpath and propagates W3C traceparent headers between services. The rule that makes this worth having: never build a log message with string concatenation. log.warn("link {} expired for owner {}", code, ownerId) keeps code and ownerId as fields you can filter on; log.warn("link " + code + " expired") produces a sentence you can only grep.

What you want to knowWhere it already is
Request rate, error rate, latency by routehttp_server_requests_seconds, tagged uri, status, outcome. Free with actuator. Do not write your own counter for this.
Is the connection pool the bottleneckhikaricp_connections_pending and _acquire. If pending is ever non-zero, the pool is your ceiling.
GC pauses, heap after collectionjvm_gc_pause_seconds, jvm_memory_used_bytes. The memory topic’s numbers, exported.
How full is the code spaceNowhere. This is the one worth adding: count generation retries. It is invisible from outside, no status code reflects it, and it is the only early warning that seven characters have stopped being enough.
Java · 34

Tests that would actually have caught it

Three tests, and each one is a different kind. The architecture rule is a test because a diagram cannot fail a build. The domain tests need no context and run in milliseconds. And the integration tests run against a real Postgres in a container, because the one guarantee this service makes — that the same idempotency key produces one link — is a guarantee about a database, and a mock cannot have it.

// src/test/java/info/jaspreet/shortlink/ArchitectureTest.java
package info.jaspreet.shortlink;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;

import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;

/** The table from the layout topic, as a test. This is the whole point of it. */
@AnalyzeClasses(packages = "info.jaspreet.shortlink",
                importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureTest {

  @ArchTest
  static final ArchRule layers = layeredArchitecture().consideringAllDependencies()
      .layer("web").definedBy("..web..")
      .layer("service").definedBy("..service..")
      .layer("persistence").definedBy("..persistence..")
      .layer("domain").definedBy("..domain..")
      .layer("config").definedBy("..config..")

      .whereLayer("web").mayNotBeAccessedByAnyLayer()
      .whereLayer("service").mayOnlyBeAccessedByLayers("web", "config")
      .whereLayer("persistence").mayOnlyBeAccessedByLayers("service", "config", "security");

  // The rule that pays for itself: the domain stays plain Java. Every one
  // of these packages arriving in ..domain.. is somebody solving a problem
  // in the wrong place, and it is much cheaper to say so at compile time
  // than in a code review six months from now.
  @ArchTest
  static final ArchRule domainIsPlainJava = noClasses()
      .that().resideInAPackage("..domain..")
      .should().dependOnClassesThat().resideInAnyPackage(
          "org.springframework..", "jakarta..", "org.hibernate..", "com.fasterxml..");

  @ArchTest
  static final ArchRule serviceKnowsNoFramework = noClasses()
      .that().resideInAPackage("..service..")
      .should().dependOnClassesThat().resideInAnyPackage(
          "org.springframework..", "jakarta.servlet..");

  // Entities do not leave the persistence package. If one ever appears in
  // a controller signature, the API's shape is now the schema's shape.
  @ArchTest
  static final ArchRule entitiesStayHome = noClasses()
      .that().resideOutsideOfPackage("..persistence..")
      .should().dependOnClassesThat().areAnnotatedWith(jakarta.persistence.Entity.class);
}
$ ./mvnw -q test -Dtest=ArchitectureTest
[ERROR] domainIsPlainJava
Architecture Violation [Priority: MEDIUM] - Rule 'no classes that reside in a
package '..domain..' should depend on classes that reside in any package
['org.springframework..', 'jakarta..', ...]' was violated (1 times):

  Class <info.jaspreet.shortlink.domain.Link> depends on
  <jakarta.persistence.Entity> in (Link.java:0)

# Somebody put @Entity on the domain record because it was convenient.
# It took four seconds to find out, and the fix is still cheap.
// src/test/java/info/jaspreet/shortlink/IntegrationTest.java
package info.jaspreet.shortlink;

import org.junit.jupiter.api.BeforeEach;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.PostgreSQLContainer;

/**
 * The containers are static and started in a static initialiser rather than
 * with @Container, on purpose: @Container ties a container's life to one
 * test class, so eight test classes start and stop Postgres eight times and
 * your suite takes four minutes instead of forty seconds. Started this way
 * they live for the JVM and Testcontainers' reaper stops them at the end.
 *
 * Every subclass shares this exact configuration, which also means Spring
 * caches one application context for the whole suite. Both halves matter:
 * an @MockitoBean in one test class would fork the cache and start a second
 * context, and that is usually where a slow suite's time actually went.
 */
@SpringBootTest
@AutoConfigureMockMvc
abstract class IntegrationTest {

  @ServiceConnection
  static final PostgreSQLContainer<?> POSTGRES =
      new PostgreSQLContainer<>("postgres:16-alpine");

  @ServiceConnection(name = "redis")
  static final GenericContainer<?> REDIS =
      new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);

  static {
    POSTGRES.start();
    REDIS.start();
  }

  // Not @Transactional. A transactional test rolls back at the end, which
  // is fast and convenient and would quietly break the most important test
  // in this file: LinkWriter uses REQUIRES_NEW, so its insert commits in a
  // transaction the test does not own and cannot roll back. Truncating is
  // honest, and it tests the code that actually runs in production.
  @BeforeEach
  void emptyTheTables(@Autowired JdbcClient jdbc) {
    jdbc.sql("truncate table links, api_keys restart identity cascade").update();
  }
}

// Flyway runs against the container on context start, so the schema under
// test is the schema the migration produces -- not one Hibernate invented
// from the entities, which is the version of this that passes while
// production fails.
// src/test/java/info/jaspreet/shortlink/web/LinkApiTest.java
package info.jaspreet.shortlink.web;

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

import info.jaspreet.shortlink.IntegrationTest;
import info.jaspreet.shortlink.domain.TargetUrl;
import info.jaspreet.shortlink.service.LinkService;
import info.jaspreet.shortlink.service.LinkService.NewLink;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.stream.IntStream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.web.servlet.assertj.MockMvcTester;

class LinkApiTest extends IntegrationTest {

  // The plaintext key never exists in production; here it is a constant so
  // the test can send it and store only its digest, exactly as the real
  // key-issuing path does.
  private static final String KEY = "sl_live_thisisatestkeyonly";

  @Autowired MockMvcTester mvc;
  @Autowired JdbcClient jdbc;
  @Autowired LinkService links;

  @BeforeEach
  void issueAKey() throws Exception {
    // Hashed here exactly as the filter hashes it, so the test exercises
    // the real comparison rather than a fixture that agrees with itself.
    String digest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
        .digest(KEY.getBytes(StandardCharsets.UTF_8)));
    jdbc.sql("insert into api_keys (owner_id, label, key_hash) values (?, ?, ?)")
        .params("owner-1", "test", digest)
        .update();
  }

  @Test
  void creates_a_link_and_answers_with_its_public_url() {
    assertThat(mvc.post().uri("/v1/links")
        .header("Authorization", "Bearer " + KEY)
        .contentType(MediaType.APPLICATION_JSON)
        .content("""
            {"url": "https://example.com/a/very/long/path?utm_source=x"}"""))
        .hasStatus(HttpStatus.CREATED)
        .bodyJson().extractingPath("$.shortUrl").asString()
        .startsWith("http://localhost:8080/");
  }

  @Test
  void refuses_a_scheme_that_is_not_http() {
    assertThat(mvc.post().uri("/v1/links")
        .header("Authorization", "Bearer " + KEY)
        .contentType(MediaType.APPLICATION_JSON)
        .content("""
            {"url": "javascript:alert(1)"}"""))
        .hasStatus(HttpStatus.BAD_REQUEST);
  }

  @Test
  void refuses_a_request_with_no_key_and_says_how_to_fix_it() {
    assertThat(mvc.get().uri("/v1/links"))
        .hasStatus(HttpStatus.UNAUTHORIZED)
        .headers().containsKey("WWW-Authenticate");
  }

  // The one a mocked repository cannot express. Sixteen threads released
  // at the same instant with one idempotency key between them; the answer
  // has to be one link, and it has to be the same link for all sixteen.
  @Test
  void the_same_idempotency_key_creates_exactly_one_link() throws Exception {
    var request = new NewLink("owner-1", TargetUrl.parse("https://example.com"),
                              null, null, "key-42");
    var releaseThemAll = new CountDownLatch(1);

    List<Future<String>> attempts;
    try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
      attempts = IntStream.range(0, 16)
          .mapToObj(i -> pool.submit(() -> {
            releaseThemAll.await();
            return links.shorten(request).code().value();
          }))
          .toList();
      releaseThemAll.countDown();
    }

    assertThat(attempts.stream().map(LinkApiTest::get).distinct().toList())
        .as("every caller got the same code")
        .hasSize(1);
    assertThat(jdbc.sql("select count(*) from links").query(Integer.class).single())
        .as("and the database has one row, not sixteen")
        .isEqualTo(1);
  }

  private static String get(Future<String> future) {
    try {
      return future.get();
    } catch (Exception e) {
      throw new AssertionError("a caller failed instead of sharing the link", e);
    }
  }
}

That last test fails without the partial unique index, and passes without a single line of the retry code

Delete the index from V1 and run it: sixteen rows, sixteen codes, and the API cheerfully told sixteen callers they had each created something. Now put the index back and delete the catch (IdempotencyClash) branch instead: fifteen callers get a 500, which is worse than the first failure in every way that matters to a client. The test tells the two apart, which is the property you want — and it is why it is worth the eight seconds a container costs. This is also, almost word for word, a question you will be asked: how do you make this endpoint idempotent. The answer is a constraint, a translated violation, and a re-read — in that order.

Java · 35

The image, the pipeline, and the last mile

The packaging topic already covered layered extraction, the actuator probes and graceful shutdown, and none of that changes here. What is left is the part that only appears when there is a whole repository: an image built from source so that CI needs nothing installed, a pipeline that runs the same four commands a developer runs, and the short list of things that are still not done when the tests are green.

# Dockerfile -- built from source. A machine with Docker and nothing else
# can produce this image, which is what makes CI reproducible and what
# makes "works on my machine" a much shorter conversation.
FROM eclipse-temurin:21-jdk-jammy AS build
WORKDIR /build

# The wrapper and the pom, alone, first. This layer is invalidated only
# when a dependency changes -- which is rare -- so the ninety seconds of
# dependency download is paid once and then cached for weeks.
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw -B -ntp dependency:go-offline

COPY src/ src/
RUN ./mvnw -B -ntp -DskipTests package
RUN cp target/shortlink-0.1.0.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted

FROM eclipse-temurin:21-jre-jammy AS runtime
WORKDIR /app

# A numeric uid, not just a name: Kubernetes' runAsNonRoot can verify a
# number in the manifest and cannot verify a name inside the image.
RUN useradd --system --uid 10001 --no-create-home app

COPY --from=build --chown=10001:10001 /build/extracted/dependencies/          ./
COPY --from=build --chown=10001:10001 /build/extracted/spring-boot-loader/    ./
COPY --from=build --chown=10001:10001 /build/extracted/snapshot-dependencies/ ./
COPY --from=build --chown=10001:10001 /build/extracted/application/           ./

USER 10001
EXPOSE 8080

# The JVM reads the cgroup limit, not the host's memory, but only if you
# let it size the heap as a percentage. ExitOnOutOfMemoryError because a
# JVM that has thrown OOM on one thread still answers liveness probes and
# is better dead than limping.
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"

# Exec form, so java is PID 1 and SIGTERM reaches it. The shell form runs
# /bin/sh -c, the shell does not forward signals, and every deploy becomes
# a ten-second hang followed by SIGKILL and dropped requests.
ENTRYPOINT ["java", "-jar", "app.jar"]
# .github/workflows/ci.yaml -- the same commands the Makefile runs, so a
# red build is reproducible locally with one line and no guessing.
name: ci

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read          # the default is write. It should not be.

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '21'
          cache: maven    # ~/.m2 keyed on the pom's hash

      # verify runs the unit tests, then the container ones. Testcontainers
      # uses the runner's own Docker daemon; nothing extra to install.
      - run: ./mvnw -B -ntp verify

      # Fail the build on a known-vulnerable dependency. This is the supply
      # chain, and it is now part of the definition of a passing build.
      - run: ./mvnw -B -ntp org.owasp:dependency-check-maven:check

      - name: Build the image
        run: docker build -t shortlink:${{ github.sha }} .

      - name: Scan the image
        uses: aquasecurity/trivy-action@0.28.0
        with:
          image-ref: shortlink:${{ github.sha }}
          severity: HIGH,CRITICAL
          exit-code: '1'
Before it is “done”Why it is on this list
Flyway runs as a separate step, not on start-upFour replicas starting together run four migrations. Postgres’ advisory lock means three of them wait rather than corrupt anything, but they wait through their readiness deadline. Run migrations once, in a job, before the new version rolls.
readiness wired to the readiness probe, liveness to the liveness probeThey are different endpoints because they answer different questions. Pointing both at /actuator/health restarts your pods every time the database hiccups.
Memory request equal to the limit, and the limit above MaxRAMPercentage of the heap plus overheadA JVM is heap plus metaspace plus thread stacks plus direct buffers plus the code cache. Size the container for all of it or the kernel kills the process with no stack trace and no log line.
Logs to stdout, and nowhere elseThe platform collects stdout. A log file inside a container is a disk that fills and an incident nobody can explain.
The dependency graph audited on every build, not on a calendarTwo of the last decade’s worst Java incidents were a transitive dependency nobody had listed. ./mvnw dependency:tree is how you find out you have it.
Alerts on the 500 rate and on p99 latency, not just dashboardsA dashboard is for after somebody noticed. An alert is for before.
A rollback that is one command, and that somebody has runEvery deploy plan has one. The ones that work have been rehearsed on a Tuesday afternoon rather than discovered at 2am.

Read this part next to the Python page’s and the differences are smaller than the languages suggest. Both put the rules in a layer that imports nothing, both make idempotency a database constraint rather than a check, both keep one error shape, both hash the API key and share the rate limit, and both fail the boot rather than the tenth request. What Java adds is enforcement: the sealed hierarchy makes the error switch exhaustive, the type system makes a ShortCode impossible to confuse with a String, and ArchUnit makes the layering a failing build rather than a convention. What it costs is the file count on this page. That trade — more ceremony up front, more caught by the compiler — is the entire argument for Java in a service, and it is the answer to give when somebody asks why you would choose it.