Skip to content

VOL 06 / CH 01 / LESSON 02

1.2 Contracts, Invariants, and Failure Atomicity ​

A method's interface includes more than its parameters and return types. It also specifies the allowed inputs, guarantees upon success, the state after failure, and the invariants that the object must always maintain.

Three Types of Contracts ​

  • Preconditions: What the caller must provide before making a call;
  • Postconditions: What is guaranteed upon successful method return;
  • Representation invariants: What must hold in the internal representation at each stable point observable to callers.

Using inventory batch management as an example:

text
Abstract value: The current available quantity for a given SKU
Precondition: The quantity passed to reserve(quantity) must be greater than zero
Postcondition: On success, the available quantity is reduced by the specified amount, and a reservation is returned
Failure semantics: If inventory is insufficient, the state remains unchanged
Representation invariant: The available quantity must always be greater than or equal to zero

Validate at Boundaries and Rely on Invariants Internally ​

Use Java 21 and place this class, together with Reservation.java below, in the previous lesson's src/main/java directory. Stock.java contains public Stock and package-private InsufficientStock. Other method-body fragments compare alternatives; do not append them all to this class.

java
public final class Stock {
    private final String sku;
    private int available;

    public Stock(String sku, int available) {
        if (sku == null || sku.isBlank()) {
            throw new IllegalArgumentException("sku is blank");
        }
        if (available < 0) {
            throw new IllegalArgumentException("available is negative");
        }
        this.sku = sku;
        this.available = available;
    }

    public Reservation reserve(int quantity) {
        if (quantity <= 0) {
            throw new IllegalArgumentException("quantity must be positive");
        }
        if (quantity > available) {
            throw new InsufficientStock(sku, quantity, available);
        }

        var reservation = new Reservation(sku, quantity);
        available -= quantity;
        return reservation;
    }

    public int available() {
        return available;
    }
}

final class InsufficientStock extends RuntimeException {
    InsufficientStock(String sku, int requested, int available) {
        super(sku + ": requested=" + requested + ", available=" + available);
    }
}

The constructor establishes the invariant, and each reserve call checks its own quantity. Validation therefore continues after construction. Build the return value before decrementing stock so constructor failure cannot leave a partial update. Within these conditions, internal code can rely on available >= 0; choose the boundary that rejects invalid state instead of repeating checks on every line.

assert Cannot Handle External Input Validation ​

Java assertions are disabled by default, so they cannot be relied upon for validating request parameters, permissions, or business rules. They are suitable only for checking internal assumptions, conditions that must hold true if the rest of the program behaves correctly:

java
assert available >= 0 : "representation invariant broken";

External inputs should be validated using explicit exceptions or typed results. Failure behaviors of public APIs must be defined in contracts and tested.

Failure Atomicity: Validate First, Then Modify ​

Dangerous patterns modify half the state before detecting an error:

java
available -= quantity;
audit.append(reservation); // If append throws, available has already changed

This alternative reserve method body retains the positive-quantity check, then checks, computes, and commits the new state:

java
if (quantity <= 0) {
    throw new IllegalArgumentException("quantity must be positive");
}
int next = Math.subtractExact(available, quantity);
if (next < 0) {
    throw new InsufficientStock(sku, quantity, available);
}

var reservation = new Reservation(sku, quantity); // Finish construction before mutating state
available = next;                                  // Commit the in-memory change last
return reservation;

Failure atomicity needs a stated failure model. This example covers parameter and business exceptions in normal Java control flow, not JVM crashes or rollback of external effects. If audit.append must persist atomically with the decrement, use one transaction or a recoverable protocol.

When dealing with cross-database, message, or remote service operations, in-memory assignment order is insufficient. Use database transactions, outbox patterns, idempotency keys, or compensating state machines, and explicitly define which external side effects cannot be rolled back.

Exception Classification Helps Consumers Decide What to Do Next ​

At a minimum, distinguish between:

  • Contract violation errors: invalid parameters, missing required fields; typically not retried;
  • Expected business denials: out-of-stock inventory, state conflicts; return domain-specific errors;
  • Temporary infrastructure failures: timeouts, exhausted available connections; may allow bounded retries;
  • Programming defects: violated invariants, unreachable code paths; must be exposed, reported, and fixed.

Do not catch Exception and return null; that erases the distinction between "no result" and "system failure." Also, avoid automatically retrying non-idempotent operations at the lower layers, as this obscures whether side effects have been duplicated at the upper level.

java
sealed interface ReserveResult {
    record Accepted(Reservation reservation) implements ReserveResult {}
    record Rejected(int requested, int available) implements ReserveResult {}
}

Expected business branches can be expressed using dedicated types; exceptions should remain reserved for contract violations and failures that prevent normal completion. Whether to adopt this style depends on the interface layer; there is no need to mechanically convert all exceptions into result objects.

Immutable Objects Reduce State Space ​

java
public record Reservation(String sku, int quantity) {
    public Reservation {
        if (sku == null || sku.isBlank()) {
            throw new IllegalArgumentException("sku is blank");
        }
        if (quantity <= 0) {
            throw new IllegalArgumentException("quantity must be positive");
        }
    }
}

The component references in a Record are final, but if a component points to a mutable collection, the object won't automatically become deeply immutable:

java
public record OrderLines(java.util.List<Reservation> values) {
    public OrderLines {
        values = java.util.List.copyOf(values);
    }
}

List.copyOf takes an unmodifiable snapshot unaffected by later additions or removals in the caller's list, and rejects a null list or null elements. It may reuse a suitable unmodifiable input, so it does not promise a new allocation. The accessor prevents structural list modification. If the elements themselves are mutable, additional copying of those elements or a redesign is still required.

Numbers and Resources Also Have Invariants ​

Integer overflow doesn't always throw an exception: Java's primitive integer operations wrap around within a fixed bit width. For counters and monetary values, boundary checks can be implemented using Math.addExact/subtractExact, range validation, or appropriate big integer types.

The following is a method-body fragment returning long, with a Path variable named path already in scope; the method must handle or declare IOException. Resources need clear ownership:

java
try (var stream = java.nio.file.Files.lines(path)) {
    return stream.filter(line -> !line.isBlank()).count();
}

"The one who creates is responsible for closing" is a common default, but if a method transfers resource ownership to the caller, this must be explicitly stated in the API.

Object Invariants Require Atomic Boundaries in Concurrent Settings ​

The Stock code works correctly in a single-threaded context, but when two threads concurrently invoke reserve, the checks and decrements may interleave. Possible approaches include:

  • Confining the object to one thread;
  • Using synchronization or locks to protect the entire check-modify operation;
  • Employing an atomic compare-and-set loop;
  • Delegating the authoritative invariant to database-driven conditional updates.

volatile can only provide visibility and partial ordering guarantees, and it cannot make composite check-modify operations automatically atomic.

Contract Test Checklist ​

In the preceding JUnit project, start with this Stock state sequence. Check available after every call, along with the return value or exception:

OperationResultavailable
new Stock("SKU-42", 2)Created2
reserve(3)InsufficientStock2
reserve(0)IllegalArgumentException2
reserve(2)Reservation("SKU-42", 2)0
reserve(1)InsufficientStock0

Choose checks for each public operation according to risk:

  • Minimum, typical, and upper-bound values;
  • Each precondition being violated;
  • Object state remaining unchanged after business-level rejections;
  • Arithmetic overflow and empty collections;
  • Mutable arguments modified by the caller after being passed in;
  • For concurrent contracts that claim thread safety, perform race condition testing and explain the synchronization that protects every relevant access.

Testing should uncover the examples of violations described, and the design rationale and code structure must explain why all paths preserve invariants.

Section Checkpoint ​

  • Can write preconditions, postconditions, invariants, and failure states.
  • Can distinguish between external validation, internal assertions, and expected business rejections.
  • Can complete validation and computation before committing a state change, preventing objects from being left in a half-updated state.
  • Can identify shallow immutability, representation leakage, and concurrent composite operations.

The next chapter elevates "internal object correctness" to "abstraction independence from a specific representation": how the same ADT can support different implementations without breaking the caller.

Built with VitePress | Software Systems Atlas