---
title: 'Exception Design'
source: 'https://academia.sh/en/courses/java-object-model/exception-design'
course: 'Object-Oriented Java'
language: en
updated: '2026-08-17T18:09:41+00:00'
license: 'CC BY-SA 4.0'
---

# Exception Design

The same work gets built with three contracts: a checked exception, an unchecked exception, and a result type. The measurement shows the count of decision points the compiler forces on the calling side, and what a careless caller gets from an empty store; none of the three contracts eliminates the defect, they only move where it shows up.

The previous four lessons took the exception as a given: it got
thrown, declared, caught, suppressed. None of them asked whether an
event should be reported as an exception at all. The same work can be
written with three separate contracts — a checked exception, an
unchecked exception, or no exception at all, returning the result
through a type.

This lesson builds all three around the same work: getting an item
from a store that might be empty. This course's question turns into a
design question here, but the measure stays the same. Whoever chooses
the contract chooses what the caller **has to** do; and the party doing
the forcing is always the compiler.

## The Decision Point the Compiler Forces

- **GE27** — The three contracts get measured on the same store, with
  the same three-layer calling chain: `inner` does the work, `middle`
  calls it, `outer` calls `middle`.
- **GE28** — The eight attempts are the set of which of the three
  points get the declaration. Since there is no declaration to place
  for the result type, that row's subset count gets left blank.

```java
// Scale.java - shared measurement core: compiles the given source
import java.io.PrintWriter;
import java.io.Writer;
import java.lang.classfile.*;
import java.nio.file.*;
import java.util.spi.ToolProvider;

class Scale {
    static ClassModel compile(String source, String className) throws Exception {
        Path dir = Files.createTempDirectory("scale");
        Path file = dir.resolve(className + ".java");
        Files.writeString(file, source);
        PrintWriter silent = new PrintWriter(Writer.nullWriter());
        if (ToolProvider.findFirst("javac").orElseThrow()
                .run(silent, silent, "-d", dir.toString(), file.toString()) != 0)
            throw new IllegalStateException("did not compile: " + className);
        return ClassFile.of().parse(dir.resolve(className + ".class"));
    }
}
```

```java
// Force.java - how many points three contracts force in the calling chain
public class Force {
    static final String TYPES = """
        class Event extends Exception { Event(String m) { super(m); } }
        class SilentEvent extends RuntimeException { SilentEvent(String m) { super(m); } }
        record Result(String value, String error) { }
        class Store {
            String checked() throws Event { throw new Event("empty"); }
            String unchecked() { throw new SilentEvent("empty"); }
            Result result() { return new Result(null, "empty"); }
        }
        """;

    static String chain(String type, String call, String[] declarations) {
        return TYPES + "class C {\n"
                + "    static " + type + " inner(Store d) " + declarations[0]
                + " { return d." + call + "(); }\n"
                + "    static " + type + " middle(Store d) " + declarations[1] + " { return inner(d); }\n"
                + "    static " + type + " outer(Store d) " + declarations[2] + " { return middle(d); }\n}\n";
    }

    static boolean compiles(String source) {
        try {
            Scale.compile(source, "C");
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    static void measure(String label, String type, String call, String declaration) {
        boolean untouched = compiles(chain(type, call, new String[] {"", "", ""}));
        int minimum = -1, passed = 0;
        for (int m = 0; m < 8; m++) {
            String[] declarations = new String[3];
            int points = 0;
            for (int i = 0; i < 3; i++) {
                boolean present = (m >> i & 1) == 1;
                declarations[i] = present ? declaration : "";
                if (present) points++;
            }
            if (compiles(chain(type, call, declarations))) {
                passed++;
                if (minimum < 0 || points < minimum) minimum = points;
            }
        }
        System.out.printf("%-16s %-20s %-10d %s%n", label,
                untouched ? "compiled" : "did not compile", minimum,
                declaration.isEmpty() ? "-" : passed + " / 8");
    }

    public static void main(String[] args) {
        System.out.printf("%-16s %-20s %-10s %s%n",
                "contract", "untouched chain", "min points", "compiled subset");
        measure("checked", "String", "checked", "throws Event");
        measure("unchecked", "String", "unchecked", "throws SilentEvent");
        measure("result type", "Result", "result", "");
    }
}
```

```
contract         untouched chain      min points compiled subset
checked          did not compile      3          1 / 8
unchecked        compiled             0          8 / 8
result type      compiled             0          -
```

The first row counts the cost the checked exception writes to the
caller. The chain untouched anywhere does not compile, and the
smallest declaration set that makes the chain compile has size
**three** — that is, **every layer**. Only one of eight subsets
compiles: the set where all three declare. The requirement cannot be
stopped at a layer in between, because if one layer does not declare,
the layer calling it never learns the exception exists at all.

The second and third rows give **zero**. For the unchecked exception,
the chain compiles with no touch at all; all eight of eight subsets
are valid, that is, declaration is entirely optional. For the result
type, there is not even a declaration to place.

A measured comparison follows from this. A checked exception writes
the error path's existence into **every layer** of the chain; this is
documentation, and it gets enforced by the compiler. In the other two
contracts, the error path is invisible in any of the intermediate
layers. What gets forced, though, is only **declaring**, not
**handling** — and the next measurement shows what that means.

## Same Work, Three Contracts

- **GE29** — Two callers get written for each contract: the shortest
  writing the compiler accepts, and a writing that genuinely handles
  the error path. Both run against the same empty store and the same
  full store.
- **GE30** — From the caller's outcome, only the returned value or
  the class name of the escaped exception gets printed.

```java
// Contract.java - same work, three contracts, a careless and a careful caller
import java.util.function.Function;

class Event extends Exception {
    Event(String m) { super(m); }
}

class SilentEvent extends RuntimeException {
    SilentEvent(String m) { super(m); }
}

record Result(String value, String error) { }

class Store {
    private final String item;

    Store(String item) { this.item = item; }

    String checked() throws Event {
        if (item == null) throw new Event("empty store");
        return item;
    }

    String unchecked() {
        if (item == null) throw new SilentEvent("empty store");
        return item;
    }

    Result result() {
        return item == null ? new Result(null, "empty store") : new Result(item, null);
    }
}

public class Contract {
    static String carelessChecked(Store d) {
        try {
            return d.checked();
        } catch (Event e) {
            return "";
        }
    }

    static String carefulChecked(Store d) {
        try {
            return d.checked();
        } catch (Event e) {
            return "fallback (" + e.getMessage() + ")";
        }
    }

    static String carefulUnchecked(Store d) {
        try {
            return d.unchecked();
        } catch (SilentEvent e) {
            return "fallback (" + e.getMessage() + ")";
        }
    }

    static String carefulResult(Store d) {
        Result s = d.result();
        return s.error() == null ? s.value() : "fallback (" + s.error() + ")";
    }

    static String run(Function<Store, String> f, Store d) {
        try {
            return "value \"" + f.apply(d) + "\"";
        } catch (RuntimeException e) {
            return "exception " + e.getClass().getSimpleName();
        }
    }

    static void row(String label, Function<Store, String> careless, Function<Store, String> careful) {
        Store empty = new Store(null), full = new Store("north");
        System.out.printf("%-16s %-26s %-26s %s%n", label,
                run(careless, empty), run(careful, empty), run(careless, full));
    }

    public static void main(String[] args) {
        System.out.printf("%-16s %-26s %-26s %s%n",
                "contract", "careless / empty store", "careful / empty store", "careless / full store");
        row("checked", Contract::carelessChecked, Contract::carefulChecked);
        row("unchecked", d -> d.unchecked(), Contract::carefulUnchecked);
        row("result type", d -> String.valueOf(d.result().value()), Contract::carefulResult);
    }
}
```

```
contract         careless / empty store     careful / empty store      careless / full store
checked          value ""                   value "fallback (empty store)" value "north"
unchecked        exception SilentEvent      value "fallback (empty store)" value "north"
result type      value "null"               value "fallback (empty store)" value "north"
```

The first column gives three separate defect shapes, and all three
come out of the same empty store.

In the checked contract, the careless caller returns an **empty
string**. The compiler forced a catch to be written, but never asked
what got put inside it; an empty body satisfies the requirement
completely. A checked exception's guarantee is the error path being
**seen**, not being **handled**.

In the unchecked contract, the careless caller **fails at runtime**;
the defect takes its loudest shape here. In the result type, it gets
stopped neither at compile time nor does it fail at runtime; it
returns the text `null` **as a value**. This is the quietest of the
three shapes, because the error path has become ordinary data, not an
exception.

The second column writes down the three contracts' common point: once
the error path genuinely gets handled, all three give the **same
result**. The third column shows where the distinction lives — once
the store is full, the three contracts are **indistinguishable**.
Choosing a contract changes nothing on the correct path; it is
entirely a decision about the error path.

## The Defect Does Not Disappear, It Moves

This lesson's boundary measurement is reading the two tables together,
and it declares none of the three contracts superior.

The defect is **singular**: an item got requested from an empty
store. The three contracts do not remove this defect, they only
choose **where it shows up**. A checked exception carries it into the
caller's **compilation** — all three layers have to write the error
path, but what they write is not required to be correct. An unchecked
exception carries it into **runtime**; no layer of the source gets
stained, but which call can fail cannot be seen by looking at the
source. A result type carries it into the **data layer**; it never
fails anywhere, but the wrong value keeps flowing.

In all three places, the one paying the price is a different party: in
the first, whoever **writes**; in the second, whoever **runs**; in the
third, whoever **reads the result**. The measure that has run through
this course since its first lesson takes its final shape here — where
a decision gets made decides where the defect shows up, and no choice
eliminates the defect.

## Summary

- A checked exception makes declaration mandatory in every layer of
  the call chain: only one of eight subsets compiles, and the
  smallest declaration set's size is three.
- An unchecked exception and a result type force no point at all; the
  chain compiles untouched, and the error path is invisible in the
  intermediate layers' source.
- What gets forced is declaring, not handling: a careless caller
  satisfies the requirement by writing an empty catch, and returns an
  empty value.
- The same empty store produces three separate defect shapes: an
  empty value, a runtime exception, and a `null` turned into data.
  Once the error path gets handled, the three contracts give the same
  result; once the store is full, they cannot even be told apart.
- Boundary measurement: none of the three contracts eliminates the
  defect. The defect is singular, and the contract only chooses where
  it shows up — at compilation, at throwing, or at reading.

## Course Wrap-Up

This course opened with a single question: when a name carries two
types, which one decides? Thirteen lessons asked this question of
thirteen separate language decisions, and got the answer every time
from an executed measurement. Gathered together, a single rule
emerges: **the behavior called looks at the runtime type, the name
chosen looks at the declared type** — and the boundary between the two
is invisible in the syntax.

| Lesson | Measured decision | Deciding party | Boundary measurement |
|---|---|---|---|
| Class, Object, and Constructor | overridden method called from the constructor chain | runtime type | no trap is born in a private or final method, all three compile to the same instruction |
| Access Modifiers | visibility: 10 of 16 pairs compile | declared type (compile time) | the member stays put at runtime, only a flag carries the distinction |
| Inheritance and Overriding | four of eight and four of eight decisions | four runtime type, four declared type | overriding is not born when the signature does not match, the deciding party switches |
| Abstract Classes and Interfaces | eight setups of the same capability, three stop compilation | the compiler, without asking which side | a capability carrying state cannot be built with an interface |
| Static Members and Initialization Blocks | static method selection and class-level state | declared type | the distinction cannot be observed once the two sides coincide |
| The final Keyword | reassignment, overriding, and widening bounds | declared type (compile time) | final does not freeze the referenced object |
| Inner and Local Classes | generated class files and hidden fields | the compiler, without it being written in the source | a static nested class carries no hidden field |
| Records and Enumerations | which side answers for the generated members | runtime type | a record's protection is shallow, an array component changes from outside |
| Generic Types | what the type parameter leaves behind at runtime | declared type, entirely | the signature attribute stays: what gets erased is not the information, it is the check |
| Bounded Wildcards | four container forms × two operations, four of eight | declared type, entirely | all four can be read as Object, all four can have null written to them |
| Exception Hierarchy | what the checked distinction forces: one of nine writings gets rejected | declared type for writing, runtime type for the branch | the virtual machine never checks the declaration |
| Try-with-Resources | closing order and the suppressed exception | the compiler, looking at the declared type | suppression's direction runs one way, closing never suppresses the body's |
| Exception Design | the decision point three contracts force: 3, 0, 0 | declared type (compile time) | none of the three contracts eliminates the defect, they move it |

The table's third column is this course's method; the fourth is where
each lesson bounds its own thesis. Read together, the course's real
claim shows through: **the difference is invisible in the syntax.**
`d.name()` and `d.label` get written with a two-character difference
and get answered by separate parties; `Store<Base>` and
`Store<String>` get written separately and become a single class; a
`throws` declaration sits in the source and never gets checked at
runtime.

A second reading comes from generics, and is the shared lesson of the
last five lessons. When a check gets moved to compile time, it does
not disappear, it only **relocates**; and once a path opens that slips
past that check, the defect shows up not where it was produced, but
much later, where it gets **read**. A value written through the raw
type falls at the reading line; without try-with-resources written, an
exception gets lost at the closing line; when the wrong contract gets
chosen, the defect turns into data.

In this course, classes always worked **alone**: a store, a
superclass, a subclass. The next course, **Standard Library and
Streams**, puts the same types inside collections and streams. There,
the type parameter travels not in a single store, but through
operations **chained** to one another; and the question "which side
decided" becomes askable again, because every link in a stream carries
its own declared type.
