15. Professional Practice and Tooling
Everything up to here has been about making a program work. This chapter is about the practices that keep it working while other people change it: tests that catch regressions, builds that produce the same artifact on any machine, and the tooling that finds defects before a user does. These skills separate code that survives a team from code that only its author can safely touch.
<a id="151-testing-object-oriented-code"></a>
15.1 Testing Object-Oriented Code
This subchapter covers why automated tests exist, how to write them with JUnit 5, and the design choices that make a class easy or impossible to test in the first place.
Unit Testing Principles
Code is read and modified far more often than it is written. Every change carries a risk of breaking something that used to work, and in a codebase of any size nobody can hold enough of it in their head to be sure. The purpose of an automated test suite is not to prove a program correct. It is to make change safe.
The analogy is a climbing harness. It does not climb for you and it does not prevent mistakes. It changes the consequence of a slip from catastrophic to inconvenient, which is what allows you to move quickly on difficult ground.
The economics are stark, because the cost of a defect rises sharply with how late it is caught.
caught by a unit test seconds you are already in the code
caught in code review minutes context still fresh
caught in CI minutes a known commit to revert
caught in QA hours/days someone else reproduces it
caught in production days/weeks incident, rollback, data repair
What is a "unit"? The unhelpful answer is "one class". The useful answer is behavioural: a unit test exercises one piece of behaviour in isolation, fast enough that you run it without thinking. If a test needs a database, a network, or a sleeping thread, it is something else, and that is fine as long as you know which kind you are writing.
The test pyramid describes the proportions that work.
/\
/ \ END-TO-END few, slow, brittle
/ \ confidence in the whole system
/------\
/ \ INTEGRATION some, seconds each
/ \ real database, real HTTP
/------------\
/ \ UNIT many, milliseconds each
/ \ one behaviour, no I/O
/__________________\
Inverting this produces the "ice cream cone": a handful of slow end-to-end tests carrying all the weight. Such suites take an hour, fail intermittently, and get ignored, which is worse than having no tests because the failures stop meaning anything.
Good unit tests share five properties, conventionally remembered as FIRST.
| Property | Meaning | What violating it looks like |
|---|---|---|
| Fast | Milliseconds, so the whole suite is seconds | Nobody runs the suite before pushing |
| Isolated | No dependence on other tests or their order | Passes alone, fails in the suite |
| Repeatable | Same result every run, everywhere | Fails on Fridays, or only in CI |
| Self-validating | Passes or fails with no human reading output | "Check the console for the value" |
| Timely | Written with the code, not months later | Tests written to fit the code's bugs |
Repeatability is where most flakiness comes from, and the usual culprits are the current time, random numbers, time zones, default locales, hash iteration order, and concurrency. Each of those is a dependency that should be injected rather than reached for, which is the subject of a later topic.
Test behaviour, not implementation. This is the single most valuable habit, because it determines whether your tests help or obstruct refactoring.
// BRITTLE: asserts HOW the class works. Any refactor breaks it,
// even one that keeps the behaviour identical.
@Test
void addsItem() {
cart.add(book);
assertEquals(1, cart.getItemsList().size()); // exposes internal storage
verify(repository).save(any()); // exposes a collaboration
}
// ROBUST: asserts WHAT the class promises. Survives any internal rewrite.
@Test
void totalReflectsAddedItems() {
cart.add(book); // book costs 1250
assertEquals(Money.of(1250), cart.total()); // observable behaviour
}
A practical test: if you rewrote the class internals without changing its public contract, how many tests would break? Ideally none.
Coverage is a signal, never a target. Line coverage tells you which lines executed, not whether anything was verified. A test with no assertions gives full coverage and zero confidence. Once coverage becomes a goal, people write tests that inflate the number, and the metric stops measuring anything. Use it to find code nobody tested at all, not to certify code that was.
Finally, the most reliable habit in practice: when a bug is reported, write a failing test that reproduces it before fixing it. The test proves you understood the bug, proves the fix works, and permanently prevents its return.
Key Takeaways
- Tests exist to make change safe, not to prove correctness.
- Defect cost rises steeply with how late it is discovered.
- A unit test exercises one behaviour with no I/O and runs in milliseconds.
- The pyramid means many unit tests, fewer integration tests, and few end-to-end tests.
- An inverted pyramid produces slow, flaky suites that everyone learns to ignore.
- FIRST names the five properties: fast, isolated, repeatable, self-validating, timely.
- Time, randomness, locale, and concurrency are the usual sources of flakiness.
- Assert observable behaviour, so that internal refactoring does not break tests.
- Coverage finds untested code but certifies nothing, and collapses when used as a target.
- Reproduce every reported bug with a failing test before fixing it.
🧪 Practice
- Take a class you have written and list its behaviours as sentences starting with "it should". Count how many are currently verified anywhere.
- Write two tests for a shopping cart total, one asserting the internal list size and one asserting the total. Rename the internal field and observe which test breaks.
- Classify each as unit, integration, or end-to-end: verifying a discount calculation, verifying a repository saves to PostgreSQL, verifying a checkout flow through the web interface.
- Find a test in any codebase that would still pass if the method body were deleted, and explain what it is actually asserting.
- Interview: A team reports 90% line coverage and frequent production defects. What would you look at? (Hint: what does an executed line tell you about whether anything was checked?)
JUnit Test Structure and Assertions
JUnit is the de facto testing framework for Java, and JUnit 5 is built from three parts worth distinguishing, because the names appear in build files.
| Component | Role |
|---|---|
| JUnit Platform | Discovers and runs tests; what build tools and IDEs talk to |
| JUnit Jupiter | The annotations and assertions you actually write against |
| JUnit Vintage | Runs legacy JUnit 3 and 4 tests on the new platform |
Tests live in src/test/java, mirroring the package structure of
src/main/java, a convention 15.2 explains. A test class is an ordinary class;
no inheritance or naming rule is required, though ClassNameTest is universal.
Every test has the same three-part shape, known as Arrange-Act-Assert or, in behavioural phrasing, Given-When-Then.
class BankAccountTest {
@Test
void withdrawalReducesBalance() {
BankAccount account = new BankAccount(1000); // ARRANGE: set up the world
account.withdraw(300); // ACT: one action under test
assertEquals(700, account.balance()); // ASSERT: verify the outcome
}
}
Keeping those phases visually separate, with a blank line between them, makes a test readable in seconds. A test with two "act" steps is usually two tests.
Naming matters more than in production code, because a test name is what you read in a failure report, often months later and without context.
| Naming style | Example |
|---|---|
methodName_condition_expectedResult | withdraw_insufficientFunds_throws |
| Sentence form | throwsWhenWithdrawingMoreThanBalance |
@DisplayName for full prose | @DisplayName("rejects a withdrawal larger than the balance") |
Avoid test1, testWithdraw, and anything that names only the method, because
they tell you nothing when they fail.
The assertion catalogue lives in org.junit.jupiter.api.Assertions.
| Assertion | Verifies |
|---|---|
assertEquals(expected, actual) | Equality by equals; note the argument order |
assertSame(expected, actual) | Identity by == |
assertTrue / assertFalse | A boolean condition |
assertNull / assertNotNull | Nullness |
assertArrayEquals | Element-wise array equality |
assertIterableEquals | Element-wise iterable equality |
assertThrows | That a call throws, and returns the exception |
assertDoesNotThrow | That a call completes normally |
assertAll | Several assertions, reporting every failure |
assertTimeout | That a call completes within a duration |
fail("reason") | Unconditional failure, for unreachable paths |
Three of these deserve detail.
assertThrows returns the exception, so you can assert on its contents rather
than merely its type.
@Test
void withdrawalBeyondBalanceIsRejected() {
BankAccount account = new BankAccount(100);
InsufficientFundsException e = assertThrows( // returns the thrown exception
InsufficientFundsException.class,
() -> account.withdraw(500)); // the code that must throw
assertEquals(400, e.shortfall()); // now assert its details
assertEquals(100, account.balance()); // and that nothing changed
}
assertAll reports every failure, where sequential assertions stop at the
first one and hide the rest.
@Test
void createsAccountWithAllFields() {
Account a = Account.open("ada", "EUR");
assertAll("account", // all four run regardless
() -> assertEquals("ada", a.owner()),
() -> assertEquals("EUR", a.currency()),
() -> assertEquals(0, a.balance()),
() -> assertTrue(a.isOpen()));
}
Failure messages can be lazy. Passing a Supplier<String> avoids building the
message on the passing path, which matters when the message is expensive.
assertEquals(expected, actual, () -> "mismatch for " + buildExpensiveReport());
Argument order is not cosmetic. assertEquals(expected, actual) determines
which value the failure message calls expected. Reversing them produces reports
that send the next reader in the wrong direction.
Floating point needs a delta, because binary representation makes exact equality unreliable.
assertEquals(0.3, 0.1 + 0.2, 1e-9); // without the delta, this FAILS
AssertJ is worth knowing, because most professional codebases use it for its fluent style and dramatically better failure output.
// JUnit: "expected: <3> but was: <2>"
assertEquals(3, cart.items().size());
// AssertJ: prints the full list contents on failure
assertThat(cart.items()).hasSize(3)
.extracting(Item::name)
.containsExactly("book", "pen", "lamp");
Four mistakes recur in real test suites.
| Mistake | Why it is a problem |
|---|---|
| A test with no assertion | Passes whatever happens; pure coverage theatre |
| Several unrelated behaviours per test | One failure hides the others; the name cannot describe it |
if or loops inside a test | The test itself now needs testing |
| Duplicating the implementation's logic | Both change together, so the test proves nothing |
That last one is subtle and common: computing the expected value with the same formula the code uses means a wrong formula passes its own test. Expected values should be written as literals wherever possible.
Key Takeaways
- JUnit 5 splits into Platform, Jupiter, and Vintage, and Jupiter is the API you write against.
- Tests live in
src/test/javamirroring the main package structure.- Every test reads as Arrange, Act, Assert, separated visually.
- A test name is read at failure time, so it must state the behaviour.
assertEqualstakes expected first, and reversing it misleads the reader.assertThrowsreturns the exception so its contents can be asserted.assertAllreports every grouped failure instead of stopping at the first.- Floating point comparisons need an explicit delta.
- AssertJ gives fluent assertions and far more informative failure messages.
- Never recompute the expected value with the implementation's own logic.
🧪 Practice
- Write a test class for a
Stackwith tests for push, pop, and popping when empty, usingassertThrowsfor the last one.- Take four related assertions about one object and rewrite them with
assertAll. Break two deliberately and compare the reports.- Write
assertEquals(0.3, 0.1 + 0.2)and run it. Explain the failure, then fix it two different ways.- Rewrite three JUnit assertions in AssertJ and compare the failure output by breaking each one.
- Interview: Why does
assertEqualstake the expected value first when the result is the same either way? (Hint: what does the failure message say, and who reads it?)
Test Lifecycle and Parameterized Tests
Tests need setup, and repeating that setup in every method is both noisy and a source of accidental sharing. JUnit provides lifecycle hooks, and the rule governing them follows from the isolation requirement of FIRST.
JUnit creates a new instance of the test class for every test method. This is deliberate: a field assigned in one test cannot leak into another, so tests cannot depend on execution order.
For a class with two @Test methods:
new TestClass() -> @BeforeEach -> test1 -> @AfterEach
new TestClass() -> @BeforeEach -> test2 -> @AfterEach
^ a FRESH instance, so fields start over
@BeforeAll runs once before everything, @AfterAll once after.
Both must be static, because no instance exists yet.
| Annotation | Runs | Must be static | Typical use |
|---|---|---|---|
@BeforeAll | Once, before all tests | Yes | Start a container, load a fixture file |
@BeforeEach | Before every test method | No | Build the object under test |
@AfterEach | After every test method | No | Close resources opened per test |
@AfterAll | Once, after all tests | Yes | Stop the container |
class OrderServiceTest {
private OrderService service; // fresh for every test
@BeforeEach
void setUp() {
service = new OrderService(new InMemoryOrderRepository());
}
@Test
void placesOrder() { /* uses a clean service */ }
@Test
void rejectsEmptyOrder() { /* also a clean service, unaffected by the above */ }
}
Annotating the class @TestInstance(Lifecycle.PER_CLASS) reuses one instance
and allows non-static @BeforeAll, but it reintroduces the shared-state risk
the default was designed to prevent. Use it only when construction is genuinely
expensive.
@Nested groups tests by context, which keeps setup close to the tests that
need it and produces readable reports.
class BankAccountTest {
@Nested
@DisplayName("when the account is overdrawn")
class WhenOverdrawn {
BankAccount account;
@BeforeEach
void overdraw() { // runs AFTER the outer @BeforeEach
account = new BankAccount(-50);
}
@Test
void rejectsWithdrawals() { /* ... */ }
@Test
void allowsDeposits() { /* ... */ }
}
}
Nested classes must be inner classes, not static, so they can see the outer
instance.
Parameterized tests remove copy-paste. When the same behaviour must hold for many inputs, writing one test per input buries the intent in duplication.
// Before: three tests that differ only in data
@Test void rejectsEmpty() { assertFalse(Validator.isValidEmail("")); }
@Test void rejectsNoAt() { assertFalse(Validator.isValidEmail("ada.dev")); }
@Test void rejectsNoDomain(){ assertFalse(Validator.isValidEmail("ada@")); }
// After: one test, three cases, each reported separately
@ParameterizedTest(name = "rejects \"{0}\"") // {0} is the argument
@ValueSource(strings = {"", "ada.dev", "ada@", "@example.com", "a b@x.com"})
void rejectsMalformedEmails(String candidate) {
assertFalse(Validator.isValidEmail(candidate));
}
Each case is reported as a separate test, so a failure names the offending input.
| Source annotation | Supplies |
|---|---|
@ValueSource | One literal argument per run |
@NullSource, @EmptySource, @NullAndEmptySource | The awkward edge cases |
@CsvSource | Several arguments per run, written inline |
@CsvFileSource | The same, from a resource file |
@EnumSource | Every constant of an enum, or a filtered subset |
@MethodSource | Arbitrary objects from a static factory method |
@ArgumentsSource | A custom provider, for full control |
@CsvSource covers the common multi-argument case.
@ParameterizedTest(name = "{0} + {1} = {2}")
@CsvSource({
"1, 1, 2",
"5, 3, 8",
"-2, 2, 0",
"0, 0, 0"
})
void addsCorrectly(int a, int b, int expected) {
assertEquals(expected, Calculator.add(a, b));
}
@MethodSource handles arguments that are not literals.
@ParameterizedTest
@MethodSource("discountCases")
void appliesDiscount(Order order, Money expected) {
assertEquals(expected, pricing.total(order));
}
static Stream<Arguments> discountCases() { // must be static, returns a Stream
return Stream.of(
Arguments.of(orderOf(1, "book"), Money.of(1250)),
Arguments.of(orderOf(10, "book"), Money.of(11250)), // bulk discount
Arguments.of(orderOf(0), Money.ZERO));
}
Other lifecycle-adjacent annotations round out the toolkit.
| Annotation | Purpose |
|---|---|
@RepeatedTest(n) | Runs a test n times, useful for flakiness hunting |
@Disabled("reason") | Skips a test; always give the reason |
@Timeout(5) | Fails a test that exceeds a duration |
@Tag("slow") | Labels tests so the build can include or exclude them |
@TempDir | Injects a temporary directory, deleted afterwards |
@TempDir pairs directly with Chapter 13's file work and removes a whole class
of test pollution.
@Test
void writesReportToDisk(@TempDir Path dir) throws IOException { // auto-cleaned
Path out = dir.resolve("report.txt");
reporter.writeTo(out);
assertTrue(Files.exists(out));
assertThat(Files.readString(out)).contains("TOTAL");
}
Assumptions differ from disabling. @Disabled always skips. An assumption
skips only when a runtime condition is unmet, which suits environment-specific
tests.
@Test
void usesNativeLibrary() {
assumeTrue(System.getProperty("os.name").startsWith("Linux")); // skip elsewhere
// ... the rest runs only on Linux
}
Finally, do not make tests depend on execution order. JUnit deliberately
does not guarantee it. @TestMethodOrder exists for the rare integration
scenario that genuinely models a sequence, and reaching for it in unit tests
signals shared state that should be removed instead.
Key Takeaways
- JUnit builds a new test instance per method, so fields cannot leak between tests.
@BeforeAlland@AfterAllare static because no instance exists when they run.@TestInstance(PER_CLASS)reuses one instance and reintroduces shared-state risk.@Nestedgroups tests by context and keeps their setup local.- Parameterized tests turn duplicated methods into one test with named cases.
@ValueSourcecovers single arguments and@CsvSourcecovers several.@MethodSourcesupplies arbitrary objects from a static factory returning a stream.@TempDirgives each test a private directory that is cleaned up automatically.- Assumptions skip conditionally at runtime, unlike the unconditional
@Disabled.- Tests must never depend on execution order, and needing an order is a design smell.
🧪 Practice
- Write a test class with all four lifecycle annotations, each printing its name, and run two tests. Write down the exact output order before running.
- Convert five near-identical validation tests into one
@ParameterizedTestwith@ValueSource, and give the cases a readablenamepattern.- Use
@CsvSourceto test a function across at least six input combinations including boundary values.- Write a test that creates files using
@TempDir, then verify that the directory no longer exists after the run.- Use
@MethodSourceto supply threeOrderobjects with their expected totals.- Interview: Why does JUnit create a new instance of the test class for every test method rather than reusing one? (Hint: which of the FIRST properties would a shared instance threaten?)
Designing Classes for Testability
When a class is hard to test, the instinct is to blame the testing tools. That instinct is almost always wrong. Difficulty testing a class in isolation is evidence that the class cannot be used in isolation, which is a design problem that also makes it hard to reuse, reason about, and change.
Testability is therefore a proxy metric. A class that is easy to test tends to have few dependencies, explicit inputs, and one responsibility, which are the same properties Chapter 9 argued for on design grounds alone.
The enabling concept is a seam: a place where behaviour can be changed without editing the code. A constructor parameter is the cheapest seam there is. Code with no seams can only be tested exactly as it runs in production.
Here is a class with no seams at all.
public class OrderService {
// Created internally: a test cannot replace either of these
private final EmailSender email = new SmtpEmailSender("smtp.corp.com");
private final OrderRepository repo = OrderRepository.getInstance(); // global state
public Receipt place(Order order) {
repo.save(order);
email.send(order.customerEmail(), "Order confirmed"); // sends REAL email in tests
return new Receipt(order, LocalDateTime.now()); // hidden, changing input
}
}
Testing this means running a real SMTP server, sharing a global repository with every other test, and writing assertions that cannot pin down the timestamp. The same class with its dependencies made explicit is trivially testable.
public class OrderService {
private final EmailSender email;
private final OrderRepository repo;
private final Clock clock; // time becomes an ordinary input
public OrderService(EmailSender email, OrderRepository repo, Clock clock) {
this.email = email; // constructor only assigns
this.repo = repo;
this.clock = clock;
}
public Receipt place(Order order) {
repo.save(order);
email.send(order.customerEmail(), "Order confirmed");
return new Receipt(order, LocalDateTime.now(clock)); // reads the injected clock
}
}
Nothing about the production behaviour changed. What changed is that a test can now supply an in-memory repository, a recording email sender, and a clock frozen at a known instant.
The obstacles to testability are a short, recognisable list.
| Smell in the code | Why it blocks testing | Fix |
|---|---|---|
new Collaborator() inside the class | No way to substitute it | Inject through the constructor |
Singleton.getInstance() | Global mutable state leaks across tests | Inject the instance |
LocalDateTime.now() | A hidden, always-changing input | Inject a Clock |
new Random() | Non-deterministic results | Inject a seeded Random |
System.getenv(...) | Behaviour depends on the machine | Inject a configuration object |
| Static methods that do real work | Cannot be substituted or overridden | Instance methods on an injected type |
| I/O or registration in a constructor | The object cannot be built in a test | Constructors assign and validate only |
| A private method you want to test | Signals a hidden responsibility | Extract it into its own class |
a.getB().getC().getD() | A test must construct the whole chain | Ask the neighbour for what you need |
The Clock case is worth seeing in full, because time is the dependency
people most often forget is a dependency.
// Production wiring
var service = new SubscriptionService(Clock.systemDefaultZone());
// Test wiring: the test now controls "now"
Clock frozen = Clock.fixed(Instant.parse("2026-01-15T10:00:00Z"), ZoneOffset.UTC);
var service = new SubscriptionService(frozen);
assertTrue(service.isExpired(subscriptionEndingOn(LocalDate.of(2026, 1, 14))));
assertFalse(service.isExpired(subscriptionEndingOn(LocalDate.of(2026, 1, 16))));
Without this, the only way to test expiry is to create data relative to the real current date, which produces tests that pass today and fail on the first of some future month.
Push side effects to the edges. The most powerful structural move is to separate calculation from communication. Pure logic that takes values and returns values needs no doubles, no setup, and no cleanup. Everything that touches the outside world lives in a thin layer around it.
IMPERATIVE SHELL FUNCTIONAL CORE
(thin, few integration tests) (thick, hundreds of fast unit tests)
read rows from the database --> apply pricing rules --> write result
parse the HTTP request --> validate and decide --> render response
read the file --> transform the records --> save the file
^ hard to test, so keep it small ^ trivial to test, so put the logic here
A method like Money total(List<Item> items, Discount d) needs no mocks at all,
because it has no collaborators. Moving logic into that shape eliminates test
complexity rather than managing it.
Do not test private methods. The urge usually means a private method has grown its own responsibility and wants to be a class. Extracting it gives you a public API to test directly, and the original class gets simpler. Reaching for reflection to invoke a private method is a workaround for a design signal.
Interfaces at the boundaries, not everywhere. Introduce an interface where
you genuinely need to substitute an implementation: persistence, messaging, the
clock, external services. Creating an interface for every class, each with one
implementation named FooImpl, adds indirection without adding a seam anyone
uses.
Key Takeaways
- Difficulty testing a class is evidence of a design problem, not a tooling problem.
- A seam is a place behaviour can change without editing the code, and constructor parameters are the cheapest kind.
- Creating collaborators internally or reaching for singletons removes every seam.
- Time and randomness are dependencies, and injecting a
Clockmakes expiry logic deterministic.- Constructors should assign and validate, never perform I/O or registration.
- Pure functions need no test doubles, so push logic into a functional core.
- Keep the side-effecting shell thin, because that is the part requiring integration tests.
- Wanting to test a private method usually means a class is hiding inside another.
- Introduce interfaces at real substitution boundaries, not reflexively for every class.
🧪 Practice
- Take the untestable
OrderServiceabove and refactor it to constructor injection. Write one test asserting the receipt timestamp exactly.- Find a method in your own code calling
LocalDateTime.now()orMath.random(), inject the dependency, and write a deterministic test.- Identify a method that both calculates and writes to a file. Split it into a pure function and a thin writer, then test the pure part with no setup.
- Find a private method you once wanted to test, extract it into its own class, and write tests for the new public API.
- Interview: A colleague wants to use reflection to test a private method. What would you suggest instead, and why? (Hint: what is the private method telling you about the class's responsibilities?)
Test Doubles: Stubs, Fakes, and Mocks
A unit test must isolate the class under test, but real collaborators get in the way. A repository needs a database, an email sender actually sends email, and a payment gateway charges a real card. A test double is a stand-in that lets the test run without them.
The name comes from a stunt double: something that resembles the real thing closely enough for one specific scene, without being it.
The five kinds are worth distinguishing, because the words are used loosely and the distinctions carry real consequences.
| Kind | Behaviour | Example |
|---|---|---|
| Dummy | Passed to satisfy a parameter, never actually used | new Order(items, null) |
| Stub | Returns canned answers, verifies nothing | A repository that always returns one order |
| Spy | A real or partial object that records how it was called | A sender that stores every message |
| Mock | Pre-programmed with expectations, and asserts on them | Verifying that send was called once |
| Fake | A real, working, simplified implementation | An in-memory repository backed by a map |
The deeper distinction is what you verify.
State verification performs an action and asserts on the resulting state. It is the default and the more robust choice, because it says nothing about how the result was produced.
Interaction verification asserts that a collaborator was called. It couples the test to the implementation, so it is justified only when the call is the observable effect: sending an email, publishing an event, charging a card. There is no state to inspect afterwards, so the interaction is the behaviour.
// STATE verification: preferred where possible
@Test
void placedOrderIsRetrievable() {
var repo = new InMemoryOrderRepository(); // a FAKE
var service = new OrderService(repo, noOpEmail(), fixedClock());
service.place(order);
assertEquals(Optional.of(order), repo.findById(order.id())); // assert on outcome
}
// INTERACTION verification: justified, because sending IS the behaviour
@Test
void placingAnOrderNotifiesTheCustomer() {
EmailSender email = mock(EmailSender.class); // a MOCK
var service = new OrderService(new InMemoryOrderRepository(), email, fixedClock());
service.place(order);
verify(email).send(eq("ada@example.com"), contains("confirmed"));
}
Mockito is the standard library for this. The essentials fit in a few lines.
@ExtendWith(MockitoExtension.class) // wires the annotations
class OrderServiceTest {
@Mock OrderRepository repo; // a double, created automatically
@Mock EmailSender email;
@InjectMocks OrderService service; // constructed with the mocks above
@Test
void rejectsUnknownOrder() {
when(repo.findById(42L)).thenReturn(Optional.empty()); // STUBBING
assertThrows(OrderNotFoundException.class, () -> service.confirm(42L));
verify(email, never()).send(any(), any()); // assert something did NOT happen
}
@Test
void storesTheOrderWithATimestamp() {
var captor = ArgumentCaptor.forClass(Order.class); // capture the argument
service.place(order);
verify(repo).save(captor.capture());
assertNotNull(captor.getValue().placedAt()); // assert on what was passed
}
}
when(...).thenReturn(...) stubs, verify(...) checks interactions,
ArgumentCaptor grabs an argument for detailed assertions, and
thenThrow(...) simulates failures that are otherwise hard to provoke.
Four cautions save a great deal of pain.
Do not mock what you do not own. A mock of a third-party client encodes your belief about how that library behaves. If the belief is wrong, the test passes and production fails. Wrap the library in a small interface of your own and mock that instead, then cover the wrapper with a genuine integration test.
Do not mock value objects. Constructing a real Money or Order is simpler,
faster, and more honest than stubbing its getters.
Prefer a fake for repositories. An in-memory implementation is written once, reused everywhere, and exhibits real behaviour such as a saved item being findable afterwards. A stubbed repository can be programmed into states the real one could never reach.
public class InMemoryOrderRepository implements OrderRepository {
private final Map<Long, Order> store = new ConcurrentHashMap<>();
@Override public void save(Order o) { store.put(o.id(), o); }
@Override public Optional<Order> findById(long id) {
return Optional.ofNullable(store.get(id)); // genuinely behaves like a repository
}
}
Watch for over-mocking. A test where every collaborator is a mock and every
assertion is a verify proves only that your code calls the methods you wrote
it to call. It restates the implementation, breaks on every refactor, and
catches almost no bugs. The signal is a test that must be rewritten whenever the
production code is reorganised without changing behaviour.
Key Takeaways
- A test double stands in for a collaborator that is slow, unreliable, or has side effects.
- Dummy, stub, spy, mock, and fake differ in how much behaviour they have and what they verify.
- State verification asserts on outcomes and survives refactoring.
- Interaction verification is justified when the call itself is the observable effect.
- Mockito stubs with
when, checks calls withverify, and inspects arguments withArgumentCaptor.verify(x, never())asserts that something did not happen.- Never mock a third-party type; wrap it in your own interface and mock that.
- Constructing real value objects beats stubbing them.
- An in-memory fake repository is reusable and exhibits genuine behaviour.
- A test made entirely of mocks and verifies restates the implementation and catches little.
🧪 Practice
- Write an in-memory fake for a repository interface and use it in three tests without any mocking library.
- Use Mockito to stub a repository that returns empty, and assert the service throws. Then add a
verifythat no email was sent.- Use
ArgumentCaptorto assert that the object passed tosavehad its timestamp populated.- Take a test that verifies four interactions and rewrite it to assert on resulting state instead. Compare which one survives renaming a private helper.
- Interview: When is
verifythe right assertion rather than checking state? (Hint: what state does sending an email leave behind in your own system?)
Test-Driven Development Workflow
Test-driven development inverts the usual order: the test is written before the code that satisfies it. The common objection is that this seems backwards, and the common misunderstanding is that TDD is a testing technique. It is a design technique whose tests are a by-product.
The reason it works is that writing the test first forces you to be the first consumer of your own API. You experience the awkwardness of a bad signature before you have built anything on top of it, and you cannot write untestable code, because the test already exists.
The cycle has three steps and a strict order.
+---------------------------------------------+
| |
v |
[ RED ] write the smallest failing test |
| it MUST fail, for the expected reason |
v |
[ GREEN ] write the simplest code that passes |
| hardcoding is allowed here |
v |
[ REFACTOR ] improve the design, tests stay green |
| |
+---------------------------------------------+
each loop is minutes, not hours
Two rules make the cycle meaningful rather than ceremonial. The test must actually fail first, because a test that passes before the code exists is testing nothing. And the refactor step is not optional, because it is where the design emerges; skipping it leaves a pile of code written to pass tests.
A worked example. Build a calculator that sums numbers in a string.
Step one, red. Start with the most trivial case.
@Test
void emptyStringIsZero() {
assertEquals(0, StringCalculator.add("")); // does not compile yet: that counts as red
}
Step one, green. The simplest thing that passes. Hardcoding is legitimate.
public static int add(String input) {
return 0; // yes, really
}
Step two, red. A second case forces generalisation. This is called triangulation: one example can be faked, two cannot.
@Test
void singleNumberIsItself() {
assertEquals(5, StringCalculator.add("5")); // fails against the hardcoded 0
}
Step two, green.
public static int add(String input) {
if (input.isEmpty()) return 0;
return Integer.parseInt(input);
}
Step three, red and green. Now commas.
@Test
void sumsCommaSeparatedNumbers() {
assertEquals(6, StringCalculator.add("1,2,3"));
}
public static int add(String input) {
if (input.isEmpty()) return 0;
int total = 0;
for (String part : input.split(",")) {
total += Integer.parseInt(part);
}
return total;
}
Step four, refactor. With three tests green, the implementation can be rewritten freely using Chapter 8's streams, and the tests prove nothing broke.
public static int add(String input) {
if (input.isBlank()) return 0;
return Arrays.stream(input.split(","))
.mapToInt(Integer::parseInt) // refactored with tests as the safety net
.sum();
}
Step five. Add error behaviour the same way, starting from the failing test.
@Test
void rejectsNegativeNumbers() {
var e = assertThrows(IllegalArgumentException.class,
() -> StringCalculator.add("1,-2,3"));
assertTrue(e.getMessage().contains("-2")); // the message is part of the behaviour
}
Notice what happened across those steps: at no point was there a large block of untested code, at no point was the design decided far in advance, and the final refactor was safe because the tests already described the behaviour.
What TDD actually buys you.
| Benefit | Mechanism |
|---|---|
| Testable design by construction | Untestable code cannot be written test-first |
| No untested code paths | Code exists only to satisfy a test |
| Fast feedback | The loop is minutes, so mistakes are local |
| Refactoring confidence | Green tests before and after prove behaviour held |
| A specification that cannot rot | Tests fail when they stop matching the code |
| Smaller units | Large classes are painful to drive test-first |
When TDD is a poor fit, and saying so is not heresy.
- Exploratory work where you do not yet know what you are building. Spike first, throw the spike away, then rebuild test-first.
- Learning an unfamiliar library, where you cannot predict the behaviour you would assert.
- Visual layout and rendering, where the assertion is "does it look right".
- Performance tuning, where the relevant feedback is a benchmark, not a test.
Two misconceptions are worth naming. TDD does not mean writing every test up front; it means one small test at a time. And high test coverage is a consequence of TDD, not its purpose. Practitioners keep doing it because the code comes out better structured, not because the numbers look good.
Key Takeaways
- TDD is a design discipline, and the tests are a by-product.
- The cycle is red, green, refactor, in that order, in loops of minutes.
- The test must genuinely fail first, or it is verifying nothing.
- The simplest passing code is correct at the green step, including hardcoded values.
- Triangulation adds a second example to force real generalisation.
- The refactor step is where design emerges and must not be skipped.
- Writing the test first makes you the first user of your own API.
- Untestable designs cannot be produced test-first, which is the main structural benefit.
- Spike first for exploratory work, then rebuild test-driven.
- Coverage is a side effect of TDD rather than its goal.
🧪 Practice
- Implement the string calculator above from scratch, committing after each green step. Do not write a line of production code before a failing test.
- Extend it test-first to support newlines as separators alongside commas.
- Write a failing test for a bug in existing code, confirm it fails for the right reason, then fix it and watch it pass.
- Implement a
RomanNumeral.toRoman(int)converter test-first, adding one numeral at a time and refactoring whenever duplication appears.- Interview: Why is it important that a new test fails before you write the implementation? (Hint: what would a test that passes immediately have proven about itself?)
<a id="152-build-tools-and-dependencies"></a>
15.2 Build Tools and Dependencies
This subchapter covers how a Java project is laid out, how Maven and Gradle turn that layout into an artifact, and how to survive the dependency graph that any real project accumulates.
Project Structure Conventions
Compiling a single file with javac is easy. A real project has hundreds of
source files, test files that must not ship, configuration that must, and
dependencies that must be on the classpath in the right order. Somebody has to
decide where all of it lives.
Before Maven, every project decided differently, so every newcomer and every tool had to learn a bespoke layout. Maven's contribution was convention over configuration: agree on one standard layout, and the build needs almost no configuration at all. Gradle adopted the same layout, so it is now simply how Java projects look.
my-app/
|-- pom.xml build definition (or build.gradle.kts)
|-- README.md
|-- .gitignore
|-- src/
| |-- main/
| | |-- java/ production sources
| | | `-- com/example/app/OrderService.java
| | `-- resources/ non-code files, PACKAGED INTO the artifact
| | `-- application.properties
| `-- test/
| |-- java/ test sources, never packaged
| | `-- com/example/app/OrderServiceTest.java
| `-- resources/ fixtures used only by tests
| `-- sample-orders.json
`-- target/ Maven output (Gradle uses build/)
|-- classes/ compiled main classes
|-- test-classes/ compiled test classes
`-- my-app-1.0.0.jar the packaged artifact
Four properties of this layout carry real consequences.
Test packages mirror main packages. A test for
com.example.app.OrderService lives at
src/test/java/com/example/app/OrderServiceTest.java. Because it is in the same
package, it can reach package-private members, which is the intended way to test
internals you do not want to make public.
Resources are classpath entries, not files. Anything under
src/main/resources is copied to the root of the artifact, so
src/main/resources/config.properties becomes /config.properties on the
classpath. This is where a very common bug lives.
// WRONG: works when run from the IDE, fails once packaged.
// Inside a jar there is no such path on the filesystem.
try (InputStream in = new FileInputStream("src/main/resources/config.properties")) { }
// RIGHT: ask the classloader, which works from a directory or from inside a jar
try (InputStream in = App.class.getResourceAsStream("/config.properties")) {
Properties props = new Properties();
props.load(in);
}
The leading slash means "from the classpath root". Without it, the lookup is relative to the class's own package.
Build output is generated and disposable. target/ and build/ are
recreated from source on every build and must never be committed. A .gitignore
that misses them is the most common first mistake in a new repository.
Package names follow reverse domain order and must match the directory
structure exactly: com.example.app.billing lives in
com/example/app/billing. Package names are lowercase by convention, and the
compiler enforces the directory correspondence.
For anything beyond a single deliverable, projects split into modules, each with the same internal layout.
my-system/
|-- pom.xml the parent: shared config, module list, versions
|-- my-system-domain/ pure model and business rules, no frameworks
| |-- pom.xml
| `-- src/main/java/...
|-- my-system-persistence/ depends on domain
| `-- ...
`-- my-system-api/ depends on domain and persistence; the deployable
`-- ...
The dependency arrows between modules are enforced by the build, which makes architectural boundaries real rather than aspirational: the persistence module simply cannot import from the API module if the build does not allow it.
The payoff for accepting the convention is that every IDE, continuous integration system, static analysis tool, and code coverage agent already knows where everything is. Deviating means configuring all of them.
Key Takeaways
- Convention over configuration means a standard layout needs almost no build configuration.
- Production code lives in
src/main/javaand tests insrc/test/java.- Test packages mirror main packages, which grants access to package-private members.
- Files under
src/main/resourcesare packaged into the artifact and read from the classpath.- Reading a resource with
FileInputStreamworks in an IDE and fails inside a jar.getResourceAsStream("/name")reads from the classpath root, with or without a leading slash changing the meaning.target/andbuild/are generated output and must be excluded from version control.- Package names are reverse-domain, lowercase, and must match the directory structure.
- Multi-module projects make architectural boundaries enforceable by the build.
🧪 Practice
- Create the standard layout by hand for a project with one class and one test, and compile it without a build tool to see what the tool automates.
- Put a properties file in
src/main/resources, load it withgetResourceAsStream, package the project, and run it from the jar.- Change that code to
new FileInputStream(...)and run it from the jar. Record the exact exception.- Write a test that exercises a package-private method, and confirm it compiles only when the test is in the matching package.
- Interview: Why are
src/main/resourcesfiles read through the classloader rather than as file paths? (Hint: what does the deployed artifact actually look like on disk?)
Maven Lifecycle and POM Basics
A build is a sequence of steps: resolve dependencies, compile, run tests, package. Maven's model is that the sequence is fixed and you plug behaviour into it. Learning Maven is mostly learning that sequence.
Three concepts hold the model together.
| Concept | What it is |
|---|---|
| Coordinates | groupId:artifactId:version, the global address of any artifact |
| POM | pom.xml, the Project Object Model: a declarative build description |
| Lifecycle | An ordered list of phases; plugins bind goals to phases |
Coordinates are how the whole ecosystem names things. The triple
org.junit.jupiter:junit-jupiter:5.10.2 identifies exactly one artifact in the
world, which is what makes automatic dependency resolution possible.
The lifecycle is an ordered list of phases, and the rule that explains most Maven behaviour is that running a phase runs every phase before it.
validate -> compile -> test -> package -> verify -> install -> deploy
mvn test runs validate, compile, test
mvn package runs all of the above, then package
mvn install runs all of the above, then install
^ you never need to chain them yourself
| Phase | What happens |
|---|---|
validate | Checks the project is coherent and information is available |
compile | Compiles src/main/java into target/classes |
test | Runs unit tests with Surefire; failures stop the build |
package | Assembles target/my-app-1.0.0.jar |
verify | Runs integration tests with Failsafe, plus quality checks |
install | Copies the artifact into the local repository at ~/.m2/repository |
deploy | Uploads the artifact to a shared remote repository |
clean belongs to a separate lifecycle, which is why the common incantation is
mvn clean install: two lifecycles, run in order.
A POM, annotated.
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<!-- This project's own coordinates -->
<groupId>com.example</groupId>
<artifactId>my-app</artifactId>
<version>1.0.0-SNAPSHOT</version> <!-- SNAPSHOT = under development, re-resolved -->
<packaging>jar</packaging>
<properties>
<!-- Declared once, referenced below as ${junit.version} -->
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.version>5.10.2</junit.version>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope> <!-- not on the production classpath -->
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.0</version> <!-- default scope: compile -->
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version> <!-- always pin plugin versions -->
</plugin>
</plugins>
</build>
</project>
Scopes decide which classpath a dependency joins, and getting them right keeps artifacts small and deployments correct.
| Scope | Compile main | Compile tests | Packaged | Typical use |
|---|---|---|---|---|
compile | Yes | Yes | Yes | The default; ordinary libraries |
provided | Yes | Yes | No | Servlet API, supplied by the container |
runtime | No | Yes | Yes | JDBC drivers, logging backends |
test | No | Yes | No | JUnit, Mockito, AssertJ |
import | n/a | n/a | n/a | Pulls a BOM into dependency management |
A dependency in runtime scope is a useful discipline: a JDBC driver is needed
to run but must not be importable at compile time, which prevents accidental
coupling to a specific database.
Repositories are where artifacts come from and go to.
your build --(1)--> ~/.m2/repository local cache; checked first
--(2)--> Maven Central the public default
--(3)--> company Nexus/Artifactory private or mirrored
mvn install places your artifact in the local cache so other projects on the
same machine can depend on it, which is the usual way to test a library change
before publishing.
Parent POMs hoist shared configuration. A parent declares versions in
<dependencyManagement>, and children inherit them without repeating a version
number, which is the main defence against the same library appearing at three
versions across a multi-module project.
Common commands worth memorising:
mvn clean install -DskipTests # rebuild everything, skip tests (compile them though)
mvn test # just the unit tests
mvn verify # unit plus integration tests and checks
mvn dependency:tree # what is actually on the classpath, and why
mvn help:effective-pom # the POM after inheritance and interpolation
mvn versions:display-dependency-updates # what is out of date
Key Takeaways
- Coordinates are
groupId:artifactId:versionand uniquely name any artifact.- The POM is declarative: it describes the project rather than scripting the build.
- Running a phase runs every preceding phase, so
mvn packagealso compiles and tests.cleanis a separate lifecycle, hencemvn clean install.SNAPSHOTversions are re-resolved and belong only in development.- Scope decides which classpaths a dependency joins and whether it is packaged.
provideddependencies compile but are not shipped, andruntimeones ship but cannot be imported.installpublishes to the local repository so sibling projects can resolve it.- Parent POMs centralise versions through
dependencyManagement.- Pin plugin versions, or a build reproducible today breaks tomorrow.
🧪 Practice
- Create a Maven project from scratch, add JUnit with
testscope, and runmvn test. Then inspect what appeared intarget/.- Run
mvn compile, thenmvn package, and list which phases each executed.- Move a dependency from
compiletoprovided, package the project, and inspect the jar to see what changed.- Run
mvn help:effective-pomon a project with a parent and identify three settings that were inherited.- Interview: Why does
mvn packagerun your tests when you only asked it to package? (Hint: what is the relationship between a phase and the ones before it?)
Gradle Build Scripts
Maven's declarative XML is predictable and verbose. The moment a build needs genuine logic, such as generating sources or varying behaviour per environment, XML fights back and you end up configuring a plugin to do something a single line of code would express.
Gradle takes the opposite position: the build script is a program, written in Kotlin or Groovy, and the build is a graph of tasks rather than a fixed lifecycle. The cost is that a Gradle build can be as complicated as any other program; the benefit is expressiveness and speed.
// build.gradle.kts, the Kotlin DSL (preferred: it gives IDE completion)
plugins {
java // adds compile, test, jar tasks
application // adds a run task
}
group = "com.example"
version = "1.0.0"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21) // downloads a JDK if needed
}
}
repositories {
mavenCentral() // where to resolve dependencies from
}
dependencies {
implementation("com.fasterxml.jackson.core:jackson-databind:2.17.0")
runtimeOnly("org.postgresql:postgresql:42.7.3")
testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
application {
mainClass = "com.example.app.Main"
}
tasks.test {
useJUnitPlatform() // required for JUnit 5
testLogging { events("passed", "failed", "skipped") }
}
Configurations are Gradle's version of scopes, and one distinction is a genuine design tool rather than a mere label.
| Configuration | On your compile classpath | Exposed to consumers | Packaged |
|---|---|---|---|
implementation | Yes | No | Yes |
api | Yes | Yes | Yes |
compileOnly | Yes | No | No |
runtimeOnly | No | No | Yes |
testImplementation | Test code only | No | No |
The implementation versus api distinction controls what leaks.
app ---depends on---> library ---depends on---> jackson
library uses "implementation(jackson)":
app CANNOT import jackson classes.
library can upgrade or replace jackson without touching app.
library uses "api(jackson)":
app CAN import jackson classes.
jackson is now part of library's public contract, forever.
Use api only when a dependency's types genuinely appear in your public
signatures. Defaulting to implementation keeps compile classpaths small, which
also makes builds faster.
Tasks form a directed graph, and Gradle runs only what is needed. Each task declares its inputs and outputs, so a task whose inputs have not changed is skipped as up to date.
compileJava --> processResources --> classes --> jar --> assemble
|
+--> compileTestJava --> test --> check --> build
gradle build runs the whole graph
gradle test runs only the path leading to test
second run most tasks report UP-TO-DATE and take zero time
tasks.register("printVersion") {
doLast { // the action runs at execution time
println("Building version $version")
}
}
tasks.named("jar") {
dependsOn("printVersion") // wire it into the graph
}
The Gradle Wrapper is not optional in practice. Committing gradlew,
gradlew.bat, and gradle/wrapper/ pins the exact Gradle version for everyone,
so the build does not depend on what each developer happens to have installed.
Always invoke ./gradlew build rather than a globally installed gradle.
Choosing between the two.
| Aspect | Maven | Gradle |
|---|---|---|
| Build definition | Declarative XML | Kotlin or Groovy code |
| Learning curve | Gentle; every project looks alike | Steeper; builds can diverge widely |
| Custom logic | Requires writing or finding a plugin | Inline in the script |
| Incremental builds | Limited | Strong, with input and output tracking |
| Build caching | No | Local and remote |
| Speed on large builds | Slower | Substantially faster |
| Predictability | High; hard to make strange | Lower; a build can become a program to maintain |
| Common in | Enterprise Java, Spring projects | Android (mandatory), large polyglot builds |
Neither is wrong. Maven's rigidity is a feature on a team that wants every project to behave identically; Gradle's flexibility pays off on large builds where minutes of compile time matter.
Key Takeaways
- A Gradle build script is a Kotlin or Groovy program, not a declarative document.
- Gradle uses the same source layout as Maven but outputs to
build/.implementationhides a dependency from consumers, whileapimakes it part of your public contract.- Defaulting to
implementationkeeps compile classpaths small and builds fast.- Tasks form a graph, and Gradle skips any task whose inputs are unchanged.
useJUnitPlatform()is required for JUnit 5 to run at all.- Toolchains let the build declare and fetch the exact JDK version it needs.
- Always run
./gradlewso everyone uses the version pinned in the repository.- Maven favours uniformity and predictability; Gradle favours flexibility and speed.
🧪 Practice
- Create a Gradle project with the Kotlin DSL, add JUnit 5, and run
./gradlew test. Run it twice and compare the second run's output.- Register a custom task that prints the project version, and wire it to run before
jar.- Build a two-module project where the library declares a dependency with
implementation, then try to import that dependency from the consumer. Switch it toapiand try again.- Run
./gradlew build --scanor--profileand identify the slowest task.- Interview: What does declaring a dependency as
apirather thanimplementationcommit you to? (Hint: what happens to every consumer when you later want to replace that library?)
Dependency Management and Transitive Conflicts
You declare five dependencies and your build downloads eighty. The extras are transitive dependencies: the libraries your libraries need, and the ones those need in turn. Build tools resolve them automatically, which is enormously convenient right up until two of them disagree.
my-app
|-- jackson-databind 2.17.0
| |-- jackson-core 2.17.0 <- transitive, you never asked for it
| `-- jackson-annotations 2.17.0
`-- some-library 3.1.0
`-- jackson-databind 2.12.0 <- and it wants a DIFFERENT version
`-- jackson-core 2.12.0
Only one version can win. A classpath maps each fully qualified class name
to exactly one class file, so the build must choose a single version of
jackson-core. This is the diamond dependency problem, and its defining
characteristic is when it fails.
compile time everything compiles: the chosen version has the classes
the compiler happened to need
|
v
run time NoSuchMethodError, NoClassDefFoundError, AbstractMethodError
a method that existed in 2.17 is missing from the 2.12 on the path
A NoSuchMethodError at runtime in code that compiled cleanly is almost always
a version conflict. The compiler saw one version and the JVM loaded another.
The two tools resolve conflicts differently, and both rules surprise people.
| Tool | Rule | Consequence |
|---|---|---|
| Maven | Nearest definition wins | The shortest path from the root, with ties broken by declaration order. A lower version can win. |
| Gradle | Highest version wins | Usually what you want, but silently upgrades libraries you never chose. |
Under Maven's rule, the diamond above resolves to 2.12.0 if some-library is
declared first and both paths are the same length, which is exactly the
counter-intuitive case that produces runtime failures.
Diagnose before you fix. Both tools will show you the resolved graph and the reason for each choice.
mvn dependency:tree -Dverbose -Dincludes=com.fasterxml.jackson.core
./gradlew dependencies --configuration runtimeClasspath
./gradlew dependencyInsight --dependency jackson-core # why THIS version?
The output marks omitted versions and the conflict that caused each omission, which turns guesswork into reading.
Four ways to take control, roughly in order of preference.
Declare the version directly. Under Maven's nearest-wins rule, a direct dependency is always at distance one, so declaring it pins it.
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.17.0</version> <!-- direct: wins over any transitive version -->
</dependency>
Use a bill of materials. A BOM is a POM listing consistent versions for a whole family of artifacts. Importing it means you never specify those versions again and they can never disagree with each other.
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson</groupId>
<artifactId>jackson-bom</artifactId>
<version>2.17.0</version>
<type>pom</type>
<scope>import</scope> <!-- imports the whole version set -->
</dependency>
</dependencies>
</dependencyManagement>
Gradle expresses the same idea as implementation(platform("...")). Spring Boot
distributes one of these, which is why Boot projects declare dependencies
without versions.
Exclude an unwanted transitive dependency. This is the tool for genuine duplicates, such as two competing logging backends.
<dependency>
<groupId>com.example</groupId>
<artifactId>some-library</artifactId>
<version>3.1.0</version>
<exclusions>
<exclusion>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId> <!-- replaced by a bridge -->
</exclusion>
</exclusions>
</dependency>
Constrain versions in Gradle without adding a dependency you do not otherwise need.
dependencies {
constraints {
implementation("com.fasterxml.jackson.core:jackson-core:2.17.0") {
because("2.12 lacks the streaming API we rely on") // documents the reason
}
}
}
Semantic versioning is the contract that makes any of this tractable.
| Change | Version part | Means |
|---|---|---|
2.17.0 -> 3.0.0 | Major | Breaking changes; expect to edit code |
2.17.0 -> 2.18.0 | Minor | New features, backward compatible |
2.17.0 -> 2.17.1 | Patch | Bug fixes only |
Treat it as a stated intention rather than a guarantee. Libraries break compatibility in minor releases more often than anyone would like, which is why upgrades still need a test suite behind them.
Reproducibility requires pinned versions. Version ranges and SNAPSHOT
dependencies mean the same source can produce different artifacts on different
days, which makes a failure impossible to reproduce and a release impossible to
audit. Pin exact versions for anything you ship.
Every dependency is attack surface. A 2021 vulnerability in Log4j affected an enormous number of applications, most of which had never declared Log4j at all: it arrived transitively, several levels deep, in something else. The lessons are practical.
| Practice | Why |
|---|---|
| Scan dependencies continuously | OWASP Dependency-Check, Dependabot, Snyk, gradle dependencyCheckAnalyze |
| Know your full graph | A vulnerability in a library you never chose is still yours |
| Produce an SBOM | You cannot respond to an advisory without knowing what you ship |
| Prefer fewer dependencies | Every one is code you did not review and must now patch |
| Upgrade regularly, in small steps | Long gaps turn routine patches into risky migrations |
The last row matters more than it sounds. A project that upgrades monthly applies small, testable changes. A project that upgrades every three years faces a wall of breaking changes at the worst possible moment, usually during a security incident.
Key Takeaways
- Transitive dependencies arrive automatically, so a few declarations become a large graph.
- Only one version of a class can be on the classpath, which forces a choice.
- Version conflicts compile cleanly and fail at runtime with
NoSuchMethodErrororNoClassDefFoundError.- Maven picks the nearest definition, which can select a lower version.
- Gradle picks the highest version, silently upgrading libraries you did not choose.
dependency:treeanddependencyInsightshow the resolved graph and the reason for each choice.- Declaring a dependency directly pins it under Maven's nearest-wins rule.
- A BOM keeps a family of artifacts at mutually consistent versions.
- Version ranges and
SNAPSHOTdependencies destroy build reproducibility.- Transitive dependencies are attack surface, so scan continuously and upgrade in small steps.
🧪 Practice
- Run
mvn dependency:treeor./gradlew dependencieson a real project and count direct versus total dependencies.- Deliberately create a conflict by depending on two libraries needing different versions of a third, then find the resolved version and the rule that chose it.
- Exclude a transitive dependency, rebuild, and confirm with the dependency tree that it is gone.
- Import a BOM and remove the explicit versions it now manages. Verify the resolved versions are unchanged.
- Run a vulnerability scan against a project with an older dependency set and trace one finding back to the direct dependency that introduced it.
- Interview: A method that compiles fine throws
NoSuchMethodErrorin production. What is your first hypothesis and how would you confirm it? (Hint: what did the compiler see that the JVM did not?)
Packaging JARs and Executables
Compilation produces a directory of loose .class files. That is fine for the
build machine and useless for shipping. Packaging turns a build into a single
artifact that can be copied, versioned, and run.
A JAR is a ZIP file with a manifest. There is no magic beyond that.
my-app-1.0.0.jar
|-- META-INF/
| |-- MANIFEST.MF metadata: main class, version, classpath
| `-- services/ ServiceLoader registrations
|-- com/example/app/
| |-- Main.class
| `-- OrderService.class
`-- config.properties whatever was in src/main/resources
unzip -l my-app.jar # a jar really is just a zip
cat META-INF/MANIFEST.MF
The manifest makes a jar executable. Two entries matter.
Manifest-Version: 1.0
Main-Class: com.example.app.Main <- makes "java -jar" work
Class-Path: lib/jackson-databind.jar lib/jackson-core.jar
Implementation-Version: 1.0.0 <- readable at runtime for diagnostics
jar --create --file app.jar --main-class com.example.app.Main -C target/classes .
java -jar app.jar
The trap that catches everyone: java -jar ignores -cp entirely. When
you launch a jar, the classpath comes from the manifest's Class-Path and
nothing else.
java -cp "app.jar:lib/*" com.example.app.Main # works: explicit classpath and class
java -jar app.jar -cp "lib/*" # the -cp is silently ignored
That is why a plain jar with dependencies almost never runs as-is, and why the packaging strategies below exist.
| Strategy | How it works | Trade-off |
|---|---|---|
Thin jar plus lib/ | Class-Path in the manifest points at a directory | Small artifact, but you must ship the directory intact |
| Fat (uber) jar | Every dependency unpacked and merged into one jar | One file to deploy; merge hazards below |
| Spring Boot executable jar | Dependencies kept as nested jars with a custom loader | No merge hazards; Boot-specific layout |
| WAR | Deployed into a servlet container | Container-managed, largely legacy |
jlink image | A trimmed JVM containing only required modules | Small self-contained runtime; needs modular dependencies |
jpackage installer | Platform installer: .msi, .deb, .dmg | Desktop distribution; one build per platform |
Fat jars are the common default, produced by the Maven Shade plugin or the Gradle Shadow plugin.
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.2</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<transformers>
<!-- merges META-INF/services entries instead of overwriting them -->
<transformer implementation=
"org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
<transformer implementation=
"org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.example.app.Main</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
Those transformers are not optional decoration. Flattening many jars into one creates three genuine hazards.
Duplicate resource files silently overwrite. If five dependencies each contain
META-INF/services/java.sql.Driver, a naive merge keeps one and the
ServiceLoader mechanism from Chapter 10 quietly stops finding the others. This
is why JDBC drivers and logging backends disappear from fat jars.
Signed jars break. A signature covers the original archive's contents, so repackaging invalidates it and the JVM refuses to load the classes.
Duplicate classes shadow each other. Two versions of the same class in one archive means whichever the packager wrote first wins, with no warning.
Spring Boot sidesteps all three by nesting dependency jars intact inside the artifact and shipping a launcher that knows how to read them, which is why a Boot jar's layout looks unusual.
my-app.jar
|-- META-INF/MANIFEST.MF Main-Class: org.springframework.boot.loader.JarLauncher
|-- org/springframework/boot/loader/... the launcher
`-- BOOT-INF/
|-- classes/ your code
`-- lib/ dependencies, as intact jars
For self-contained distribution, jlink builds a runtime image containing
only the modules your application uses, typically tens of megabytes rather than
a full JDK, and jpackage wraps an application into a native installer.
jlink --add-modules java.base,java.sql --output runtime --strip-debug --no-man-pages
jpackage --name MyApp --input target/ --main-jar app.jar --type deb
Two finishing touches separate a professional artifact from a working one. Record the version and build metadata in the manifest so a running instance can report exactly what it is, and enable reproducible builds so that the same source produces byte-identical output, since jars otherwise embed timestamps.
<properties>
<project.build.outputTimestamp>2026-01-15T00:00:00Z</project.build.outputTimestamp>
</properties>
Key Takeaways
- A jar is a ZIP archive plus a
META-INF/MANIFEST.MF.Main-Classin the manifest is what makesjava -jarwork.java -jarignores-cpcompletely, taking the classpath from the manifest.- A fat jar merges every dependency into one archive for single-file deployment.
- Merging overwrites duplicate resources, which silently breaks
ServiceLoaderregistrations.- Shade and Shadow transformers exist to merge those service files correctly.
- Repackaging invalidates signed jars, and duplicate classes shadow one another without warning.
- Spring Boot nests intact dependency jars with a custom loader, avoiding merge hazards.
jlinkproduces a trimmed runtime andjpackagea native installer.- Embedding version metadata and fixing build timestamps make artifacts diagnosable and reproducible.
🧪 Practice
- Build a plain jar with a
Main-Class, run it withjava -jar, then unzip it and read the manifest.- Add a dependency, rebuild the plain jar, and run it. Explain the
NoClassDefFoundError, then fix it two ways: withClass-Pathand with a fat jar.- Build a fat jar from a project using a JDBC driver, and check whether
META-INF/servicessurvived the merge.- Compare the size of a plain jar, a fat jar, and a
jlinkimage for the same application.- Interview: Why does adding
-cp lib/*to ajava -jarcommand have no effect? (Hint: where does the JVM look for the classpath in that mode?)
<a id="153-debugging-and-code-quality"></a>
15.3 Debugging and Code Quality
This subchapter covers the tools for understanding code that is misbehaving and for preventing the next problem: debuggers, logging, automated analysis, formatting, profiling, and documentation.
Interactive Debugging and Breakpoints
The universal first debugging technique is to add a print statement, rebuild, and run again. It works, and it is slow: each cycle costs a rebuild, and you only ever see the values you thought to print in advance.
A debugger removes both limits. It suspends the program mid-execution and lets you inspect everything in scope. If printing is taking photographs at positions you guessed beforehand, a debugger is pausing the film and walking around the set.
Breakpoints come in several kinds, and knowing the less obvious ones is what separates casual from effective debugging.
| Kind | Stops when | Use it for |
|---|---|---|
| Line breakpoint | Execution reaches a line | The default |
| Conditional breakpoint | A line is reached and an expression is true | The one bad iteration out of 10,000 |
| Exception breakpoint | An exception is thrown, even if caught | Finding silently swallowed exceptions |
| Field watchpoint | A field is read or written | Answering "what changed this value?" |
| Method breakpoint | Entry or exit of a method | Interfaces with many implementations |
| Logging breakpoint | Never suspends; prints an expression and continues | Printing without editing or rebuilding |
The conditional breakpoint is the workhorse. Stepping through a loop a thousand times to reach the failing case is wasted effort when the debugger can do it.
for (Order order : orders) { // set a breakpoint here with the condition:
process(order); // order.getId() == 4711
} // it now stops only on the one that fails
The field watchpoint answers a question that is otherwise genuinely hard: some field holds an unexpected value and nothing obvious assigned it. A watchpoint stops at the assignment and shows you the stack that led there.
The logging breakpoint deserves more use than it gets. It gives you print-style debugging with no source changes, no rebuild, and nothing to accidentally commit.
Stepping controls navigate once you are stopped.
| Action | Effect |
|---|---|
| Step over | Execute the current line fully, including any calls |
| Step into | Enter the method being called on this line |
| Step out | Finish the current method and stop at the caller |
| Run to cursor | Continue until a chosen line, without setting a breakpoint |
| Drop frame | Discard the current frame and re-execute the call from its start |
Drop frame is the one people do not know exists. Stepping one line too far normally means restarting the whole session; dropping the frame rewinds to the method's entry instead. Side effects already performed are not undone, so it suits pure computation better than I/O.
The call stack is the most under-used panel. Every frame has its own local variables, and selecting a caller shows the state that produced the arguments you are looking at.
FRAMES VARIABLES (for the selected frame)
> OrderService.validate(Order) <-- order = Order{id=4711, total=null}
OrderService.place(Order) this = OrderService@3f2a
OrderController.submit(Request) request = {"customer":"ada", ...}
... framework frames ...
^ click any frame to inspect the state at that level
The "evaluate expression" feature completes the picture: you can call methods, construct objects, and test a fix hypothesis without changing code.
Remote debugging attaches to an already-running JVM through the Java Debug Wire Protocol.
java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 -jar app.jar
# ^socket ^JVM listens ^do not wait ^port
Setting suspend=y makes the JVM wait for a debugger before running a line,
which is how you debug something that fails during startup.
This is a serious security boundary. A JDWP port is unauthenticated remote code execution. Never expose it publicly, never leave it enabled in production, and bind it to localhost with an SSH tunnel if you must use it against a remote host.
Debugging concurrent code needs care, because a breakpoint changes timing. Suspending all threads at a breakpoint can hide the race you are hunting, and suspending only one thread can let others race ahead into states that never occur in production. Most IDEs let you choose the suspend policy per breakpoint, and conditioning on a thread name is often the cleanest approach.
The general warning is the observer effect: a bug that disappears under a debugger is usually a timing-dependent bug, and that disappearance is itself diagnostic information.
Hot swap lets you edit a method body and continue without restarting. It cannot add or remove methods or fields, since that would change the class shape, but for tuning a condition or a calculation it removes the restart cycle entirely.
Finally, know when not to reach for the debugger. If you can reproduce the bug, writing a failing test is usually faster and leaves something permanent behind. A debugger is for understanding unfamiliar behaviour; a test is for preventing its return. The two are complements, and the best sequence is often to debug until you understand, then write the test before fixing.
Key Takeaways
- A debugger inspects everything in scope, unlike prints that show only what you anticipated.
- Conditional breakpoints skip straight to the failing iteration.
- Exception breakpoints catch exceptions that are being silently swallowed.
- Field watchpoints reveal what assigned an unexpected value, and from where.
- Logging breakpoints print without editing, rebuilding, or risking a commit.
- Drop frame re-runs the current method, but does not undo side effects already performed.
- Every stack frame has its own variables, so selecting a caller shows how you got here.
- JDWP ports are unauthenticated remote code execution and must never be exposed.
- Breakpoints alter timing, so a bug that vanishes under a debugger is probably a race.
- Debug to understand, then write a failing test before fixing.
🧪 Practice
- Set a conditional breakpoint inside a loop over a thousand items so that it triggers only on one specific element.
- Add an exception breakpoint for
NumberFormatExceptionin code that catches and ignores it, and find where it originates.- Use a field watchpoint to discover which method sets a field to
null.- Attach a remote debugger to a locally running JVM started with JDWP, then explain why
address=*:5005would be dangerous on a public server.- Use "evaluate expression" to test a fix without editing code, then apply it with hot swap and continue.
- Interview: A bug reproduces reliably in production but never under the debugger. What does that tell you? (Hint: what does suspending a thread do to the interleaving of others?)
Logging Frameworks and Levels
A debugger requires that you be present while the code runs. Production code runs at three in the morning, on a machine you cannot pause, for a user you cannot ask. Logs are the only account of what happened, and they are written before anyone knows which moment will matter.
System.out.println is not logging. It has no severity, no timestamp, no
source location, no thread name, no routing, and no way to turn off without
editing code. It cannot be sampled, filtered, or shipped anywhere, and it writes
synchronously to a stream that may be a slow pipe.
Java's ecosystem separates the interface from the implementation, which is worth understanding because it explains the confusing number of logging artifacts on a typical classpath.
your code and libraries
|
v
SLF4J API <- the FACADE you compile against
|
v
a binding <- chosen by the APPLICATION at deployment time
|
+----+----+-------------+
v v v
Logback Log4j 2 java.util.logging
The reason for the split is that a library must not impose a logging backend on the application embedding it. Libraries depend only on the facade; the application picks the implementation. A project that pulls in three libraries with three different loggers uses bridge artifacts to funnel them all into one.
Levels express who should care, and the most common mistake is choosing them from the author's perspective rather than the reader's.
| Level | Meaning | Volume in production |
|---|---|---|
ERROR | Something failed and needs human attention | Rare; each one is a signal |
WARN | Unexpected but handled; may matter if it recurs | Occasional |
INFO | Significant lifecycle events: started, stopped, connected | Low and steady |
DEBUG | Diagnostic detail for investigating a problem | Off by default |
TRACE | Very fine-grained flow, usually per-iteration | Almost always off |
The practical test for ERROR is whether a human should act on it. A handled,
expected condition such as a validation failure is not an error just because the
code took the unhappy path, and logging it as one trains people to ignore
errors.
Parameterized logging is not a style preference. String concatenation builds the message whether or not the level is enabled.
// BAD: the concatenation and every toString() run even when DEBUG is off
log.debug("Processing order " + order.getId() + " for " + customer.getName());
// GOOD: arguments are formatted only if DEBUG is actually enabled
log.debug("Processing order {} for {}", order.getId(), customer.getName());
// For a genuinely expensive argument, guard explicitly
if (log.isDebugEnabled()) {
log.debug("state dump: {}", buildExpensiveDiagnosticReport());
}
In a hot loop, the first form costs allocations and string building on every iteration for output nobody will ever see.
Log exceptions by passing the throwable, never its message.
// BAD: discards the stack trace, which is the only useful part
log.error("Failed to place order " + id + ": " + e.getMessage());
// GOOD: the throwable goes LAST, with no placeholder, and the trace is printed
log.error("Failed to place order {}", id, e);
Equally, avoid log-and-throw. Logging an exception and then rethrowing it means it gets logged again by whoever handles it, producing duplicate stack traces that make an incident harder to read. Log where you handle, throw where you do not.
Context is what makes logs usable in a concurrent server. With many requests interleaving, as Chapter 12's servers do, individual lines are meaningless without knowing which request they belong to. The Mapped Diagnostic Context attaches key-value pairs to every log line from the current thread.
try (MDC.MDCCloseable ignored = MDC.putCloseable("requestId", request.id())) {
log.info("processing"); // every line inside now carries requestId
service.handle(request); // including lines from deep inside the call
} // removed automatically, which matters on pooled threads
Forgetting to remove an MDC entry on a pooled thread leaks it into the next request's logs, which is why the closeable form exists.
Configuration lives outside the code, so levels can change without a rebuild.
<!-- logback.xml -->
<configuration>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/app.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory> <!-- bounded: disks fill up -->
</rollingPolicy>
<encoder>
<pattern>%d{ISO8601} [%thread] %-5level %logger{36} %X{requestId} - %msg%n</pattern>
</encoder>
</appender>
<logger name="com.example.app.pricing" level="DEBUG"/> <!-- one package, verbose -->
<root level="INFO">
<appender-ref ref="FILE"/>
</root>
</configuration>
For systems whose logs are collected centrally, emitting JSON rather than text makes every field queryable instead of requiring fragile pattern matching.
Two safety rules. Never log secrets: passwords, tokens, keys, card numbers, or personal data that regulation covers. Logs are copied, shipped, and retained far more widely than the systems they describe. And treat logged values as untrusted input, which is the lesson of the 2021 Log4Shell vulnerability, where a logging library interpolated attacker-supplied text and executed a lookup embedded in it, turning a log statement into remote code execution.
Key Takeaways
- Logs are the only account of a production system you cannot pause.
System.outhas no levels, timestamps, context, routing, or off switch.- SLF4J is a facade so that libraries do not force a backend on applications.
- Levels describe who should act, and
ERRORshould mean a human is needed.- Placeholder logging avoids building messages for disabled levels.
- Pass the exception as the last argument, or you lose the stack trace.
- Avoid log-and-throw, which duplicates traces during incidents.
- MDC attaches request context to every line, and the closeable form prevents leaks on pooled threads.
- Configuration belongs outside the code so levels change without a rebuild.
- Never log secrets, and treat logged values as attacker-controlled input.
🧪 Practice
- Replace every
System.out.printlnin a small project with SLF4J calls at appropriate levels, then change the level in configuration without recompiling.- Write a loop that logs at
DEBUGwith string concatenation and again with placeholders, withDEBUGdisabled, and measure the difference.- Log an exception both by concatenating
getMessage()and by passing the throwable. Compare the output.- Add an MDC request identifier in a multithreaded server and confirm that interleaved lines can be grouped by request.
- Configure a rolling file appender with a thirty-day history and verify the rollover naming.
- Interview: Why is
log.debug("x=" + x)a problem whenDEBUGis disabled? (Hint: what has already happened before the logging method is even called?)
Static Analysis Tools
Tests tell you whether the code does what you checked. Static analysis tells you whether the code contains patterns that are known to be wrong, without running it at all. The two are complementary: tests find the bugs you thought of, static analysis finds entire categories of defect across every line you wrote, including the ones nobody tests.
The idea is that many bugs have a shape. An equals without a hashCode, a
result that is computed and discarded, a string compared with ==: none of
these require understanding intent to flag, only pattern recognition.
public class Money {
@Override public boolean equals(Object o) { ... }
// SpotBugs: HE_EQUALS_NO_HASHCODE - equals without hashCode breaks HashMap
}
String trimmed = name.trim();
name.trim(); // RV_RETURN_VALUE_IGNORED: String is immutable,
// this line does literally nothing
if (config.get("mode") == "fast") // ES_COMPARING_STRINGS_WITH_EQ: reference comparison
Integer a = 1000, b = 1000;
if (a == b) { ... } // compares references outside the cache range
Every one of those compiles cleanly and may pass tests that never exercise the path.
The tool landscape divides by what each looks for.
| Tool | Analyses | Finds |
|---|---|---|
| SpotBugs | Compiled bytecode | Bug patterns: null dereference, ignored results, bad equals |
| Error Prone | During compilation | Bug patterns, as compiler errors; Google's |
| NullAway | During compilation | Nullness contract violations |
| PMD | Source | Dead code, complexity, questionable constructs |
| Checkstyle | Source | Naming, imports, formatting, required Javadoc |
| OWASP Dependency-Check | The dependency graph | Dependencies with published vulnerabilities |
| SonarQube | Aggregates the above | Trends, quality gates, duplication, coverage |
Error Prone is distinctive because it runs as part of javac and can fail the
compilation, which means a whole class of mistakes never reaches a commit.
Wire it into the build so it is not optional.
<plugin>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-maven-plugin</artifactId>
<version>4.8.3.1</version>
<configuration>
<effort>Max</effort>
<threshold>Medium</threshold> <!-- below this, do not report -->
<failOnError>true</failOnError> <!-- break the build on findings -->
</configuration>
<executions>
<execution>
<phase>verify</phase> <!-- runs during mvn verify -->
<goals><goal>check</goal></goals>
</execution>
</executions>
</plugin>
Adoption strategy matters more than tool choice. Enabling every rule on an existing codebase produces thousands of findings, which everyone learns to ignore, and the build gets a permanent exemption. The approach that works is a ratchet: record the current findings as a baseline, fail the build only on new ones, and reduce the baseline opportunistically.
Day 1: 4,312 existing findings recorded as the baseline
build fails only if the count RISES
Ongoing: every new commit must be clean
the baseline shrinks as people touch old code
This gets you the benefit immediately without a months-long cleanup nobody has budget for.
False positives are real and must be handled honestly. A finding that is genuinely wrong should be suppressed narrowly, at the smallest scope, with a comment saying why.
@SuppressWarnings("PMD.AvoidCatchingGenericException") // narrow, named rule
// The handler boundary must not let any exception reach the accept loop (14.2).
catch (Exception e) {
log.warn("handler failed", e);
}
A blanket @SuppressWarnings("all") on a class is how a tool stops being
useful.
Know the limits. Static analysis cannot determine intent, cannot find logic errors where the code correctly does the wrong thing, and cannot tell you that a requirement was misunderstood. It catches mechanical mistakes cheaply and reliably, which is valuable precisely because it frees human review to focus on design and correctness.
Key Takeaways
- Static analysis finds defects by pattern, without executing the code.
- It complements tests by covering paths no test exercises.
- SpotBugs analyses bytecode, Error Prone runs inside the compiler, PMD and Checkstyle read source.
- Error Prone can fail compilation, stopping known mistakes before commit.
- Dependency scanners find published vulnerabilities in code you never wrote.
- Wire analysis into the build so findings are not optional.
- Enabling everything on a legacy codebase produces noise that gets ignored.
- A baseline ratchet fails only on new findings and shrinks over time.
- Suppress false positives narrowly, by rule name, with a written reason.
- No tool infers intent, so logic errors and misunderstood requirements remain human work.
🧪 Practice
- Add SpotBugs to a project, run it, and categorise the first ten findings as genuine, stylistic, or false.
- Write a class with
equalsbut nohashCode, confirm the tool flags it, and demonstrate the actual bug with aHashSet.- Configure the build to fail on findings above a threshold, then commit a deliberate violation and observe the failure.
- Suppress one finding narrowly with a justification comment, and explain why a class-level suppression would be worse.
- Interview: You inherit a codebase with four thousand static analysis findings. How do you make the tool useful without a cleanup project? (Hint: what has to be true of tomorrow's commits, rather than yesterday's?)
Code Style and Formatting Standards
Formatting does not change what a program does, which is exactly why teams argue about it. The argument is worth ending quickly, and the reason is not aesthetics.
Consistent formatting reduces the effort of reading. Code is read far more
often than written, and every deviation from an expected shape costs the reader
a fraction of a second of reorientation. More concretely, inconsistent
formatting destroys version control history: a commit that reformats a file
while changing one line shows as a hundred changed lines, so git blame stops
telling you who changed what and why.
WITHOUT a shared format WITH a shared format
diff shows 120 changed lines diff shows 2 changed lines
review focuses on whitespace review focuses on the change
blame points at the reformatter blame points at the author
The established conventions are a starting point rather than something to invent.
| Convention | Origin | Notable choices |
|---|---|---|
| Oracle Code Conventions | The original, 1997 | 4-space indent, 80 columns |
| Google Java Style | Widely adopted today | 2-space indent, 100 columns, explicit rules for everything |
| Spring / project-specific | Framework ecosystems | Tabs, 120 columns, varies |
Chapter 1.2 already covered the naming rules these share, and they are worth restating because they are the part a formatter cannot fix for you.
| Element | Convention | Example |
|---|---|---|
| Class, interface, enum | UpperCamelCase | OrderService |
| Method, variable | lowerCamelCase | placeOrder, totalDue |
| Constant | UPPER_SNAKE_CASE | MAX_RETRIES |
| Package | lowercase.dotted | com.example.billing |
| Type parameter | Single capital | T, K, V, R |
The decisive move is to automate it entirely. A formatter that runs as part of the build turns style from an opinion into a fact, and nobody reviews whitespace again.
<plugin>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>2.43.0</version>
<configuration>
<java>
<googleJavaFormat/> <!-- one canonical format, no options to argue about -->
<removeUnusedImports/>
<importOrder/>
<trimTrailingWhitespace/>
<endWithNewline/>
</java>
</configuration>
<executions>
<execution>
<goals><goal>check</goal></goals> <!-- fails the build if unformatted -->
<phase>verify</phase>
</execution>
</executions>
</plugin>
mvn spotless:apply # reformat everything
mvn spotless:check # verify, used in CI
google-java-format deliberately offers almost no configuration, which is its
main virtue: a format with no options has nothing to debate.
Three supporting pieces make this stick across a team.
An .editorconfig file gives every editor the basics, including for
non-Java files, without anyone configuring their IDE.
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.java]
indent_style = space
indent_size = 4
max_line_length = 100
A pre-commit hook formats before code ever leaves a machine, so CI never has to reject a commit for whitespace.
A one-off formatting commit, recorded in .git-blame-ignore-revs, lets you
reformat an entire legacy codebase once while keeping git blame useful.
git blame --ignore-revs-file .git-blame-ignore-revs OrderService.java
Line length deserves one honest note. The 80-column limit originates in physical terminals, and modern arguments for it are about side-by-side diffs and the readability of long lines rather than hardware. Between 100 and 120 is the common modern compromise. The specific number matters far less than everyone using the same one.
What automation cannot do is the part that actually affects readability:
choosing names that say what something is, keeping methods short enough to hold
in mind, and ordering members so a reader meets them in a sensible sequence. A
perfectly formatted method named doStuff is still unreadable.
Key Takeaways
- Consistent formatting reduces reading effort and keeps diffs and blame meaningful.
- Reformatting while editing hides a real change inside noise.
- Google Java Style and the Oracle conventions are the two common baselines.
- Naming conventions are the part of style a formatter cannot fix for you.
- Automating the format with Spotless turns style from opinion into build output.
- A formatter with no options, such as google-java-format, removes the debate entirely.
.editorconfiggives every editor the basics with no per-developer setup.- Record a bulk reformat in
.git-blame-ignore-revsto preserve history.- Line length choice matters much less than consistency.
- Naming, method length, and member order remain human judgement.
🧪 Practice
- Add Spotless to a project, run
apply, and inspect the resulting diff.- Configure the build to fail on unformatted code, commit a badly formatted file, and observe the failure.
- Write an
.editorconfigand verify your editor respects it by typing in a file with different settings.- Reformat a file, commit it, and compare
git blameoutput with and without an ignore-revs file.- Interview: Beyond preference, what concrete engineering problem does inconsistent formatting cause? (Hint: what does a reviewer see when one logical change touches a reformatted file?)
Profiling and Performance Measurement
The single most reliable fact about performance work is that developers guess wrong about where time goes. Optimising the wrong thing is worse than doing nothing, because it costs effort, adds complexity, and leaves the real bottleneck untouched. The discipline is simple to state: measure, change, measure again.
Chapter 12's discussion of Amdahl's law explains why this matters mathematically. Making a function twice as fast yields nothing measurable if it accounts for two percent of the runtime.
Two kinds of profiler exist, and the difference determines what you can trust.
| Type | How it works | Overhead | Accuracy |
|---|---|---|---|
| Sampling | Periodically records every thread's stack | Low, a few percent | Statistical; misses very short calls |
| Instrumenting | Injects counters into every method entry and exit | High, can be 10x | Exact counts; distorts what it measures |
The distortion of an instrumenting profiler is not a minor caveat. Adding overhead to every method call penalises code with many small calls, so the profiler can report a bottleneck that only exists because it is measuring. For finding where wall-clock time goes, sampling is almost always the right choice.
The JDK ships the tool you should start with. Java Flight Recorder is a sampling profiler built into the JVM with roughly one percent overhead, designed to be safe to leave enabled in production.
# Record 60 seconds from a running application
java -XX:StartFlightRecording=duration=60s,filename=app.jfr -jar app.jar
# Or attach to an already-running process
jcmd <pid> JFR.start duration=60s filename=app.jfr
jcmd <pid> JFR.dump filename=snapshot.jfr
The recording opens in JDK Mission Control, which shows processor time by method, allocation by type, garbage collection pauses, lock contention, and I/O waits in one place.
Flame graphs are the most useful visualisation of a sampled profile, and
async-profiler is the common way to produce them.
width = share of samples, so wider means more time spent
|---------------------------- main ----------------------------|
|------- handleRequest -------|-------- writeResponse ---------|
|-- parse --|---- price ------| |
|-- lookupTax ----| <- 40% of total time, and it is
| | a database call in a loop
Reading one is mechanical: find the widest block that is not simply a caller of one child, and that is where the time is.
Memory problems need different tools. A steadily growing heap that
eventually throws OutOfMemoryError is a leak in the Java sense: objects still
reachable that should not be, as Chapter 11 described.
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heap.hprof -jar app.jar
jcmd <pid> GC.heap_info # quick summary
jmap -histo:live <pid> | head # what is on the heap right now, by count
A heap dump opened in Eclipse MAT gives the decisive answer through its dominator tree and "path to GC root", which shows exactly what is still holding a reference to the objects you expected to be collected.
Microbenchmarks in plain Java are almost always wrong. This is worth demonstrating, because the naive approach is so tempting.
// WRONG in at least four distinct ways
long start = System.currentTimeMillis();
for (int i = 0; i < 1_000_000; i++) {
result = compute(i);
}
System.out.println(System.currentTimeMillis() - start);
The problems: the JIT compiler has not warmed up, so the first iterations run
interpreted; the JIT may eliminate the loop entirely once it proves the result
is unused; currentTimeMillis has coarse resolution; and a garbage collection
during the measurement window silently inflates the result.
The Java Microbenchmark Harness exists because getting this right requires defeating the compiler deliberately.
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5) // let the JIT compile and stabilise
@Measurement(iterations = 10)
@Fork(2) // fresh JVMs, to expose run-to-run variance
public class StringConcatBenchmark {
@Param({"10", "1000"}) // run the whole benchmark per value
private int size;
@Benchmark
public String stringBuilder() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < size; i++) sb.append(i);
return sb.toString(); // RETURNED, so it cannot be optimised away
}
@Benchmark
public void concatenation(Blackhole hole) {
String s = "";
for (int i = 0; i < size; i++) s += i;
hole.consume(s); // Blackhole defeats dead-code elimination
}
}
Measure the right statistic. Averages hide exactly the behaviour users notice.
Endpoint latency over 10,000 requests
mean 42 ms <- looks fine
p50 28 ms
p95 110 ms
p99 850 ms <- one request in a hundred is terrible
max 4,200 ms <- and someone waited four seconds
If a page makes twenty such calls, nearly every page load hits the p99 at least once. Tail latency is the user experience; report percentiles, not averages.
A workable order of operations, before reaching for any micro-optimisation.
| Step | Question |
|---|---|
| 1 | Is there a measured problem, with a target number? |
| 2 | Where does the time actually go, per a profile? |
| 3 | Is it algorithmic? An O(n²) loop beats any constant-factor tuning |
| 4 | Is it I/O? Queries in a loop, missing indexes, no batching, no caching |
| 5 | Is it allocation or garbage collection pressure? |
| 6 | Only now, consider low-level tuning |
In practice, most real Java performance problems are found at steps 3 and 4, and the fix is a better query or one fewer round trip rather than anything about the language.
Key Takeaways
- Developers reliably guess wrong about bottlenecks, so always measure first.
- Sampling profilers have low overhead and reflect reality; instrumenting ones distort what they measure.
- Java Flight Recorder ships with the JDK and is safe to run in production.
- Flame graph width is share of time, so the widest non-pass-through block is the target.
- Heap dumps plus a dominator tree identify what is still referencing leaked objects.
- Naive timing loops are invalidated by JIT warmup, dead-code elimination, clock resolution, and garbage collection.
- JMH handles warmup, forking, and
Blackholeconsumption so results mean something.- Report percentiles rather than averages, because tail latency is what users experience.
- Algorithmic and I/O problems dominate real Java performance work.
- Optimise only against a measured target, then measure again afterwards.
🧪 Practice
- Record a Flight Recorder session of a program doing real work and identify the three methods consuming the most processor time.
- Write the naive timing loop above for string concatenation, then rewrite it with JMH. Explain the difference in the results.
- Write a program that leaks by adding to a static collection, capture a heap dump on
OutOfMemoryError, and find the retaining reference.- Compute p50, p95, and p99 for a set of recorded latencies and explain what the mean alone would have hidden.
- Find a method calling a repository inside a loop, fix it with a batch query, and measure before and after.
- Interview: Why can a microbenchmark report that an empty method takes zero nanoseconds? (Hint: what is the JIT allowed to do with code whose result nobody uses?)
Effective Javadoc Documentation
Chapter 1.2 covered Javadoc's syntax: the comment form, the block tags, and how the tool generates HTML. This topic is about the harder question of what to write, because a correctly formatted comment that restates the method signature is worse than no comment, since it must still be maintained.
The guiding principle is that Javadoc documents a contract, not an implementation. A caller reads it to learn what they may rely on and what is required of them. Anything that describes how the method currently works is a promise you did not mean to make and will break on the next refactor.
// USELESS: restates the signature, and the reader learns nothing
/**
* Gets the balance.
* @return the balance
*/
public long getBalance() { ... }
// USEFUL: states units, guarantees, and what the caller must know
/**
* Returns the currently available balance in minor units of the account's
* currency, excluding funds reserved by pending authorisations.
*
* @return the available balance; may be negative for overdraft accounts
*/
public long getBalance() { ... }
A contract has four parts, and a good comment covers whichever apply.
| Part | Question it answers | Where it goes |
|---|---|---|
| Purpose | What does this accomplish, in the caller's terms | The summary sentence |
| Preconditions | What must be true before calling | @param, and @throws for violations |
| Postconditions | What is guaranteed afterwards | @return and the body |
| Failure modes | What can go wrong and how it is signalled | @throws |
Four properties are invisible in a signature and belong in the documentation, because a caller has no other way to learn them.
Nullability. Whether a parameter may be null, and whether the return value
ever is. This is the most common source of production NullPointerException
between two correct components.
Thread safety. Whether an instance may be shared across threads, as Chapter 12 discussed. The absence of a statement is generally read as "not safe", so say so explicitly when it is.
Mutability and ownership. Whether a returned collection is a live view or a copy, and whether the callee retains a reference to a passed array.
Units and ranges. Milliseconds or seconds, minor units or major, inclusive or exclusive bounds.
/**
* Transfers funds between two accounts atomically.
*
* <p>Either both balances change or neither does. The accounts are locked in a
* fixed global order, so concurrent transfers between the same pair cannot
* deadlock.
*
* <p>This method is thread-safe and may be called concurrently.
*
* @param from the account to debit; must not be {@code null} and must be open
* @param to the account to credit; must not be {@code null}, must be open,
* and must use the same currency as {@code from}
* @param amount the amount to move in minor units; must be positive
* @return an immutable record of the completed transfer, never {@code null}
* @throws InsufficientFundsException if {@code from} cannot cover {@code amount}
* @throws CurrencyMismatchException if the accounts use different currencies
* @throws IllegalArgumentException if {@code amount} is zero or negative
* @since 2.1
*/
public Transfer transfer(Account from, Account to, long amount) { ... }
Everything there is a commitment to the caller. None of it describes how the transfer is implemented, so the body can be rewritten freely.
What not to document. Trivial accessors whose name says everything, private implementation details that no caller can reach, and anything the code already states unambiguously. Comments have a maintenance cost and rot silently, and a comment contradicting the code is actively harmful because readers believe it.
Writing documentation is a design tool. If the contract is hard to state without conditionals, that is evidence the method does too much.
"Returns the user, or null if not found, unless includeDeleted is true,
in which case it returns the deleted user, except when the caller lacks
permission, when it returns null anyway."
The comment is not the problem. That is three methods.
Documentation difficulty is a design smell in the same way testing difficulty is, and for the same underlying reason.
Mechanics worth remembering. The first sentence becomes the summary in the
generated index, so write it as a complete standalone statement ending in a
period. Use {@code} for anything that is literal code, since it escapes angle
brackets that would otherwise be interpreted as HTML. Use {@link} for
references a reader will want to follow. @inheritDoc pulls an overridden
method's documentation in rather than duplicating it. And @deprecated must
always say what to use instead, paired with the @Deprecated annotation so that
both the compiler and the reader are informed.
/**
* @deprecated Use {@link #transfer(Account, Account, Money)} instead, which
* makes the currency explicit. This method will be removed in 3.0.
*/
@Deprecated(since = "2.4", forRemoval = true) // the annotation warns the compiler
public Transfer transfer(Account from, Account to, long amount) { ... }
Generating and checking the output should be part of the build, since malformed
Javadoc fails the javadoc tool and broken links silently degrade the published
documentation.
mvn javadoc:javadoc # generate into target/site/apidocs
javadoc -Xdoclint:all -d docs src/main/java/com/example/**/*.java
Key Takeaways
- Javadoc documents a contract for callers, not the current implementation.
- A comment restating the signature adds maintenance cost and no information.
- Cover purpose, preconditions, postconditions, and failure modes.
- Nullability, thread safety, mutability, and units are invisible in a signature and must be stated.
- A returned collection should say whether it is a live view or a copy.
- Skip documentation for trivial accessors and unreachable internals.
- A contract that needs many conditionals to state is a method doing too much.
- The first sentence is the generated summary and must stand alone.
{@code}escapes literal code and{@inheritDoc}avoids duplicating overridden documentation.@deprecatedmust name the replacement, alongside the@Deprecatedannotation.
🧪 Practice
- Take three public methods you have written and document each contract, stating nullability and thread safety explicitly.
- Find a Javadoc comment that describes implementation rather than contract, and rewrite it so an internal refactor would not invalidate it.
- Document a method returning a collection, stating clearly whether callers may modify it, then verify the code matches the claim.
- Run
javadocwith-Xdoclint:allon a package and fix every warning.- Deprecate a method properly with both the annotation and the tag, then confirm the compiler warns at a call site.
- Interview: A method's documentation needs four "unless" clauses to describe what it returns. What does that suggest? (Hint: what is the comment struggling to describe, and how many behaviours does the method have?)
<a id="16-database-connectivity-with-jdbc"></a>