Skip to content

1.2 Contracts, Invariants, and Failure Atomicity

A method's true interface goes beyond just its parameters and return types, it also includes 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 always hold true at any observable point in time, regardless of external interactions, within the object's internal state.

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

In Boundary Validation, Keep Internal Dependencies Invariant

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);
        }

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

    public int available() {
        return available;
    }
}

After a single entry-point validation, internal methods can rely on available >= 0 without needing to repeat defensive checks on every line. Defensive programming isn't about scattering null checks everywhere; it's about deciding which boundary is responsible for rejecting invalid states.

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); // Throw exception afterward available It’s changed

Within an object, a "check (compute) commit" pattern can reduce partial updates:

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

var reservation = new Reservation(sku, quantity); // Construct also completed
available = next;                                  // Final submission of memory status
return reservation;

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;
  • Programmatic defects: violated invariants, unreachable code paths; must be exposed, reported, and fixed.

Do not catch Exception and re-throw null, this 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's 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);
    }
}

Defensive copying is performed during construction, and immutable lists returned by accessors prevent representation leakage. 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.

Resources must have 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:

  • Limiting the object to be thread-closed;
  • 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

Cover each public operation with:

  • Minimum, typical, and upper-bound values;
  • Each precondition being violated;
  • Object state remaining unchanged after business-level rejections;
  • Arithmetic overflow and empty collections;
  • Variadic parameters passed in, then modified by the caller afterward;
  • For concurrent contracts that claim thread safety, perform race condition testing and combine it with thread-level synchronization proofs.

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 ensure validation and computation complete before failure, 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