Skip to content

VOL 06 / CH 01 / LESSON 01

1.1 Fast Feedback for Software Correctness ​

Software construction needs errors to surface early, consistently, and at low cost. Compilers, static analysis, tests, review, and production monitoring provide feedback at different times. Reliable early checks leave fewer basic mistakes for later stages to catch.

Start with a vague function ​

“Calculating the number of days between two dates” seems simple, but the tricky part is:

  • Is the input a date or a timezone-aware moment in time?
  • Is it allowed for an end date to come before a start date?
  • Return signed days or the absolute value?
  • How do you handle null?

This lesson uses Java 21 language features. Save the complete class below as src/main/java/StayLength.java; the testing section supplies the JUnit environment. Shorter fragments state their required context.

If the business talks in calendar dates, Java should prefer LocalDate over dividing the millisecond difference by 86,400,000 with Date. A "day" spanning daylight saving time changes may not equal a fixed 24 hours.

java
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.Objects;

public final class StayLength {
    private StayLength() {}

    /** Returns the number of calendar days in [arrival, departure). */
    public static long days(LocalDate arrival, LocalDate departure) {
        Objects.requireNonNull(arrival, "arrival");
        Objects.requireNonNull(departure, "departure");
        if (departure.isBefore(arrival)) {
            throw new IllegalArgumentException("departure must not precede arrival");
        }
        return ChronoUnit.DAYS.between(arrival, departure);
    }
}

The type selection eliminates timezone and millisecond conversion issues; method names, interval conventions, and exceptions further lock in the remaining semantics.

Feedback Ladder ​

LevelStrengths in DiscoveryTime of DiscoveryLimitations
CompilerTypes, syntax, reachability, some exhaustiveness checksEditing/compilingDoes not understand most business rules
Static AnalysisKnown bug patterns, resource leaks, incorrect API usageLocal/CIFalse positives; rule coverage limited
Unit and Property-Based TestingExecutable behavior, boundary, and algebraic propertiesSeconds to minutesCovers only generated/written inputs
Integration TestingDatabase, Serialization, Process BoundariesMinutesHigher environment and diagnostics costs
ReviewReadability, Design, Risk, and OmissionsBefore MergeHumans will fatigue and cannot consistently simulate execution
Production ObservationsTrue Distribution, Capacity, and Unknown FailuresPost-ReleaseHighest Cost, Cannot Replace Pre-Production Quality

These layers complement one another, helping each type of error surface at a low-cost stage.

Use Types to Rule Out Invalid States ​

This call is illustrative; transfer is not defined here. Three strings make the parameter roles hard to distinguish:

java
transfer("account-1", "account-2", "100.00");

Value objects distinguish identifiers from amounts. Save the two public records in separate AccountId.java and Money.java files alongside StayLength. The BigDecimal import belongs in Money.java:

java
import java.math.BigDecimal;

public record AccountId(String value) {
    public AccountId {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("account id is blank");
        }
    }
}

public record Money(BigDecimal amount, String currency) {
    public Money {
        if (amount == null || currency == null) {
            throw new IllegalArgumentException("amount and currency are required");
        }
        if (amount.signum() < 0) {
            throw new IllegalArgumentException("amount must be non-negative");
        }
    }
}

Two AccountId parameters can still be swapped: the compiler cannot infer source and destination roles from one shared type. A command with named roles, or distinct source/destination types where the risk warrants them, can make the direction clearer. Tests should verify it.

Money is still not a complete currency library: decimal places, rounding, and currency validity need to be defined. But it at least prevents "arbitrary string amounts" from entering the core interface.

Optional is suitable as a return type for "possibly no return value," and should not be indiscriminately applied to entity fields or all parameters. Returning an empty collection is usually easier to compose than returning null.

Static analysis checks patterns, not proofs ​

For Java reference operands, == compares object identity, while .equals() checks for value equality; enum and explicit sentinel cases indeed require identity comparison. Error Prone's ReferenceEquality rules alert on identifiable error-prone scenarios, but this doesn't mean any == is wrong.

java
if (Objects.equals(expectedName, actualName)) {
    // null-safe value equality
}

Enum switches require a distinction between statements and expressions. Place this method in a class with enum Status { PENDING, RUNNING, DONE } in scope. Java 21 switch expressions must be exhaustive:

java
static int priority(Status status) {
    return switch (status) {
        case PENDING -> 10;
        case RUNNING -> 20;
        case DONE -> 30;
    };
}

After adding an enum value and recompiling this code, the compiler forces a decision here. Exhaustiveness does not imply null support; this example throws NullPointerException for null. Writing default casually may compile but could hide future branches; use it only when there is a defined fallback behavior.

To maintain static analysis rules:

  • Pin build configuration so developer machines and CI agree;
  • Assess existing warnings and false positives before enabling a rule;
  • Keep suppressions narrow and explain each one;
  • Manage bug checks separately from formatting rules;
  • Read rule changes when upgrading; do not copy stale plugin versions.

Tests Establish Behavioral Boundaries ​

Create this pom.xml in an isolated practice directory, using JDK 21+ and Maven 3.9+. These versions fix the exercise environment; they are not a latest-version claim. The first Maven run needs access to the dependencies.

xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>example</groupId><artifactId>contracts-lab</artifactId><version>1.0</version>
  <properties>
    <maven.compiler.release>21</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId>
      <version>5.12.2</version><scope>test</scope>
    </dependency>
  </dependencies>
  <build><plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId>
      <version>3.14.0</version>
    </plugin>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId>
      <version>3.5.3</version>
    </plugin>
  </plugins></build>
</project>

Save the test as src/test/java/StayLengthTest.java. The exercise uses the default package to keep setup short; real projects should use named packages.

java
import static org.junit.jupiter.api.Assertions.*;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;

class StayLengthTest {
    @Test
    void sameDayHasZeroNights() {
        var day = LocalDate.of(2026, 8, 3);
        assertEquals(0, StayLength.days(day, day));
    }

    @Test
    void countsCalendarDaysAcrossMonthBoundary() {
        assertEquals(2, StayLength.days(
            LocalDate.of(2026, 7, 31),
            LocalDate.of(2026, 8, 2)));
    }

    @Test
    void countsLeapDay() {
        assertEquals(2, StayLength.days(
            LocalDate.of(2024, 2, 28), LocalDate.of(2024, 3, 1)));
    }

    @Test
    void rejectsMissingDates() {
        var day = LocalDate.of(2026, 8, 3);
        assertThrows(NullPointerException.class, () -> StayLength.days(null, day));
        assertThrows(NullPointerException.class, () -> StayLength.days(day, null));
    }

    @Test
    void rejectsReverseRange() {
        assertThrows(IllegalArgumentException.class, () ->
            StayLength.days(
                LocalDate.of(2026, 8, 4),
                LocalDate.of(2026, 8, 3)));
    }
}

Run mvn test beside pom.xml. It should discover 5 tests, with zero failures, errors, and skips. These tests establish calendar-date behavior; a requirement about elapsed hours needs a different contract for instants and time zones. JUnit 5.12.2

The value of test-first is forcing interfaces to be validated by examples before implementation, not requiring every line of code to mechanically follow the same ritual. When fixing defects, write tests that can reproduce the defect first; when exploring unknown APIs, do a spike first, discard the experiment, and rewrite from a stable contract.

Small-batch construction makes feedback interpretable ​

When a single change modifies interfaces, rewrites modules, upgrades dependencies, and formats the entire codebase, it's difficult to pinpoint issues even if tests fail. Reviewable batches typically satisfy:

text
one behavior change
+ its tests
+ necessary refactor
+ explicit migration/compatibility note

Each batch should compile, run relevant tests, and document failure modes. Small, incremental commits make cause-and-effect clearer and make rollbacks safer.

Section Checkpoint ​

  • Use domain types and exhaustive branches to eliminate a class of invalid states.
  • Can illustrate the evidence boundaries for compilers, static analysis, testing, and review.
  • Turns vague requirements into executable examples and clearly defined failure semantics.

Reference ​

The next section applies these checks inside an object: how preconditions, postconditions, and representation invariants remain valid even in the face of failure.

Built with VitePress | Software Systems Atlas