The previous lesson ended with code that worked but that laid bare its own limit: a catch with four standard types joined by |, an IllegalArgumentException used for three different things and a catch (IllegalStateException e) incapable of telling "the material is not available" from "the employee has reached their limit" without reading a free-text message.

That is the ceiling of standard exceptions. IllegalArgumentException describes a technical category: "the argument is no good". It says nothing about the business. And the only place where the specific information lives —which reference was duplicated, what the limit was, how many days it had been on loan— is a string of text nobody should be parsing.

In this lesson you build BiblioTech's own vocabulary. By the end, a catch (LoanLimitExceededException e) will not only catch exactly that failure and no other, but will be able to ask e.getLimit() and e.getEmployeeId() in order to decide whether to offer a reservation, to alert the supervisor or to suggest returning something first. The difference between reading a message and querying a piece of data is the difference between a log and a program that reacts.

Contents

  1. Why create your own exceptions
  2. How an exception is created
  3. Checked or unchecked: the criterion for choosing
  4. The four canonical constructors
  5. Adding your own fields and accessors
  6. Designing a domain hierarchy
  7. The common root and what it enables
  8. Naming conventions
  9. Message conventions
  10. Serialisation and serialVersionUID
  11. When NOT to create your own exception
  12. Advanced note: exceptions without a stack trace
  13. BiblioTech: the complete refactoring
  14. Common Mistakes and Tips
  15. Exercises

  1. Why create your own exceptions

Two reasons, and the second is the one that really changes the code.

Reason 1: naming the failure in the domain's language

Compare these two catches:

// With standard exceptions
try {
    manager.lend("BK-0001", "EMP-001", 12);
} catch (IllegalStateException e) {
    // What happened? Is the material on loan? Has the employee hit the limit?
    // Was the loan already returned? You have to read the MESSAGE to find out.
    if (e.getMessage().contains("limit")) {       // fragile, horrible, and it breaks on translation
        // ...
    }
}

// With domain exceptions
try {
    manager.lend("BK-0001", "EMP-001", 12);
} catch (MaterialNotAvailableException e) {
    offerReservation(e.getReference(), e.getExpectedReturnDay());
} catch (LoanLimitExceededException e) {
    showActiveLoans(e.getEmployeeId());
}

In the second version, the exception's type is the decision. Nothing needs interpreting: the compiler routes each failure to its handler. And the code reads like the business: "if the material is not available, offer a reservation".

That e.getMessage().contains("limit") in the first version is not an exaggerated example. It appears in real code, and it is a time bomb: the day somebody improves the message or translates it into another language, the logic stops working with no compilation error and no failing test, if the tests did not cover that path.

Reason 2: carrying structured data

This is the decisive reason. A standard exception only carries a String:

throw new IllegalStateException(
        "The employee EMP-001 (Marta Ruiz) already has 3 loans, the maximum is 3");

For whoever catches it to do anything with that data, they would have to extract it from the text. With your own exception, the data goes in fields:

throw new LoanLimitExceededException(employee, Employee.MAX_CONCURRENT_LOANS);

// And whoever catches it:
} catch (LoanLimitExceededException e) {
    String emp  = e.getEmployeeId();              // the identifier, not its name in a string
    int limit   = e.getLimit();                   // the number, not a piece of text
    int current = e.getCurrentLoans();

    System.out.printf("%s has %d of %d loans.%n",
            e.getEmployeeName(), current, limit);
    offerEarlyReturn(emp);                        // the data can be USED
}

The rule that summarises both reasons:

The message is for people. The fields are for the program. A well-designed exception offers both.

  1. How an exception is created

An exception is an ordinary class extending Exception or RuntimeException. In its minimal form:

package com.nexussoftware.bibliotech.domain;

/** Minimal version: just the name and the message. */
public class MaterialNotFoundException extends RuntimeException {

    public MaterialNotFoundException(String message) {
        super(message);
    }
}

And that is it. With those five lines you can write:

throw new MaterialNotFoundException("Material BK-9999 does not exist");

...and catch it specifically. But this minimal version wastes what makes custom exceptions useful: it carries no data and it does not offer the constructor with a cause. The following sections complete it.

What you should not do:

// BAD: extending Throwable directly
public class MyException extends Throwable { }      // legal, but never correct

// BAD: extending Error
public class MyException extends Error { }          // Error is for JVM failures

// BAD: extending a very specific standard exception for no reason
public class MyException extends NumberFormatException { }   // inherits alien semantics

Extending Throwable directly produces an exception that is neither an Error nor an Exception, so it escapes every usual catch (Exception e) without being a JVM error. It has no practical use whatsoever.

  1. Checked or unchecked: the criterion for choosing

The most important decision when creating an exception, and the one most often got wrong.

Extend Exception (checked) Extend RuntimeException (unchecked)
The compiler forces you to Catch or declare on every call Nothing
Effect on signatures Pollutes them all the way up the chain None
Effect on lambdas Cannot be thrown from Function, Consumer, etc. No problem
Risk That people silence it with empty catch blocks That nobody catches it and it reaches the user
When to choose it The caller almost always can and should do something other than propagate It is a business rule or a programming error

The deciding question:

Is the caller going to do something other than "propagate" in most cases?

  • Yes, almost always → checked. A real example: "the catalogue file does not exist", where higher up the decision is to start with an empty catalogue.
  • No, hardly ever → unchecked. Example: "that reference does not exist in the catalogue", where the normal thing is to let it go up to the presentation layer.

Applied to BiblioTech, with the explicit reasoning behind each decision:

Exception Type Reason
MaterialNotFoundException Unchecked Forcing a try on every search would be unbearable, and normally it goes up to presentation
DuplicateReferenceException Unchecked It is preventable: catalog.exists(ref) is there to check beforehand
LoanLimitExceededException Unchecked A business rule checkable with canBorrow()
MaterialNotAvailableException Unchecked Likewise, checkable with isAvailable()
LoanAlreadyReturnedException Unchecked A sequencing error: it should not happen with well-written code
CatalogNotAccessibleException Checked An external failure (disk, permissions) and the application does have an alternative: start empty

Notice the pattern: all the business rules are unchecked; the only checked one is the one representing an environment failure. It is the pragmatic stance you announced in 06-01, and it coincides with what Spring, Hibernate and practically the whole modern ecosystem do.

An important nuance that reinforces the decision: a checked exception in an interface method binds all its implementations and all its users. If Lendable.lend() declared throws MaterialNotAvailableException, every lambda, every forEach and every future implementation would carry it. The overriding rule from 06-03 makes it irreversible: once published, you cannot remove it without breaking somebody... but you cannot add it later without breaking everybody either.

  1. The four canonical constructors

Throwable defines four public constructors, and a well-designed exception offers them all —or at least the first three:

package com.nexussoftware.bibliotech.domain;

/** Exception with the four canonical constructors. */
public class BiblioTechException extends RuntimeException {

    /** 1. No message, no cause. Not very useful, but it completes the contract. */
    public BiblioTechException() {
        super();
    }

    /** 2. With a message. The most used when ORIGINATING a failure. */
    public BiblioTechException(String message) {
        super(message);
    }

    /** 3. With a message and a cause. The one you must use when WRAPPING (06-03). */
    public BiblioTechException(String message, Throwable cause) {
        super(message, cause);
    }

    /** 4. Cause only: the message is derived from cause.toString(). */
    public BiblioTechException(Throwable cause) {
        super(cause);
    }
}

Why it is worth offering them all:

Constructor Needed when
() Rarely. Some framework asks for it by reflection (10-03)
(String) You originate the failure yourself, with an explanatory message
(String, Throwable) You wrap another exception adding context. Essential
(Throwable) You wrap with nothing to add. Not much recommended: you almost always have something better to say than cause.toString()

The practical consequence of omitting the third is that somebody will end up losing a cause. If your MaterialNotFoundException only accepts a String, whoever needs to wrap a low-level failure will have to choose between using another exception type or discarding the cause. And you already know how that ends.

Throwable also has a fifth protected constructor, which you will see in section 12:

protected Throwable(String message, Throwable cause,
                    boolean enableSuppression, boolean writableStackTrace)

  1. Adding your own fields and accessors

Here lies the real power. An exception is an object: it can have whatever state you want.

package com.nexussoftware.bibliotech.domain;

/**
 * The requested material does not exist in the catalogue.
 *
 * It carries the searched reference so that whoever catches it can
 * offer it in a message, look for alternatives or record it, without
 * having to parse the text message.
 */
public class MaterialNotFoundException extends BiblioTechException {

    private static final long serialVersionUID = 1L;

    /** An exception's fields must be final: an exception is immutable. */
    private final String searchedReference;
    private final int catalogSize;

    public MaterialNotFoundException(String searchedReference, int catalogSize) {
        // The message is built from the data: a single source of truth
        super("There is no material with the reference '" + searchedReference
                + "' (the catalogue has " + catalogSize + " materials)");
        this.searchedReference = searchedReference;
        this.catalogSize = catalogSize;
    }

    /** Variant for when another failure is wrapped. */
    public MaterialNotFoundException(String searchedReference, int catalogSize,
                                     Throwable cause) {
        super("There is no material with the reference '" + searchedReference
                + "' (the catalogue has " + catalogSize + " materials)", cause);
        this.searchedReference = searchedReference;
        this.catalogSize = catalogSize;
    }

    public String getSearchedReference() { return searchedReference; }
    public int getCatalogSize()          { return catalogSize; }

    /** Convenience method: the exception can contribute useful logic. */
    public boolean isCatalogEmpty() {
        return catalogSize == 0;
    }
}

And what that enables at the catch site:

try {
    Material m = catalog.getByReference(userInput);
    manager.lend(m.getReference(), "EMP-001", day);
} catch (MaterialNotFoundException e) {

    if (e.isCatalogEmpty()) {
        System.out.println("The catalogue is empty. Have you loaded the materials file?");
    } else {
        System.out.println("'" + e.getSearchedReference() + "' was not found.");
        // The DATA can be used to help: suggestions by prefix
        List<Material> similar = catalog.findByPrefix(
                e.getSearchedReference().substring(0, 3));
        if (!similar.isEmpty()) {
            System.out.println("Did you perhaps mean one of these?");
            similar.forEach(m -> System.out.println("  " + m.getReference()));
        }
    }
}

Three design rules for an exception's fields:

  1. final always. An exception is an immutable object describing something that happened. Nobody should be able to modify it afterwards.
  2. Build the message from the fields, in the super(...). That way the text and the data can never contradict each other.
  3. Be careful about storing large objects. If you store the whole Material instead of its reference, the exception keeps that reference alive for as long as it exists, and stops the collector taking the object away. With a Material it is irrelevant; with a whole collection or an open stream, it is a memory leak. Store identifiers, not object graphs.

  1. Designing a domain hierarchy

A single exception already helps. A hierarchy helps far more, because it lets you catch at different levels of granularity as each layer needs.

This is BiblioTech's:

flowchart TB
    RE["RuntimeException<br/>(from the JDK)"]
    RE --> BT["BiblioTechException<br/>domain ROOT (abstract)"]

    BT --> CAT["CatalogException<br/>catalogue failures"]
    BT --> PRE["LoanException<br/>loan failures"]

    CAT --> MNE["MaterialNotFoundException<br/>+ getSearchedReference()"]
    CAT --> RDE["DuplicateReferenceException<br/>+ getDuplicateValue()<br/>+ getExistingTitle()"]

    PRE --> MND["MaterialNotAvailableException<br/>+ getReference()<br/>+ getExpectedReturnDay()"]
    PRE --> LPE["LoanLimitExceededException<br/>+ getEmployeeId()<br/>+ getLimit()"]
    PRE --> PYD["LoanAlreadyReturnedException<br/>+ getLoanReference()<br/>+ getOriginalReturnDay()"]

The three decisions in this design:

A common abstract root, BiblioTechException. It allows catch (BiblioTechException e) to trap any domain failure in one go —which is what the error boundary will do in 06-07— without dragging in the NullPointerExceptions that really are bugs. It is abstract because nobody should throw it directly: it would be as uninformative as the mute false we are eliminating.

Two intermediate levels, CatalogException and LoanException, for medium-granularity catches: "any catalogue failure" without having to enumerate its two children.

Concrete leaves with data, which are the ones thrown and the ones carrying the useful fields.

The catch granularity this enables, from broadest to finest:

// Level 1: any BiblioTech domain failure (error boundary)
catch (BiblioTechException e) { ... }

// Level 2: any failure related to loans
catch (LoanException e) { ... }

// Level 3: this specific failure, with its data
catch (LoanLimitExceededException e) { use(e.getLimit()); }

And remember the ordering rule from 06-02: if you combine several levels in the same try, from most specific to most general, or it does not compile.

try {
    manager.lend(reference, employeeId, day);
} catch (LoanLimitExceededException e) {            // leaf
    offerEarlyReturn(e.getEmployeeId());
} catch (LoanException e) {                          // branch
    System.out.println("The loan could not be completed: " + e.getMessage());
} catch (BiblioTechException e) {                    // root
    System.out.println("BiblioTech failure: " + e.getMessage());
}

A warning about the size of the hierarchy: do not make it deeper than you are going to use. Three levels is a reasonable maximum for an application of this size. A six-level hierarchy with forty classes, half of which nobody ever catches separately, is pure ceremony. The practical test: if you are never going to write a catch of an intermediate level, that level is surplus.

  1. The common root and what it enables

It is worth pausing on why the common root is the most valuable piece of the hierarchy. These are the three uses that justify it:

1. The error boundary in main (which you will build in 06-07):

try {
    application.start();
} catch (BiblioTechException e) {
    // EXPECTED domain failure: a clean message for the user
    System.out.println("Operation not completed: " + e.getMessage());
} catch (RuntimeException e) {
    // UNEXPECTED failure: it is a bug. Generic message + full log entry
    System.out.println("Internal error. Check the log (incident " + id + ").");
    logger.log(Level.SEVERE, "Unhandled failure", e);
}

Without the common root you could not tell "the user asked for something impossible" from "we have a bug". With it, the first case produces a friendly message and the second a logged incident.

2. Shared behaviour. The root can contribute methods to the whole family:

public abstract class BiblioTechException extends RuntimeException {

    /** Stable code for the log and for support documentation. */
    public String getCode() {
        return getClass().getSimpleName().replace("Exception", "").toUpperCase();
    }

    /**
     * Is this a failure the user can fix themselves?
     * Subclasses redefine it when that is not the case.
     */
    public boolean isUserFixable() {
        return true;
    }

    /** Message suitable for showing on screen, with no internal details. */
    public String getUserMessage() {
        return getMessage();
    }
}

3. A single point of evolution. If tomorrow you want every BiblioTech exception to carry an operation identifier for correlating logs, you add it in the root and all seven inherit it.

  1. Naming conventions

Four rules the whole Java ecosystem follows:

Rule Good Bad
Exception suffix MaterialNotFoundException MaterialNotFound, MaterialError
Describes the problem, not the solution LoanLimitExceededException MustReturnSomethingException
Specific, not generic DuplicateReferenceException InvalidDataException
No project prefix on the leaves MaterialNotFoundException BiblioTechMaterialNotFoundException

About the last one: the prefix is fine on the root (BiblioTechException) because there it identifies the family. Repeating it on every leaf is noise, since the package already does that job.

And a note about Error as a suffix: do not use it. In Java, Error means "unrecoverable JVM failure". Calling something that extends RuntimeException MaterialError is misleading for anyone who knows the hierarchy.

  1. Message conventions

A good exception message answers three questions:

  1. What happened, in the domain's language.
  2. With what specific data.
  3. What was expected, when it is not obvious.
Message Assessment
"Error" Useless
"Invalid reference" The data is missing
"Invalid reference: BK-99" What was expected is missing
"The reference must have the format BK-NNNN, and it was: 'BK-99'" Complete

Applied to the hierarchy:

// What + data + expected
"The employee EMP-001 (Marta Ruiz) has 3 active loans and the limit is 3"

// What + data + context useful for acting
"The material BK-0001 ('Effective Java') is on loan to EMP-002 since day 12"

// What + the two colliding pieces of data
"A material with the reference BK-0001 already exists: 'Effective Java'"

Three things that must not go in an exception message:

  • Sensitive data: passwords, tokens, card numbers, personal data that is not strictly necessary. The message will end up in a log, and possibly in an automated email or on somebody's screen. In 06-07 there is a formal warning about this.
  • The suggested solution, when it depends on the context. "The material is not available. Return it first." assumes the reader is the person who has it on loan, and that need not be so. The suggestion is the presentation layer's business, since it does know the user.
  • Trailing punctuation or line breaks. The message is concatenated in logs and stack traces; a line break breaks the one-line-per-event format that analysis systems depend on.

  1. Serialisation and serialVersionUID

Throwable implements java.io.Serializable, which means that all exceptions are serialisable: they can be turned into bytes and sent over the network or saved to disk. That is what allows an exception thrown on a server to reach a remote client.

Two practical consequences come out of that.

1. The compiler warning about serialVersionUID. If you compile with -Xlint:serial, you will see:

warning: [serial] serializable class MaterialNotFoundException has no definition
of serialVersionUID

It is a minor warning but it is solved with one line:

public class MaterialNotFoundException extends BiblioTechException {
    private static final long serialVersionUID = 1L;
    // ...
}

That number identifies the version of the class for serialisation purposes. If you do not declare it, Java computes it automatically from the class structure, and it changes every time you modify a field or a method, which breaks compatibility with previously serialised bytes. By declaring it explicitly you control when it changes. The complete mechanics of serialisation is the subject of 07-05.

2. Your own fields must be serialisable. If your exception stores an object of a class that does not implement Serializable, serialisation will fail with NotSerializableException:

// PROBLEMATIC if the exception travels over the network and Employee is not Serializable
private final Employee employee;

// SAFE: String and int are always serialisable
private final String employeeId;
private final int limit;

It is one more argument in favour of storing identifiers instead of complete objects, on top of the memory one you already saw. In a console application like BiblioTech this never actually shows up, but it is the kind of decision that avoids a problem the day the application grows.

  1. When NOT to create your own exception

Creating exceptions has a cost: more classes, more to document, more to learn. The rule:

If a standard exception already exists whose semantics are exactly yours and you do not need to carry data, use it.

The standard ones that cover most cases:

Standard exception Semantics Example in BiblioTech
IllegalArgumentException Invalid argument, with no business meaning day < 1; incorrect reference format
IllegalStateException The object is not in a fit condition, with no business meaning An iterator already closed
NullPointerException Required argument null Objects.requireNonNull in any constructor
UnsupportedOperationException Operation not supported by this implementation add on a read-only catalogue
NoSuchElementException No more elements, or not found Iterating over an empty reservation queue
IndexOutOfBoundsException Index out of range An invalid position in a listing

The decision test, in three questions:

  1. Does the failure have a name in the language of the business? If Marta Ruiz would say "that book is already on loan", it deserves its own exception. If she would say "the program has got it wrong", it does not.
  2. Does it need to carry structured data? If whoever catches it is going to want the object or the number, your own. If they are only going to show the message, standard.
  3. Is anybody going to catch it specifically? If throughout the system it will always end up in a generic catch, your own adds nothing.

Applied to BiblioTech, this is the resulting mix, which is what is normal in a healthy project:

// STANDARD: technical validation with no business meaning
Objects.requireNonNull(reference, "The reference cannot be null");
if (day < 1) {
    throw new IllegalArgumentException("The day must be 1 or later, and it was: " + day);
}
if (!reference.matches("BK-\\d{4}")) {
    throw new IllegalArgumentException("Invalid reference format: " + reference);
}

// YOUR OWN: a business rule with a name and with data
if (!material.isAvailable()) {
    throw new MaterialNotAvailableException(reference, material.getTitle(),
                                            material.getExpectedReturnDay());
}

A frequent beginner's mistake at the other extreme: creating NullNameException, InvalidYearException, EmptyTitleException... one exception per field. That is replacing a standard, well-known vocabulary with one of your own that nobody knows, gaining nothing. Technical argument validations use standard exceptions; business rules use yours.

  1. Advanced note: exceptions without a stack trace

In 06-02 you measured that the expensive part of an exception is not throwing it or catching it, but building it, because the Throwable constructor calls fillInStackTrace() to capture the whole stack.

For 99.9% of cases that is irrelevant: if something fails once in a thousand operations, the cost is unnoticeable. But there is a legitimate case where it does matter: an exception used as a very frequent control signal in a hot loop, where the stack trace is never read.

Throwable offers a protected constructor with two extra parameters:

protected Throwable(String message, Throwable cause,
                    boolean enableSuppression,      // does it accept suppressed exceptions? (06-06)
                    boolean writableStackTrace)     // does it capture the stack?

With writableStackTrace = false, the stack is not captured and the exception is built almost for free:

package com.nexussoftware.bibliotech.service;

/**
 * Signal exception, with no stack trace.
 *
 * WARNING: use ONLY when the three conditions hold:
 *   1. It is thrown in a very hot loop (thousands of times per second).
 *   2. It is caught immediately in the same method or very close by.
 *   3. The stack trace contributes NOTHING, because the throwing point is unique.
 *
 * If the three do not hold, this optimisation only manages to make a real
 * failure impossible to diagnose. It is the definition of premature optimisation.
 */
public class SearchInterruptedException extends RuntimeException {

    private static final long serialVersionUID = 1L;

    private final int position;

    public SearchInterruptedException(int position) {
        //      message, cause, suppression, stackTrace
        super("Search interrupted at position " + position, null, false, false);
        this.position = position;
    }

    public int getPosition() { return position; }
}

And an even more extreme variant, the pre-built exception as a constant:

/** A single reused instance: zero memory allocations. */
private static final SearchInterruptedException SIGNAL =
        new SearchInterruptedException(-1);

// ...
throw SIGNAL;                         // builds nothing

The numbers from 06-02 back the technique up: an exception without a stack trace cost 1.5 times an if, against the 70 times of a complete one.

And now the warning, which is more important than the technique:

This is a last-resort optimisation. An exception without a stack trace that reaches production by an unforeseen path is impossible to diagnose: you will see the class and the message, and not one single line of where it happened. And if you also reuse it as a constant, the message will always be the same even when the context is different.

The JDK itself uses this technique in very specific and controlled places, and reactive programming libraries employ it for internal signals. In application code like BiblioTech, you do not need it. It is here so that you recognise it when you see it and so that you understand where an exception's cost comes from.

  1. BiblioTech: the complete refactoring

The whole hierarchy, ready to use. First the root:

package com.nexussoftware.bibliotech.domain;

/**
 * Root of all BiblioTech domain exceptions.
 *
 * It is abstract because nobody should throw it directly: it would be as
 * uninformative as the mute 'false' this module is eliminating.
 *
 * It is UNCHECKED (it extends RuntimeException) because it represents business
 * rules the caller normally cannot solve on the spot, and because forcing them
 * to be declared would pollute the whole application, lambdas included.
 */
public abstract class BiblioTechException extends RuntimeException {

    private static final long serialVersionUID = 1L;

    protected BiblioTechException(String message) {
        super(message);
    }

    protected BiblioTechException(String message, Throwable cause) {
        super(message, cause);
    }

    /**
     * Stable code for the failure, derived from the class name.
     * Useful for the log and for support documentation:
     * MATERIALNOTFOUND, LOANLIMITEXCEEDED, etc.
     */
    public String getCode() {
        return getClass().getSimpleName()
                .replace("Exception", "")
                .toUpperCase();
    }

    /**
     * Can the user fix this themselves?
     * By default yes; subclasses representing system failures redefine it.
     */
    public boolean isUserFixable() {
        return true;
    }
}

The two intermediate branches:

package com.nexussoftware.bibliotech.domain;

/** Failures related to the catalogue of materials. */
public abstract class CatalogException extends BiblioTechException {

    private static final long serialVersionUID = 1L;

    protected CatalogException(String message) { super(message); }

    protected CatalogException(String message, Throwable cause) { super(message, cause); }
}
package com.nexussoftware.bibliotech.domain;

/** Failures related to loans and returns. */
public abstract class LoanException extends BiblioTechException {

    private static final long serialVersionUID = 1L;

    protected LoanException(String message) { super(message); }

    protected LoanException(String message, Throwable cause) { super(message, cause); }
}

And the five concrete leaves, each with its data:

package com.nexussoftware.bibliotech.domain;

/** There is no material with the searched reference. */
public class MaterialNotFoundException extends CatalogException {

    private static final long serialVersionUID = 1L;

    private final String searchedReference;
    private final int catalogSize;

    public MaterialNotFoundException(String searchedReference, int catalogSize) {
        super("There is no material with the reference '" + searchedReference
                + "' (the catalogue has " + catalogSize + " materials)");
        this.searchedReference = searchedReference;
        this.catalogSize = catalogSize;
    }

    public String getSearchedReference() { return searchedReference; }
    public int getCatalogSize()          { return catalogSize; }
    public boolean isCatalogEmpty()      { return catalogSize == 0; }
}
package com.nexussoftware.bibliotech.domain;

/** An attempt to register a material with a reference or an ISBN that already exist. */
public class DuplicateReferenceException extends CatalogException {

    private static final long serialVersionUID = 1L;

    /** Kind of identifier that collides. An enum (04-07) avoids magic strings. */
    public enum Kind { REFERENCE, ISBN }

    private final Kind kind;
    private final String duplicateValue;
    private final String existingTitle;

    public DuplicateReferenceException(Kind kind, String duplicateValue, String existingTitle) {
        super("A material already exists with " + (kind == Kind.ISBN ? "the ISBN " : "the reference ")
                + duplicateValue + ": '" + existingTitle + "'");
        this.kind = kind;
        this.duplicateValue = duplicateValue;
        this.existingTitle = existingTitle;
    }

    public Kind getKind()             { return kind; }
    public String getDuplicateValue() { return duplicateValue; }
    public String getExistingTitle()  { return existingTitle; }
}
package com.nexussoftware.bibliotech.domain;

/** The material exists but is on loan to another employee. */
public class MaterialNotAvailableException extends LoanException {

    private static final long serialVersionUID = 1L;

    private final String reference;
    private final String title;
    private final String currentBorrowerId;
    private final int expectedReturnDay;

    public MaterialNotAvailableException(String reference, String title,
                                         String currentBorrowerId, int expectedReturnDay) {
        super("The material " + reference + " ('" + title + "') is on loan to "
                + currentBorrowerId + " and its return is expected on day " + expectedReturnDay);
        this.reference = reference;
        this.title = title;
        this.currentBorrowerId = currentBorrowerId;
        this.expectedReturnDay = expectedReturnDay;
    }

    public String getReference()         { return reference; }
    public String getTitle()             { return title; }
    public String getCurrentBorrowerId() { return currentBorrowerId; }
    public int getExpectedReturnDay()    { return expectedReturnDay; }

    /** Days remaining until the material is free again. */
    public int waitDays(int currentDay) {
        return Math.max(0, expectedReturnDay - currentDay);
    }
}
package com.nexussoftware.bibliotech.domain;

/** The employee has reached the maximum of concurrent loans. */
public class LoanLimitExceededException extends LoanException {

    private static final long serialVersionUID = 1L;

    private final String employeeId;
    private final String employeeName;
    private final int currentLoans;
    private final int limit;

    public LoanLimitExceededException(String employeeId, String employeeName,
                                      int currentLoans, int limit) {
        super("The employee " + employeeId + " (" + employeeName + ") has "
                + currentLoans + " active loans and the limit is " + limit);
        this.employeeId = employeeId;
        this.employeeName = employeeName;
        this.currentLoans = currentLoans;
        this.limit = limit;
    }

    public String getEmployeeId()   { return employeeId; }
    public String getEmployeeName() { return employeeName; }
    public int getCurrentLoans()    { return currentLoans; }
    public int getLimit()           { return limit; }

    /** How many materials must be returned before another can be borrowed. */
    public int returnsNeeded() {
        return Math.max(1, currentLoans - limit + 1);
    }
}
package com.nexussoftware.bibliotech.domain;

/** An attempt to return a loan that had already been returned. */
public class LoanAlreadyReturnedException extends LoanException {

    private static final long serialVersionUID = 1L;

    private final String loanReference;
    private final int originalReturnDay;

    public LoanAlreadyReturnedException(String loanReference, int originalReturnDay) {
        super("The loan " + loanReference + " was already returned on day "
                + originalReturnDay);
        this.loanReference = loanReference;
        this.originalReturnDay = originalReturnDay;
    }

    public String getLoanReference()  { return loanReference; }
    public int getOriginalReturnDay() { return originalReturnDay; }

    /**
     * This failure almost always indicates a sequencing problem in the code
     * or a double click by the user, not something the user can "fix".
     */
    @Override
    public boolean isUserFixable() {
        return false;
    }
}

And the only checked one in the family, which does not hang from BiblioTechException precisely because its nature is different:

package com.nexussoftware.bibliotech.service;

/**
 * The persisted catalogue cannot be read or written.
 *
 * It is CHECKED because it represents an environment failure (disk, permissions,
 * missing file) for which the application DOES have a reasonable alternative:
 * starting with an empty catalogue. Forcing that to be decided explicitly is
 * exactly what we want.
 *
 * It does not extend BiblioTechException because that root is unchecked and
 * represents business rules; this is an infrastructure failure.
 * Module 7 will develop the I/O part.
 */
public class CatalogNotAccessibleException extends Exception {

    private static final long serialVersionUID = 1L;

    private final String path;

    public CatalogNotAccessibleException(String path, Throwable cause) {
        super("Could not access the catalogue at '" + path + "'", cause);
        this.path = path;
    }

    public String getPath() { return path; }
}

Now the refactored Catalog:

package com.nexussoftware.bibliotech.service;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;

import com.nexussoftware.bibliotech.domain.Book;
import com.nexussoftware.bibliotech.domain.Material;
import com.nexussoftware.bibliotech.domain.MaterialNotFoundException;
import com.nexussoftware.bibliotech.domain.DuplicateReferenceException;

/** BiblioTech's catalogue with domain exceptions. */
public class Catalog {

    private final List<Material> materials = new ArrayList<>();
    private final Map<String, Material> indexByReference = new HashMap<>();
    private final Map<String, String> titlesByIsbn = new HashMap<>();

    /**
     * Registers a material.
     *
     * @throws NullPointerException           if the material is null (technical failure)
     * @throws DuplicateReferenceException    if the reference or the ISBN already exist
     */
    public void register(Material material) {
        // Technical validation: STANDARD exception
        Objects.requireNonNull(material, "The material to register cannot be null");

        String reference = material.getReference();

        // Business rule: OUR OWN exception, with data
        Material existing = indexByReference.get(reference);
        if (existing != null) {
            throw new DuplicateReferenceException(
                    DuplicateReferenceException.Kind.REFERENCE,
                    reference, existing.getTitle());
        }

        if (material instanceof Book book) {
            String titleWithThatIsbn = titlesByIsbn.get(book.getIsbn());
            if (titleWithThatIsbn != null) {
                throw new DuplicateReferenceException(
                        DuplicateReferenceException.Kind.ISBN,
                        book.getIsbn(), titleWithThatIsbn);
            }
            titlesByIsbn.put(book.getIsbn(), book.getTitle());
        }

        materials.add(material);
        indexByReference.put(reference, material);
    }

    /**
     * Gets a material REQUIRING that it exists. Never returns null.
     *
     * @throws MaterialNotFoundException if it does not exist
     */
    public Material getByReference(String reference) {
        Objects.requireNonNull(reference, "The reference cannot be null");

        Material found = indexByReference.get(reference);
        if (found == null) {
            throw new MaterialNotFoundException(reference, materials.size());
        }
        return found;
    }

    /** Searches ALLOWING that it does not exist. Never returns null: returns Optional. */
    public Optional<Material> findByReference(String reference) {
        if (reference == null) { return Optional.empty(); }
        return Optional.ofNullable(indexByReference.get(reference));
    }

    public List<Material> findByPrefix(String prefix) {
        List<Material> result = new ArrayList<>();
        for (Material m : materials) {
            if (m.getReference().startsWith(prefix)) {
                result.add(m);
            }
        }
        return result;
    }

    public boolean exists(String reference) {
        return reference != null && indexByReference.containsKey(reference);
    }

    public int size()               { return materials.size(); }
    public List<Material> list()    { return List.copyOf(materials); }
}

And the demonstration that shows what we have gained:

package com.nexussoftware.bibliotech.presentation;

import com.nexussoftware.bibliotech.domain.*;
import com.nexussoftware.bibliotech.service.Catalog;
import com.nexussoftware.bibliotech.service.LoanManager;

/**
 * Demonstrates the difference between catching by TYPE with structured data
 * and catching standard types by reading the message.
 */
public class DomainExceptionsDemo {

    public static void main(String[] args) {
        Catalog catalog = new Catalog();
        catalog.register(new Book("BK-0001", "Effective Java", "Bloch", 2018, "978-0000000001"));
        catalog.register(new Book("BK-0002", "Design Patterns", "GoF", 1994, "978-0000000002"));
        catalog.register(new Book("BK-0003", "Refactoring", "Fowler", 1999, "978-0000000003"));

        LoanManager manager = new LoanManager(catalog);
        manager.enrol(new Employee("Marta Ruiz", "EMP-001"));
        manager.enrol(new Employee("Diego Alonso", "EMP-002"));

        // Marta uses up her allowance
        manager.lend("BK-0001", "EMP-001", 10);
        manager.lend("BK-0002", "EMP-001", 10);
        manager.lend("BK-0003", "EMP-001", 10);

        System.out.println("=== Failures, each with ITS handler ===\n");

        operate(manager, catalog, "BK-9999", "EMP-001", 12);   // not found
        operate(manager, catalog, "BK-0001", "EMP-002", 12);   // not available
        operate(manager, catalog, "BK-0001", "EMP-001", 12);   // not available (and limit)

        System.out.println("\n=== Duplicates ===\n");
        register(catalog, new Book("BK-0001", "Another title", "X", 2020, "978-0000000009"));
        register(catalog, new Book("BK-0004", "Copy", "Bloch", 2018, "978-0000000001"));

        System.out.println("\n=== Catching by the ROOT: the whole domain in one go ===\n");
        try {
            catalog.getByReference("BK-7777");
        } catch (BiblioTechException e) {
            System.out.println("Code: " + e.getCode());
            System.out.println("Message: " + e.getMessage());
            System.out.println("User fixable? " + e.isUserFixable());
        }
    }

    /** Each kind of failure is handled DIFFERENTLY using its data. */
    private static void operate(LoanManager manager, Catalog catalog,
                                String reference, String employeeId, int day) {
        System.out.println("--- lend(" + reference + ", " + employeeId + ") ---");
        try {
            manager.lend(reference, employeeId, day);
            System.out.println("  OK");

        } catch (MaterialNotFoundException e) {
            // Uses the exception's DATA to help the user
            System.out.println("  '" + e.getSearchedReference() + "' was not found.");
            if (e.isCatalogEmpty()) {
                System.out.println("  The catalogue is empty: load the materials first.");
            } else {
                var similar = catalog.findByPrefix(
                        e.getSearchedReference().substring(0, 3));
                System.out.println("  Materials with a similar prefix: " + similar.size());
            }

        } catch (MaterialNotAvailableException e) {
            System.out.println("  '" + e.getTitle() + "' is held by " + e.getCurrentBorrowerId() + ".");
            System.out.println("  Back in " + e.waitDays(day) + " days.");
            System.out.println("  -> Added to the reservation queue for " + e.getReference());

        } catch (LoanLimitExceededException e) {
            System.out.println("  " + e.getEmployeeName() + " has "
                    + e.getCurrentLoans() + "/" + e.getLimit() + " loans.");
            System.out.println("  They must return " + e.returnsNeeded()
                    + " material(s) before borrowing another.");
        }
    }

    private static void register(Catalog catalog, Material material) {
        try {
            catalog.register(material);
            System.out.println("Registered: " + material.getReference());
        } catch (DuplicateReferenceException e) {
            // The KIND of duplicate allows a precise message, with no text parsing
            String whatCollides = (e.getKind() == DuplicateReferenceException.Kind.ISBN)
                    ? "The ISBN" : "The reference";
            System.out.println(whatCollides + " " + e.getDuplicateValue()
                    + " is already used by '" + e.getExistingTitle() + "'. Registration rejected.");
        }
    }
}

Output:

=== Failures, each with ITS handler ===

--- lend(BK-9999, EMP-001) ---
  'BK-9999' was not found.
  Materials with a similar prefix: 3
--- lend(BK-0001, EMP-002) ---
  'Effective Java' is held by EMP-001.
  Back in 13 days.
  -> Added to the reservation queue for BK-0001
--- lend(BK-0001, EMP-001) ---
  'Effective Java' is held by EMP-001.
  Back in 13 days.
  -> Added to the reservation queue for BK-0001

=== Duplicates ===

The reference BK-0001 is already used by 'Effective Java'. Registration rejected.
The ISBN 978-0000000001 is already used by 'Effective Java'. Registration rejected.

=== Catching by the ROOT: the whole domain in one go ===

Code: MATERIALNOTFOUND
Message: There is no material with the reference 'BK-7777' (the catalogue has 3 materials)
User fixable? true

Compare this output with the one from 06-03. There, each failure produced a line of text that was only good for reading. Here, each failure triggers a different, specific reaction: looking for similar materials, offering a reservation with the waiting days calculated, or telling the employee how many materials they must return. All that logic is possible because the data comes in fields, not in a string.

Common Mistakes and Tips

Creating an exception for every validated field. NullNameException, InvalidYearException, EmptyTitleException... You are replacing a standard, well-known vocabulary with one of your own that nobody knows. Technical validations use IllegalArgumentException and NullPointerException; business rules use yours.

Making business exceptions checked. They pollute the signatures of the whole application, stop you throwing them from lambdas and push people towards the empty catch. Reserve checked ones for environment failures where the caller is going to do something other than propagate.

Forgetting the (String, Throwable) constructor. Without it, whoever needs to wrap a failure will have to discard the cause or use another type. It is the direct route to the mutilated stack traces of 06-03.

Non-final fields. An exception describes something that happened: nobody should be able to modify it after it is thrown.

Storing large objects in the exception. It keeps alive references the collector cannot free, and it breaks serialisation if those objects are not Serializable. Store identifiers.

Parsing the message to take decisions. if (e.getMessage().contains("limit")) breaks silently as soon as somebody improves the text or translates it. If you need that piece of data, put it in a field.

A hierarchy deeper than you are going to use. If you are never going to write a catch of an intermediate level, that level is surplus. Three levels is a reasonable maximum.

A non-abstract root. If BiblioTechException is instantiable, somebody will end up throwing it directly, and you will be back at square one: a failure with no specific name.

Names without the Exception suffix. It breaks Java's universal convention and confuses anyone reading the code. And Error as a suffix is worse: it suggests an unrecoverable JVM failure.

Tip: build the message in the super(...) from the fields. That way the text and the data cannot contradict each other, and all the message formatting lives in one place.

Tip: add useful convenience methods. waitDays(currentDay), returnsNeeded(), isCatalogEmpty(). The exception knows things about the failure; let it compute them instead of repeating the arithmetic in every catch.

Tip: declare serialVersionUID = 1L. One line that silences the compiler warning and gives you control over serialisation compatibility. The complete mechanics, in 07-05.

Tip: document every exception with Javadoc and @throws on the methods that throw it. It is the only way for whoever uses your API to know what to expect without reading the implementation.

Exercises

Exercise 1: an exception with data and logic

Create ExcessiveFineException, to be thrown when an employee accumulates a fine above a threshold and cannot take out more loans until it is settled.

Requirements:

  • Extends LoanException.
  • final fields: employeeId, employeeName, accruedAmount (double), allowedThreshold (double), finedMaterials (a List<String> of references).
  • Constructors: the complete one and one that also accepts Throwable cause.
  • The message is built from the fields and includes the amount with two decimals.
  • Accessors for every field. The list is returned as an immutable copy.
  • Convenience methods:
    • double getExcess(): how much it exceeds the threshold.
    • boolean overMaxFine(): whether accruedAmount >= MAX_FINE (20.0).
    • String getSummary(): one line per fined material.
  • Redefine isUserFixable() returning true (the employee can pay).

Write a main that throws it with three fined materials and catches it, demonstrating the use of all its methods.

Exercise 2: redesigning a badly built hierarchy

A colleague has written this hierarchy for BiblioTech's reservations module. It has seven design problems. Identify them, explain the damage each one does and rewrite the hierarchy correctly.

public class ReservationError extends Throwable {
    public ReservationError() { }
}

public class InvalidReservation extends ReservationError {
    public String message;
    public InvalidReservation(String m) { this.message = m; }
}

public class ReservationDateError extends Exception {
    public ReservationDateError(String m) { super(m); }
}

public class DuplicateReservationError extends InvalidReservation {
    public DuplicateReservationError() { super("Error"); }
}

// Typical use in the code:
// try {
//     reservationQueue.reserve(reference, employeeId, day);
// } catch (Throwable t) {
//     if (t.getMessage() != null && t.getMessage().startsWith("Error")) {
//         System.out.println("Reservation failure");
//     }
// }

Exercise 3: ReservationQueue with its own family of exceptions

Extend BiblioTech's ReservationQueue service with its own hierarchy and structured data.

Requirements:

  • Create ReservationException extends BiblioTechException (abstract) and three leaves:
    • DuplicateReservationException: the same employee already has a reservation for that material. Fields: reference, employeeId, currentPosition.
    • ReservationQueueFullException: that material's queue has reached the maximum (MAX_RESERVATIONS_PER_MATERIAL = 5). Fields: reference, maximum, estimatedAvailabilityDay.
    • ReservationNotFoundException: an attempt to cancel a non-existent reservation. Fields: reference, employeeId.
  • ReservationQueue keeps a Map<String, Deque<Reservation>> (one FIFO queue per material, as in 05-07) and offers:
    • int reserve(String reference, String employeeId, int day): returns the position in the queue (1 = next up).
    • void cancel(String reference, String employeeId).
    • Optional<Reservation> serveNext(String reference): takes the first one out of the queue.
    • int positionOf(String reference, String employeeId): -1 if not there.
  • Validate the arguments with standard exceptions and the business rules with your own.
  • The main must demonstrate the three failures, a full queue with the project's three employees and serving the queue in FIFO order. Add a handler that catches by ReservationException (the branch) and shows getCode().

Solutions

Solution 1

package com.nexussoftware.bibliotech.domain;

import java.util.List;

/**
 * The employee has accrued a fine that stops them taking out more loans
 * until it is settled.
 *
 * It carries the complete breakdown so that the presentation layer can
 * show a receipt, and so that the service layer can decide whether to grant
 * an exception to the rule.
 */
public class ExcessiveFineException extends LoanException {

    private static final long serialVersionUID = 1L;

    public static final double MAX_FINE = 20.0;

    private final String employeeId;
    private final String employeeName;
    private final double accruedAmount;
    private final double allowedThreshold;
    private final List<String> finedMaterials;

    public ExcessiveFineException(String employeeId, String employeeName,
                                  double accruedAmount, double allowedThreshold,
                                  List<String> finedMaterials) {
        super(buildMessage(employeeId, employeeName, accruedAmount,
                allowedThreshold, finedMaterials));
        this.employeeId = employeeId;
        this.employeeName = employeeName;
        this.accruedAmount = accruedAmount;
        this.allowedThreshold = allowedThreshold;
        // Defensive copy: the exception must be immutable even if the original
        // list changes afterwards (03-07)
        this.finedMaterials = List.copyOf(finedMaterials);
    }

    public ExcessiveFineException(String employeeId, String employeeName,
                                  double accruedAmount, double allowedThreshold,
                                  List<String> finedMaterials, Throwable cause) {
        super(buildMessage(employeeId, employeeName, accruedAmount,
                allowedThreshold, finedMaterials), cause);
        this.employeeId = employeeId;
        this.employeeName = employeeName;
        this.accruedAmount = accruedAmount;
        this.allowedThreshold = allowedThreshold;
        this.finedMaterials = List.copyOf(finedMaterials);
    }

    /**
     * The message is built in a static method because it has to be called
     * inside the super(...), before the fields exist.
     */
    private static String buildMessage(String employeeId, String employeeName,
                                       double amount, double threshold,
                                       List<String> materials) {
        return String.format(
                "The employee %s (%s) has accrued %.2f EUR in fines, above the threshold of %.2f EUR, "
                        + "on %d material(s)",
                employeeId, employeeName, amount, threshold, materials.size());
    }

    public String getEmployeeId()      { return employeeId; }
    public String getEmployeeName()    { return employeeName; }
    public double getAccruedAmount()   { return accruedAmount; }
    public double getAllowedThreshold(){ return allowedThreshold; }

    /** Already immutable thanks to List.copyOf, but returned explicitly this way. */
    public List<String> getFinedMaterials() {
        return finedMaterials;
    }

    // ---------- Convenience methods ----------

    /** The minimum that must be paid to get back below the threshold. */
    public double getExcess() {
        return Math.max(0.0, accruedAmount - allowedThreshold);
    }

    /** Has the system's absolute fine cap been reached? */
    public boolean overMaxFine() {
        return accruedAmount >= MAX_FINE;
    }

    /** Readable breakdown, one line per material. */
    public String getSummary() {
        StringBuilder sb = new StringBuilder();
        sb.append(String.format("Fine for %s: %.2f EUR (threshold %.2f, excess %.2f)%n",
                employeeName, accruedAmount, allowedThreshold, getExcess()));
        for (String reference : finedMaterials) {
            sb.append("  - ").append(reference).append('\n');
        }
        if (overMaxFine()) {
            sb.append(String.format("  ATTENTION: the maximum fine has been reached (%.2f EUR)%n",
                    MAX_FINE));
        }
        return sb.toString();
    }

    /** The employee can solve it by paying: it is fixable by them. */
    @Override
    public boolean isUserFixable() {
        return true;
    }

    // ---------- Demonstration ----------

    public static void main(String[] args) {
        try {
            throw new ExcessiveFineException(
                    "EMP-001", "Marta Ruiz",
                    23.75, 10.0,
                    List.of("BK-0001", "BK-0002", "REV-0007"));

        } catch (ExcessiveFineException e) {
            System.out.println("=== Specific catch ===");
            System.out.println("Code              : " + e.getCode());
            System.out.println("Message           : " + e.getMessage());
            System.out.println("Employee          : " + e.getEmployeeId()
                    + " (" + e.getEmployeeName() + ")");
            System.out.printf ("Accrued amount    : %.2f EUR%n", e.getAccruedAmount());
            System.out.printf ("Allowed threshold : %.2f EUR%n", e.getAllowedThreshold());
            System.out.printf ("Excess to pay     : %.2f EUR%n", e.getExcess());
            System.out.println("Over the maximum  : " + e.overMaxFine());
            System.out.println("Materials         : " + e.getFinedMaterials());
            System.out.println("User fixable: " + e.isUserFixable());

            System.out.println("\n=== Summary for the receipt ===");
            System.out.print(e.getSummary());

            // Demonstration that the list is IMMUTABLE
            System.out.println("=== Immutability ===");
            try {
                e.getFinedMaterials().add("BK-9999");
            } catch (UnsupportedOperationException uoe) {
                System.out.println("The list of materials cannot be modified. Correct.");
            }
        }

        // Catching by the branch and by the root
        System.out.println("\n=== Catching by the LoanException branch ===");
        try {
            throw new ExcessiveFineException("EMP-002", "Diego Alonso",
                    12.5, 10.0, List.of("BK-0003"));
        } catch (LoanException e) {
            System.out.println("Loan failure [" + e.getCode() + "]: " + e.getMessage());
        }
    }
}

Output:

=== Specific catch ===
Code              : EXCESSIVEFINE
Message           : The employee EMP-001 (Marta Ruiz) has accrued 23.75 EUR in fines, above the threshold of 10.00 EUR, on 3 material(s)
Employee          : EMP-001 (Marta Ruiz)
Accrued amount    : 23.75 EUR
Allowed threshold : 10.00 EUR
Excess to pay     : 13.75 EUR
Over the maximum  : true
Materials         : [BK-0001, BK-0002, REV-0007]
User fixable: true

=== Summary for the receipt ===
Fine for Marta Ruiz: 23.75 EUR (threshold 10.00, excess 13.75)
  - BK-0001
  - BK-0002
  - REV-0007
  ATTENTION: the maximum fine has been reached (20.00 EUR)
=== Immutability ===
The list of materials cannot be modified. Correct.

=== Catching by the LoanException branch ===
Loan failure [EXCESSIVEFINE]: The employee EMP-002 (Diego Alonso) has accrued 12.50 EUR in fines, above the threshold of 10.00 EUR, on 1 material(s)

Solution 2

The seven problems:

# Problem Damage
1 ReservationError extends Throwable It is neither an Error nor an Exception: it escapes every catch (Exception e) and every catch (RuntimeException e) without being a JVM failure. And being checked, it forces throws Throwable all over the application
2 Names without the Exception suffix ReservationError, InvalidReservation, DuplicateReservationError break the convention. And Error suggests an unrecoverable JVM failure
3 public String message instead of using super(m) A public, mutable and duplicated field: getMessage() will return null while the text lives somewhere else. Stack traces will come out with no message
4 ReservationDateError extends Exception, outside the hierarchy It breaks the family: it cannot be caught with the common root. And it is checked for no reason
5 DuplicateReservationError with the fixed message "Error" It does not say which material, nor which employee, nor which position. Zero diagnostic value
6 No class carries data The whole state of the failure is lost. Whoever catches it can only read text
7 The usage: catch (Throwable t) + getMessage().startsWith("Error") It catches OutOfMemoryError, and the logic depends on a text prefix that breaks as soon as somebody improves the message. Besides, getMessage() can be null

Corrected version:

package com.nexussoftware.bibliotech.domain;

/** Root of the reservation subsystem's failures. */
public abstract class ReservationException extends BiblioTechException {

    private static final long serialVersionUID = 1L;

    private final String reference;
    private final String employeeId;

    protected ReservationException(String message, String reference, String employeeId) {
        super(message);
        this.reference = reference;
        this.employeeId = employeeId;
    }

    protected ReservationException(String message, String reference, String employeeId,
                                   Throwable cause) {
        super(message, cause);
        this.reference = reference;
        this.employeeId = employeeId;
    }

    /** Data common to the whole family: declared once, in the root. */
    public String getReference()  { return reference; }
    public String getEmployeeId() { return employeeId; }
}
package com.nexussoftware.bibliotech.domain;

/** The employee already has an active reservation for that material. */
public class DuplicateReservationException extends ReservationException {

    private static final long serialVersionUID = 1L;

    private final int currentPosition;

    public DuplicateReservationException(String reference, String employeeId, int currentPosition) {
        super("The employee " + employeeId + " already has a reservation for " + reference
                + " at position " + currentPosition + " of the queue",
                reference, employeeId);
        this.currentPosition = currentPosition;
    }

    public int getCurrentPosition() { return currentPosition; }
}
package com.nexussoftware.bibliotech.domain;

/** The reservation day is not valid for that material. */
public class InvalidReservationDayException extends ReservationException {

    private static final long serialVersionUID = 1L;

    private final int requestedDay;
    private final int minimumDay;

    public InvalidReservationDayException(String reference, String employeeId,
                                          int requestedDay, int minimumDay) {
        super("The reservation for " + reference + " was requested for day " + requestedDay
                + ", but the material will not be available until day " + minimumDay,
                reference, employeeId);
        this.requestedDay = requestedDay;
        this.minimumDay = minimumDay;
    }

    public int getRequestedDay() { return requestedDay; }
    public int getMinimumDay()   { return minimumDay; }
    public int getWaitDays()     { return Math.max(0, minimumDay - requestedDay); }
}

And the corrected usage:

try {
    int position = reservationQueue.reserve(reference, employeeId, day);
    System.out.println("Reserved. Position in the queue: " + position);

} catch (DuplicateReservationException e) {
    // The DATA is used, not the text
    System.out.println("You already had a reservation for " + e.getReference()
            + ", at position " + e.getCurrentPosition() + ". It has not been duplicated.");

} catch (InvalidReservationDayException e) {
    System.out.println("That material will not be free for another "
            + e.getWaitDays() + " days.");
    System.out.println("Do you want to reserve it for day " + e.getMinimumDay() + "? (y/n)");

} catch (ReservationException e) {
    // Safety net at the BRANCH, not at Throwable
    System.out.println("The reservation could not be completed [" + e.getCode() + "]: "
            + e.getMessage());
}

The changes, in summary: an abstract root inside BiblioTechException, the Exception suffix on every name, messages built with super(...) and with the specific data, common fields declared in the root, specific fields in each leaf, everything unchecked because they are business rules, and catches by type instead of by message prefix.

Solution 3

package com.nexussoftware.bibliotech.domain;

/** Root of the reservation queue's failures. */
public abstract class ReservationException extends BiblioTechException {
    private static final long serialVersionUID = 1L;
    protected ReservationException(String message) { super(message); }
}
package com.nexussoftware.bibliotech.domain;

public class DuplicateReservationException extends ReservationException {

    private static final long serialVersionUID = 1L;

    private final String reference;
    private final String employeeId;
    private final int currentPosition;

    public DuplicateReservationException(String reference, String employeeId, int currentPosition) {
        super("The employee " + employeeId + " has already reserved " + reference
                + " (position " + currentPosition + " of the queue)");
        this.reference = reference;
        this.employeeId = employeeId;
        this.currentPosition = currentPosition;
    }

    public String getReference()    { return reference; }
    public String getEmployeeId()   { return employeeId; }
    public int getCurrentPosition() { return currentPosition; }
}
package com.nexussoftware.bibliotech.domain;

public class ReservationQueueFullException extends ReservationException {

    private static final long serialVersionUID = 1L;

    private final String reference;
    private final int maximum;
    private final int estimatedAvailabilityDay;

    public ReservationQueueFullException(String reference, int maximum,
                                         int estimatedAvailabilityDay) {
        super("The reservation queue for " + reference + " is full (" + maximum
                + " reservations). Estimated availability: day " + estimatedAvailabilityDay);
        this.reference = reference;
        this.maximum = maximum;
        this.estimatedAvailabilityDay = estimatedAvailabilityDay;
    }

    public String getReference()               { return reference; }
    public int getMaximum()                    { return maximum; }
    public int getEstimatedAvailabilityDay()   { return estimatedAvailabilityDay; }

    @Override
    public boolean isUserFixable() {
        return false;   // the user can do nothing: the queue is full
    }
}
package com.nexussoftware.bibliotech.domain;

public class ReservationNotFoundException extends ReservationException {

    private static final long serialVersionUID = 1L;

    private final String reference;
    private final String employeeId;

    public ReservationNotFoundException(String reference, String employeeId) {
        super("The employee " + employeeId + " has no reservation for " + reference);
        this.reference = reference;
        this.employeeId = employeeId;
    }

    public String getReference()  { return reference; }
    public String getEmployeeId() { return employeeId; }
}
package com.nexussoftware.bibliotech.service;

import java.util.ArrayDeque;
import java.util.Deque;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;

import com.nexussoftware.bibliotech.domain.ReservationQueueFullException;
import com.nexussoftware.bibliotech.domain.DuplicateReservationException;
import com.nexussoftware.bibliotech.domain.ReservationException;
import com.nexussoftware.bibliotech.domain.ReservationNotFoundException;

/**
 * FIFO reservation queue per material (picks up the ArrayDeque from 05-07).
 *
 * Error policy:
 *  - Invalid arguments        -> STANDARD exceptions (NPE, IllegalArgument).
 *  - Business rules           -> OUR OWN exceptions with data.
 */
public class ReservationQueue {

    public static final int MAX_RESERVATIONS_PER_MATERIAL = 5;
    private static final int ESTIMATED_DAYS_PER_TURN = 15;

    /** An employee's reservation of a material (record, 04-07). */
    public record Reservation(String reference, String employeeId, int day) { }

    private final Map<String, Deque<Reservation>> queuesByMaterial = new HashMap<>();

    /**
     * Adds a reservation to the end of the material's queue.
     *
     * @return position in the queue, 1 being the next one to be served
     * @throws NullPointerException              if any argument is null
     * @throws IllegalArgumentException          if the day is less than 1
     * @throws DuplicateReservationException     if that employee already reserved that material
     * @throws ReservationQueueFullException     if the queue has reached the maximum
     */
    public int reserve(String reference, String employeeId, int day) {
        // Technical validation: standard exceptions
        Objects.requireNonNull(reference, "The reference cannot be null");
        Objects.requireNonNull(employeeId, "The employee identifier cannot be null");
        if (day < 1) {
            throw new IllegalArgumentException("The day must be 1 or later, and it was: " + day);
        }

        Deque<Reservation> queue = queuesByMaterial.computeIfAbsent(reference, r -> new ArrayDeque<>());

        // Business rule 1: no duplicates
        int already = positionIn(queue, employeeId);
        if (already > 0) {
            throw new DuplicateReservationException(reference, employeeId, already);
        }

        // Business rule 2: queue full
        if (queue.size() >= MAX_RESERVATIONS_PER_MATERIAL) {
            int estimatedDay = day + queue.size() * ESTIMATED_DAYS_PER_TURN;
            throw new ReservationQueueFullException(reference, MAX_RESERVATIONS_PER_MATERIAL, estimatedDay);
        }

        queue.addLast(new Reservation(reference, employeeId, day));
        return queue.size();
    }

    /**
     * Cancels an employee's reservation of a material.
     *
     * @throws ReservationNotFoundException if there was no such reservation
     */
    public void cancel(String reference, String employeeId) {
        Objects.requireNonNull(reference, "The reference cannot be null");
        Objects.requireNonNull(employeeId, "The employee identifier cannot be null");

        Deque<Reservation> queue = queuesByMaterial.get(reference);
        if (queue == null) {
            throw new ReservationNotFoundException(reference, employeeId);
        }

        // removeIf on a Deque: the safe way to remove while iterating (05-04)
        boolean removed = queue.removeIf(r -> r.employeeId().equals(employeeId));
        if (!removed) {
            throw new ReservationNotFoundException(reference, employeeId);
        }
        if (queue.isEmpty()) {
            queuesByMaterial.remove(reference);        // do not leave empty queues in the map
        }
    }

    /**
     * Takes the first one out of the queue. Returns an empty Optional if nobody is
     * waiting: here absence is a NORMAL case, not a failure.
     */
    public Optional<Reservation> serveNext(String reference) {
        Deque<Reservation> queue = queuesByMaterial.get(reference);
        if (queue == null || queue.isEmpty()) {
            return Optional.empty();
        }
        Reservation first = queue.pollFirst();
        if (queue.isEmpty()) {
            queuesByMaterial.remove(reference);
        }
        return Optional.of(first);
    }

    /** The employee's position in that material's queue, or -1 if not there. */
    public int positionOf(String reference, String employeeId) {
        Deque<Reservation> queue = queuesByMaterial.get(reference);
        if (queue == null) { return -1; }
        int p = positionIn(queue, employeeId);
        return (p > 0) ? p : -1;
    }

    /** Returns the position 1..n, or 0 if not there. */
    private int positionIn(Deque<Reservation> queue, String employeeId) {
        int position = 1;
        for (Iterator<Reservation> it = queue.iterator(); it.hasNext(); position++) {
            if (it.next().employeeId().equals(employeeId)) {
                return position;
            }
        }
        return 0;
    }

    public int queueSize(String reference) {
        Deque<Reservation> queue = queuesByMaterial.get(reference);
        return (queue == null) ? 0 : queue.size();
    }

    // ------------------------------------------------------------------

    public static void main(String[] args) {
        ReservationQueue queues = new ReservationQueue();
        String BOOK = "BK-0001";

        System.out.println("=== Correct reservations ===");
        System.out.println("Marta -> position " + queues.reserve(BOOK, "EMP-001", 10));
        System.out.println("Diego -> position " + queues.reserve(BOOK, "EMP-002", 11));
        System.out.println("Nuria -> position " + queues.reserve(BOOK, "EMP-003", 12));

        System.out.println("\n=== Failures ===");
        attempt("Duplicate reservation", () -> queues.reserve(BOOK, "EMP-001", 13));
        attempt("Cancel a non-existent one", () -> queues.cancel(BOOK, "EMP-009"));
        attempt("Invalid day", () -> queues.reserve(BOOK, "EMP-004", 0));
        attempt("Null reference", () -> queues.reserve(null, "EMP-004", 5));

        // Fill the queue up to the maximum
        queues.reserve(BOOK, "EMP-004", 13);
        queues.reserve(BOOK, "EMP-005", 14);
        System.out.println("\nQueue for " + BOOK + ": " + queues.queueSize(BOOK)
                + "/" + MAX_RESERVATIONS_PER_MATERIAL);
        attempt("Queue full", () -> queues.reserve(BOOK, "EMP-006", 15));

        System.out.println("\n=== FIFO service ===");
        Optional<Reservation> next;
        while ((next = queues.serveNext(BOOK)).isPresent()) {
            Reservation r = next.get();
            System.out.println("  Served " + r.employeeId() + " (reserved on day " + r.day() + ")");
        }
        System.out.println("Queue empty: " + (queues.queueSize(BOOK) == 0));
    }

    /** Handler that catches by the BRANCH and uses getCode() from the root. */
    private static void attempt(String description, Runnable action) {
        try {
            action.run();
            System.out.println("[" + description + "] -> did not fail (unexpected)");
        } catch (ReservationException e) {
            System.out.println("[" + description + "] [" + e.getCode() + "] " + e.getMessage()
                    + " | fixable: " + e.isUserFixable());
        } catch (NullPointerException | IllegalArgumentException e) {
            System.out.println("[" + description + "] [TECHNICAL] "
                    + e.getClass().getSimpleName() + ": " + e.getMessage());
        }
    }
}

Output:

=== Correct reservations ===
Marta -> position 1
Diego -> position 2
Nuria -> position 3

=== Failures ===
[Duplicate reservation] [DUPLICATERESERVATION] The employee EMP-001 has already reserved BK-0001 (position 1 of the queue) | fixable: true
[Cancel a non-existent one] [RESERVATIONNOTFOUND] The employee EMP-009 has no reservation for BK-0001 | fixable: true
[Invalid day] [TECHNICAL] IllegalArgumentException: The day must be 1 or later, and it was: 0
[Null reference] [TECHNICAL] NullPointerException: The reference cannot be null

Queue for BK-0001: 5/5
[Queue full] [RESERVATIONQUEUEFULL] The reservation queue for BK-0001 is full (5 reservations). Estimated availability: day 90 | fixable: false

=== FIFO service ===
  Served EMP-001 (reserved on day 10)
  Served EMP-002 (reserved on day 11)
  Served EMP-003 (reserved on day 12)
  Served EMP-004 (reserved on day 13)
  Served EMP-005 (reserved on day 14)
Queue empty: true

Notice the clean separation in the handler: one catch for the business rules —caught at the ReservationException branch, which can also use getCode() and isUserFixable() from the root— and another for the technical failures with the standard types. The first are messages for the user; the second are bugs that in 06-07 will end up in the log with an incident identifier.

Conclusion

You now know how to create exceptions that speak the language of your domain. You know the two reasons that justify it: naming the failure —so that the type is the decision and the fragile e.getMessage().contains("limit") disappears— and, above all, carrying structured data, because the message is for people and the fields are for the program.

You know how one is created: by extending Exception or RuntimeException, never Throwable or Error directly. And you have the criterion for choosing between checked and unchecked, with the deciding question —is the caller going to do something other than propagate in most cases?— and its application to BiblioTech: all the business rules, unchecked; the only checked one, CatalogNotAccessibleException, because it represents an environment failure against which the application does have an alternative. You also know that a checked one in an interface binds all its present and future implementations, because of the overriding rule from 06-03.

You have mastered the four canonical constructors and why the third, (String, Throwable), is essential: without it, somebody will end up losing a cause. You know how to add your own fields —always final, with the message built from them in the super(...), and storing identifiers instead of object graphs— and convenience methods that compute what whoever catches is going to need: waitDays, returnsNeeded, isCatalogEmpty.

You have designed a three-level domain hierarchy with a common abstract root, and you understand what that root enables: catching the whole domain in one go at the error boundary —telling "the user asked for something impossible" from "we have a bug"—, sharing behaviour such as getCode() and isUserFixable(), and having a single point of evolution. With the warning not to make it deeper than you are going to use: if you are never going to write a catch of an intermediate level, that level is surplus.

You know the conventions: the Exception suffix, names describing the problem and not the solution, specific and without the project prefix on the leaves; and messages answering what happened, with what data and what was expected, with no sensitive data, no suggested solutions that depend on the context and no line breaks. You know how to declare serialVersionUID and why —all exceptions are Serializable, and 07-05 develops the mechanics—, and you know when NOT to create your own exception: if a standard one already exists with your same semantics and you do not need to carry data, use it. Technical validations use IllegalArgumentException, NullPointerException and IllegalStateException; only business rules deserve their own classes. And you have the advanced note about writableStackTrace = false for very frequent control exceptions, with the warning that an exception without a stack trace in production is impossible to diagnose.

BiblioTech now has its complete vocabulary: abstract BiblioTechException as the root, CatalogException and LoanException as branches, and five leaves with data —MaterialNotFoundException with the searched reference and the catalogue size, DuplicateReferenceException with the kind of collision and the existing title, MaterialNotAvailableException with the current borrower and the expected return day, LoanLimitExceededException with the limit and the current loans, and LoanAlreadyReturnedException with the original return day—, plus the checked CatalogNotAccessibleException. And the demonstration proves it: each failure now triggers a different, specific reaction —looking for similar materials, offering a reservation with the waiting days calculated, telling the employee how many materials they must return— instead of printing a line of text.

Two fragilities from the module 5 list remain. The fourth: operations can fail halfway leaving the state inconsistent. If LoanManager.lend marks the material as lent and then fails to record it in the registry, the material is left blocked with no loan to justify it. No exception, however well designed, fixes that on its own: what is needed is a mechanism guaranteeing that certain code runs no matter what.

That is the next lesson, The Finally Block: what exactly finally guarantees —that it runs with and without an exception, and also when the try or the catch do return, break or continue—, with the complete trace of the execution order; the try-catch-finally and try-finally combinations without a catch, and what the latter is for; the classic purpose of releasing resources; the trap of return inside finally, which discards the value and even the pending exception; the finally that throws its own exception and makes the original disappear —the problem that directly motivates the try-with-resources of 06-06—; the two cases in which finally does not run; and the verbose manual closing pattern from before Java 7. With the application that solves the fourth fragility: LoanManager guaranteeing that Catalog and LoanRegistry are left consistent even if the operation fails halfway.

Java Programming Course

Module 1: Introduction to Java

Module 2: Control Flow

Module 3: Object-Oriented Programming

Module 4: Advanced Object-Oriented Programming

Module 5: Data Structures and Collections

Module 6: Exception Handling

Module 7: File Input/Output

Module 8: Multithreading and Concurrency

Module 9: Networking

Module 10: Advanced Topics

Module 11: Java Frameworks and Libraries

Module 12: Building Real-World Applications

© Copyright 2026. All rights reserved