RizTech Academy logo
RizTech Academy
Exceptions and Error HandlingLesson 4 of 620 min

Writing your own exceptions

Every exception you have thrown so far has been one of Java's. IllegalArgumentException is fine for "the caller passed rubbish". It stops being enough the moment a caller needs to distinguish which rubbish, or the support engineer reading the log needs to know which row of which file.

Writing your own is four lines. Making it earn its place is the rest of this lesson.

The message is most of the value

throw new RuntimeException("invalid");
invalid

That is what somebody will read at 2am, and it tells them nothing. Compare:

line 4: pincode [41120] must be six digits

A good exception message answers three things: what was wrong, what the value was, and what was expected. The brackets around the value matter more than they look — they make a trailing space or an empty string visible.

That message is achievable with IllegalArgumentException, and for a small program it is the right answer. Do not write a custom exception until you need one. You need one when a caller must catch it specifically, or when it needs to carry structured data.

Writing one

public class InvalidRowException extends RuntimeException {
    public InvalidRowException(String message) {
        super(message);
    }

    public InvalidRowException(String message, Throwable cause) {
        super(message, cause);
    }
}

Extend RuntimeException for unchecked — the default, per the last lesson. Extend Exception for checked, when you genuinely want the compiler to force every caller to decide.

Always provide the cause constructor, even if you do not use it today. The day you wrap something and there is nowhere to put the cause, you lose the Caused by section of every trace.

Name it for the problem, ending in Exception: InvalidRowException, InsufficientBalanceException, PincodeNotServiceableException. Not MyException, not DataError.

Carrying structured data

This is the real reason to write one. The message is for a human; the fields are for the code.

public class InvalidRowException extends TiffinDataException {
    private final String field;

    public InvalidRowException(int lineNumber, String field, String value, String rule) {
        super(lineNumber, "line %d: %s [%s] %s".formatted(lineNumber, field, value, rule));
        this.field = field;
    }

    public String field() {
        return field;
    }
}
line 4: pincode [41120] must be six digits
line=4 field=pincode

Now the caller can build a per-field error report, highlight the offending column in a UI, or count failures by field — none of which is possible by parsing a string.

Build the message in the constructor from the fields. Then the message and the data cannot disagree, which is the same discipline as equals and hashCode sharing a field list.

Collecting failures instead of stopping

The pattern this whole module has been building towards:

List<String> problems = new ArrayList<>();
int good = 0;
for (Row row : rows) {
    try {
        validate(row);
        good++;
    } catch (InvalidRowException e) {
        problems.add(e.getMessage());
    }
}
accepted 1, rejected 3
  line 2: name [] must not be blank
  line 3: pincode [4112] must be six digits
  line 4: tiffins [99] must be between 0 and 62

Reporting every bad row beats stopping at the first. A user fixing a four-thousand-row upload does not want to discover one problem per attempt. This is the difference between a script and a tool, and it is exactly what the capstone does.

Note that the try is inside the loop. Put it outside and you are back to stopping at the first failure.

A small hierarchy

Two or three related exceptions with a common parent let a caller choose its level of precision:

public abstract class TiffinDataException extends RuntimeException {
    private final int lineNumber;

    protected TiffinDataException(int lineNumber, String message) {
        super(message);
        this.lineNumber = lineNumber;
    }

    public int lineNumber() {
        return lineNumber;
    }
}

public class InvalidRowException extends TiffinDataException { ... }
public class DuplicateRowException extends TiffinDataException { ... }
caught as TiffinDataException: InvalidRowException at line 9
caught as TiffinDataException: DuplicateRowException at line 11

A caller that cares about the difference catches the specific type; one that just wants to report a bad file catches the parent and reads lineNumber().

Two or three types is a hierarchy. Fifteen is a design problem. If you find yourself writing an exception per validation rule, the rule belongs in a field rather than in a type name — which is what the field and rule parameters above are doing.

This is also a place where a sealed hierarchy of results, as in module 4's Result<T>, is often better than exceptions at all. Exceptions are for the exceptional; a row failing validation in a user-uploaded file is entirely expected.

Rules worth following

Do not extend Exception by default. Checked means every caller must handle it. Reserve that for failures a caller genuinely must decide about.

Do not extend Error, Throwable or RuntimeException directly when a standard type fits. IllegalArgumentException and IllegalStateException cover a great deal, and a reader already knows what they mean.

Do not put behaviour in an exception. It is a report of what happened, not a place to retry or log. An exception with a sendEmail() method is a design that will hurt.

Do not include the stack trace in the message. It is already there.

Do include identifiers. Which row, which customer, which file, which order. Not the whole record — and never a password, a token or anything else you would not want in a log file that gets emailed around.

Consider disabling the stack trace for expected exceptions. The four-argument RuntimeException constructor takes writableStackTrace; passing false makes construction much cheaper. Only do this when the exception is thrown in a hot loop and nobody needs the trace — a validation failure on thousands of rows is the case where it is worth it.

Check your work

What three things does a good message contain? What was wrong, what the value was, and what was expected. Brackets around the value make whitespace visible.

When should you write a custom exception? When a caller must catch it specifically, or when it needs to carry structured data. Not before.

Why always add the cause constructor? Without it you cannot preserve an original exception when wrapping, and every trace loses its Caused by section.

Why build the message inside the constructor? So the message and the fields cannot drift apart, the same discipline as equals and hashCode sharing a field list.

Where does the try go when validating many rows? Inside the loop. Outside, you stop at the first failure.

When is a hierarchy worth it? Two or three related types with a shared parent, so a caller can choose its precision. An exception per validation rule is a design problem — put the rule in a field.

Name three things not to put in an exception. Behaviour; the stack trace in the message; secrets such as passwords or tokens.

Practice 2, the message. "invalid" versus line 4: pincode [41120] must be six digits. The second names the location, the field, the actual value and the rule. Someone can fix the data from the message alone, without opening the code — which is the test.

Practice 4, collecting failures. With the try inside the loop, four rows give "accepted 1, rejected 3" and three specific messages. Moving the try outside the loop gives one message and no idea how many other rows are bad. Run both; the difference is what makes a tool usable.

Practice

  1. Write InvalidRowException. Message and cause constructors, extending RuntimeException. Throw it from a validation method.

  2. Improve a message. Start with "invalid". Add the field, then the value in brackets, then the rule, then the line number. At each step, ask whether somebody could fix the data without reading your code.

  3. Give it fields. Add lineNumber and field with accessors, and build the message from them in the constructor. Then write a caller that groups failures by field and counts them.

  4. Collect instead of stopping. Validate four rows where three are bad, with the try inside the loop. Then move the try outside and compare the output.

  5. Build a two-type hierarchy. TiffinDataException with InvalidRowException and DuplicateRowException under it. Write one caller that catches the parent and one that distinguishes.

  6. Harder — exception or result? Take the validation above and rewrite it returning Result<Row> from module 4 instead of throwing. Write down which version you would use for: a CSV uploaded by a user, a config file read at startup, and an internal method whose caller has already validated. The answer differs for each, and being able to say why is the point of this module.

Next: try-with-resources, and the exception that gets lost when you close a file by hand.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship