Low-level designCore6 min

Errors and exceptions

An error that's caught and ignored doesn't go away: it turns into wrong data. Decide what kind of failure each one is, handle it where something useful can be done, and let the rest fail loudly.

Watch it evolve1 / 7
The problem

A shop imports its prices

Each line is a product with a price; a helper turns "8.50" into cents.

80
Start
Stage 1
Final

1 · The situation

A shop imports its prices

A small online shop updates prices from a spreadsheet exported as lines of sku;name;price. A helper, Prices.cents(), turns "8.50" into 850 cents and throws on anything else. The importer's author decided one bad price shouldn't stop the whole import, so it catches the error and carries on:

A bad price becomes zeroJava
class PriceImporter {
    private final List<Product> products = new ArrayList<>();

    void importAll(List<String> lines) {
        for (String line : lines.subList(1, lines.size())) {
            String[] f = line.split(";", -1);
            int price;
            try {
                price = Prices.cents(f[2]);
            } catch (NumberFormatException e) {
                price = 0;
            }
            products.add(new Product(f[0], f[1], price));
        }
    }

    List<Product> products() {
        return products;
    }
}
*«record»Product− sku: String− name: String− priceCents: intPrices~ cents(String): intPriceImporter− products: List<Product>~ importAll(List<String>)~ products(): List<Product>

Drawn from the code: the importer keeps the products it creates and uses the Prices helper.

2 · The change

Two products, free of charge

This week's file has a price typed with a comma, 12,50, and a row with the price left blank:

Imported 4 products
Mug: $8.50
Scarf: $0.00
Lamp: $0.00
Bowl: $14.00

"Imported 4 products", two of them at $0.00. Nothing said anything was wrong.

The import "succeeded", and the shop now sells a scarf and a lamp for nothing. Catching an error and carrying on as if nothing happened is called swallowing it. The error didn't disappear; it became wrong data, found later by a customer, with no record of where it came from.

The author's instinct was right: one bad row shouldn't stop the rest. What went wrong is that the failure was hidden instead of handled.

3 · The fix

Name the failure, handle it where it can be, fail the rest loudly

A bad line gets its own error type: BadLineException in Java, BadLine in the other languages, with a message that says what's wrong. Parsing a line throws it instead of inventing a price.

Java
class PriceImporter {
    static final String HEADER = "sku;name;price";

    private final List<Product> products = new ArrayList<>();
    private final List<String> rejected = new ArrayList<>();

    // A missing header fails the whole import; a bad line is set aside and reported.
    void importAll(List<String> lines) {
        if (lines.isEmpty() || !lines.get(0).equals(HEADER)) {
            throw new IllegalArgumentException("the file has no header");
        }
        for (int i = 1; i < lines.size(); i++) {
            try {
                products.add(parse(lines.get(i)));
            } catch (BadLineException e) {
                rejected.add("Line " + (i + 1) + ": " + e.getMessage());
            }
        }
    }

    private Product parse(String line) throws BadLineException {
        String[] f = line.split(";", -1);
        if (f.length != 3) throw new BadLineException("expected 3 fields");
        if (f[2].isEmpty()) throw new BadLineException("missing price");
        try {
            return new Product(f[0], f[1], Prices.cents(f[2]));
        } catch (NumberFormatException e) {
            throw new BadLineException(e.getMessage(), e);
        }
    }

    List<Product> products() {
        return products;
    }

    List<String> rejected() {
        return rejected;
    }
}
*«create»«record»Product− sku: String− name: String− priceCents: intBadLineExceptionPrices~ cents(String): intPriceImporter~ HEADER: String− products: List<Product>− rejected: List<String>~ importAll(List<String>)− parse(String): Product~ products(): List<Product>~ rejected(): List<String>
Imported 2 products, rejected 2
Mug: $8.50
Bowl: $14.00
Line 3: "12,50" is not a price
Line 4: missing price
Import failed: the file has no header

Two products imported, two lines rejected with their reasons, and an empty file refused outright.

4 · Kinds of failure

Which kind of failure is it?

Kind

Example

Usual handling

Expected bad input

A price typed as 12,50

Report it with a clear message; skip it or ask again

Trouble outside the program

The file is missing; the network is down

Let it reach a layer that can retry, or tell the user

A bug

A null where none should be; an index out of range

Fail at once; fix the code, don't catch it

Failing early is a design choice called fail fast: report a problem at the point it's detected, instead of carrying on in a state that will cause a stranger failure later. The constructors on the page Classes, objects and state do the same: an invalid gift card is refused at once, not spent later.

5 · Exceptions or results

Throw, or return the failure?

An exception jumps out of the code that hit the problem, up through its callers, until some code catches it. A result value is returned like any other value, holding either the outcome or the error, and the caller must look inside it. Both are sound; languages lean different ways:

Language

The usual way

Notes

Java

Exceptions; Optional when "nothing" isn't an error

Checked exceptions are part of a method's signature

C++

Exceptions, or a value holding a result or an error

C++23 adds std::expected; some codebases avoid exceptions entirely

Python

Exceptions: try it, and catch the specific error

The language's own style is "easier to ask forgiveness than permission"

JavaScript

Exceptions, and rejected promises in async code

Pass the original as cause when wrapping

Java adds a twist: a checked exception, like BadLineException here, must be caught or declared by every caller, so the compiler won't let anyone forget it. The usual guideline: make it checked if callers can reasonably recover from it, and unchecked (a RuntimeException) if they can't, as with bugs.

6 · Where to catch

Rules of thumb

  • Catch where you can act. The import loop can skip and report a line; Prices.cents() can't, so it throws.
  • Catch the specific error. Catching every error also catches bugs, and hides them.
  • Keep the cause. When you wrap one error in another, carry the original along: throw new X(..., e) in Java, std::throw_with_nested(X(...)) in C++, new X(msg, { cause: e }) in JavaScript, raise X(...) from e in Python.
  • Never catch and do nothing. If ignoring really is right, say why in a comment, and still log it.
  • Say what went wrong, in the user's terms. "12,50" is not a price helps the person fixing the spreadsheet; a stack trace doesn't.

The error travels up to the importer, which can do something with it, and goes no further.

7 · In practice

A coin the machine doesn't take

In the vending-machine problem, a foreign coin is expected bad input: refuse it with a message and hand it back, rather than ignoring it or counting it as zero. Running out of a snack is a normal outcome, not an error at all.

Design the vending machine and decide what each failure is.

Check yourself

4 questions

1. What was wrong with catching the parse error and using a price of 0?
2. Where should a bad line be handled?
3. The file has no header line. What should the importer do?
4. In Java, when should an exception be checked?

Takeaways

Remember this

  • Catching an error and carrying on silently turns it into wrong data.
  • Name each failure with its own error type and a message in the user's terms.
  • Catch where something useful can be done; let the rest propagate.
  • Fail fast on anything that makes the rest meaningless, like a missing header or an invalid object.
  • Exceptions and result values both work; follow the language's usual style, and always keep the cause.