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.
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
| Level | Strengths in Discovery | Time of Discovery | Limitations |
|---|---|---|---|
| Compiler | Types, syntax, reachability, some exhaustiveness checks | Editing/compiling | Does not understand most business rules |
| Static Analysis | Known bug patterns, resource leaks, incorrect API usage | Local/CI | False positives; rule coverage limited |
| Unit and Property-Based Testing | Executable behavior, boundary, and algebraic properties | Seconds to minutes | Covers only generated/written inputs |
| Integration Testing | Database, Serialization, Process Boundaries | Minutes | Higher environment and diagnostics costs |
| Review | Readability, Design, Risk, and Omissions | Before Merge | Humans will fatigue and cannot consistently simulate execution |
| Production Observations | True Distribution, Capacity, and Unknown Failures | Post-Release | Highest 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:
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:
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.
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:
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.
<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.
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:
one behavior change
+ its tests
+ necessary refactor
+ explicit migration/compatibility noteEach 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
- Java 21: LocalDate
- Java 21: Object
- Error Prone: ReferenceEquality
- Error Prone: MissingCasesInEnumSwitch
The next section applies these checks inside an object: how preconditions, postconditions, and representation invariants remain valid even in the face of failure.