Two fragilities remain from the list module 5 closed with, and this lesson attacks the most treacherous: operations that fail halfway and leave the state inconsistent. If LoanManager.lend marks the material as lent, increments the employee's counter and then fails to record it in the registry, BiblioTech is left with a blocked material nobody has on loan and an employee with a phantom loan eating into their allowance. No exception, however well designed, fixes that: what is needed is a mechanism guaranteeing that certain code runs no matter what.

That mechanism is finally. It is the simplest construct in the module —a block that always runs— and at the same time the one hiding the most traps: the return inside finally that silently discards the value and even the pending exception, the finally that throws and makes the original failure disappear, and the two cases in which not even finally runs.

At the end you will see the manual resource-closing pattern from before Java 7 in all its verbosity. It is not nostalgia: it is the only way to truly understand what the try-with-resources of the next lesson does for you, and why its existence was so justified.

Contents

  1. What finally guarantees
  2. The exact order of execution
  3. finally with return, break and continue
  4. The valid combinations
  5. try-finally without catch
  6. The classic purpose: releasing resources
  7. The trap of return in finally
  8. The finally that throws and loses the original exception
  9. The two cases in which finally does NOT run
  10. The manual closing pattern before Java 7
  11. BiblioTech: operations that do not leave the state half-done
  12. Common Mistakes and Tips
  13. Exercises

  1. What finally guarantees

finally is an optional block added to a try, and its guarantee is this:

The finally block runs whenever the try has been entered, with or without an exception, caught or not, and regardless of how the block is left.

The four possible exits from a try, and what finally does in each:

How the try ends Does finally run?
It ends normally Yes
It throws an exception caught by a catch Yes, after the catch
It throws an uncaught exception Yes, before the exception carries on up
It executes return, break or continue Yes, before jumping

A first example showing the first three cases:

package com.nexussoftware.bibliotech.presentation;

public class FinallyGuarantee {

    public static void main(String[] args) {
        System.out.println("--- CASE 1: no exception ---");
        process("15");

        System.out.println("\n--- CASE 2: caught exception ---");
        process("twelve");

        System.out.println("\n--- CASE 3: UNCAUGHT exception ---");
        try {
            processWithoutNet(null);
        } catch (NullPointerException e) {
            System.out.println("  (caught above: " + e.getClass().getSimpleName() + ")");
        }
    }

    static void process(String input) {
        System.out.println("  1. before the try");
        try {
            System.out.println("  2. inside the try");
            int days = Integer.parseInt(input);
            System.out.println("  3. converted: " + days);
        } catch (NumberFormatException e) {
            System.out.println("  4. in the catch: " + e.getMessage());
        } finally {
            System.out.println("  5. IN THE FINALLY");
        }
        System.out.println("  6. after the block");
    }

    static void processWithoutNet(String input) {
        try {
            System.out.println("  2. inside the try");
            System.out.println("  3. length: " + input.length());     // NullPointerException
        } catch (NumberFormatException e) {
            System.out.println("  4. this catch does NOT catch NullPointerException");
        } finally {
            System.out.println("  5. IN THE FINALLY (even though nobody caught it)");
        }
        System.out.println("  6. this line does NOT run");
    }
}

Output:

--- CASE 1: no exception ---
  1. before the try
  2. inside the try
  3. converted: 15
  5. IN THE FINALLY
  6. after the block

--- CASE 2: caught exception ---
  1. before the try
  2. inside the try
  4. in the catch: For input string: "twelve"
  5. IN THE FINALLY
  6. after the block

--- CASE 3: UNCAUGHT exception ---
  2. inside the try
  5. IN THE FINALLY (even though nobody caught it)
  (caught above: NullPointerException)

Case 3 is the revealing one. No local catch was compatible with NullPointerException, so the exception carried on up... but the finally ran all the same, before it left. Line 6 did not run, because the method was abandoned. That combination —"the rest of the method is abandoned, but the finally runs"— is exactly what makes the block useful for releasing resources and for repairing state.

  1. The exact order of execution

When there is an exception, the order is: try up to the point of failure → compatible catch (if there is one) → finally → whatever comes next.

flowchart TB
    A["Enters the try"] --> B{"Does it throw?"}

    B -->|"no"| C["The try ends"]
    C --> F["FINALLY"]
    F --> G["Continues after the block"]

    B -->|"yes"| D{"Is there a compatible catch?"}
    D -->|"yes"| E["Runs the catch"]
    E --> F2["FINALLY"]
    F2 --> G

    D -->|"no"| H["FINALLY"]
    H --> I["The exception carries on up<br/>the call stack"]

With nested try blocks, each finally runs from the inside out as the exception travels up:

package com.nexussoftware.bibliotech.presentation;

public class FinallyOrder {

    public static void main(String[] args) {
        try {
            level1();
        } catch (IllegalStateException e) {
            System.out.println("6. main catches: " + e.getMessage());
        }
    }

    static void level1() {
        try {
            System.out.println("1. level1: try");
            level2();
        } finally {
            System.out.println("5. level1: FINALLY");
        }
    }

    static void level2() {
        try {
            System.out.println("2. level2: try");
            level3();
        } finally {
            System.out.println("4. level2: FINALLY");
        }
    }

    static void level3() {
        System.out.println("3. level3: throws");
        throw new IllegalStateException("failure at the deepest level");
    }
}

Output:

1. level1: try
2. level2: try
3. level3: throws
4. level2: FINALLY
5. level1: FINALLY
6. main catches: failure at the deepest level

The finally blocks fire in reverse order of depth, exactly as the frames are popped. It is the same call stack from 05-08 and from 06-01: while the exception unwinds the stack, each discarded frame runs its finally before disappearing. That is the property allowing each level to clean up its own without knowing anything about the others.

  1. finally with return, break and continue

Here comes the surprising part: finally runs even when the try or the catch do a return. The JVM saves the return value, runs the finally, and only then returns.

package com.nexussoftware.bibliotech.presentation;

public class FinallyWithReturn {

    public static void main(String[] args) {
        System.out.println("Result A: " + withReturnInTry());
        System.out.println("Result B: " + withReturnInCatch());
        System.out.println("\n--- break and continue ---");
        withBreak();
        withContinue();
    }

    /** The try's return is "frozen": the finally runs BEFORE returning. */
    static int withReturnInTry() {
        try {
            System.out.println("  [A] in the try, about to return 10");
            return 10;
        } finally {
            System.out.println("  [A] FINALLY (runs before handing back the 10)");
        }
    }

    static int withReturnInCatch() {
        try {
            System.out.println("  [B] in the try, throwing");
            throw new IllegalStateException("failure");
        } catch (IllegalStateException e) {
            System.out.println("  [B] in the catch, about to return 20");
            return 20;
        } finally {
            System.out.println("  [B] FINALLY (also after the catch's return)");
        }
    }

    /** With break: the finally runs before leaving the loop. */
    static void withBreak() {
        for (int i = 1; i <= 3; i++) {
            try {
                System.out.println("  [break] round " + i);
                if (i == 2) {
                    break;
                }
            } finally {
                System.out.println("  [break] FINALLY of round " + i);
            }
        }
        System.out.println("  [break] outside the loop");
    }

    /** With continue: the finally runs before moving to the next round. */
    static void withContinue() {
        for (int i = 1; i <= 3; i++) {
            try {
                if (i == 2) {
                    System.out.println("  [continue] skipping round 2");
                    continue;
                }
                System.out.println("  [continue] processing round " + i);
            } finally {
                System.out.println("  [continue] FINALLY of round " + i);
            }
        }
    }
}

Output:

  [A] in the try, about to return 10
  [A] FINALLY (runs before handing back the 10)
Result A: 10
  [B] in the try, throwing
  [B] in the catch, about to return 20
  [B] FINALLY (also after the catch's return)
Result B: 20

--- break and continue ---
  [break] round 1
  [break] FINALLY of round 1
  [break] round 2
  [break] FINALLY of round 2
  [break] outside the loop
  [continue] processing round 1
  [continue] FINALLY of round 1
  [continue] skipping round 2
  [continue] FINALLY of round 2
  [continue] processing round 3
  [continue] FINALLY of round 3

The exact mechanics with the return, worth understanding properly because it is the basis of section 7's trap:

  1. The return expression is evaluated. In case A, the value 10 is computed and saved.
  2. The finally runs.
  3. The value saved in step 1 is returned.

The consequence is subtle but important: if the finally modifies the variable that was going to be returned, the change does not affect the returned value, because the value has already been copied.

static int demo() {
    int value = 10;
    try {
        return value;              // the 10 is saved NOW
    } finally {
        value = 99;                // modifies the variable, not the saved value
        System.out.println("  finally set value = " + value);
    }
}
// Prints "finally set value = 99" and RETURNS 10

Watch out for the exception to this rule: if what you return is a reference to a mutable object, the finally can indeed modify the object pointed to, because what was copied was the reference, not the content.

static List<String> demoObject() {
    List<String> list = new ArrayList<>(List.of("a"));
    try {
        return list;               // the REFERENCE is saved
    } finally {
        list.add("b");             // modifies the OBJECT pointed to: this does count
    }
}
// Returns [a, b]

It is the same pass-by-value of references you saw in 03-03, applied here.

  1. The valid combinations

A try admits three legal forms:

Form Valid? What it is for
try + catch Yes Handle the failure
try + catch + finally Yes Handle the failure and clean up
try + finally Yes Clean up without handling: the failure carries on up
try alone Does not compile —
try + finally + catch (in that order) Does not compile The finally always goes last
// error: 'try' without 'catch', 'finally' or resource declarations
try {
    doSomething();
}

// error: 'catch' without 'try'   (the finally must go at the end)
try {
    doSomething();
} finally {
    cleanUp();
} catch (Exception e) {         // DOES NOT COMPILE
    handle(e);
}

There can also be several catch blocks and a single finally, always at the end:

try {
    operation();
} catch (MaterialNotFoundException e) {
    // ...
} catch (LoanException e) {
    // ...
} finally {
    release();                  // just one, and last
}

  1. try-finally without catch

The least known form and one of the most useful. It means exactly: "I do not know how to handle this failure, but I have to clean up before it leaves".

package com.nexussoftware.bibliotech.service;

public class TryFinallyWithoutCatch {

    private boolean catalogLocked = false;

    /**
     * Reorganises the catalogue under a lock.
     *
     * It catches NOTHING: if the reorganisation fails, the failure must go up so
     * that a decision is taken above. But the lock must ALWAYS be released, or the
     * catalogue would be left inaccessible to the rest of the application.
     */
    public void reorganise(int criterion) {
        lock();
        try {
            System.out.println("  Reorganising with criterion " + criterion);
            if (criterion < 0) {
                throw new IllegalArgumentException("Invalid criterion: " + criterion);
            }
            System.out.println("  Reorganisation completed");
        } finally {
            unlock();               // runs on success AND on failure
        }
    }

    private void lock() {
        catalogLocked = true;
        System.out.println("  [lock acquired]");
    }

    private void unlock() {
        catalogLocked = false;
        System.out.println("  [lock released]");
    }

    public boolean isLocked() { return catalogLocked; }

    public static void main(String[] args) {
        TryFinallyWithoutCatch service = new TryFinallyWithoutCatch();

        System.out.println("Correct case:");
        service.reorganise(1);
        System.out.println("Locked after success? " + service.isLocked());

        System.out.println("\nCase with a failure:");
        try {
            service.reorganise(-1);
        } catch (IllegalArgumentException e) {
            System.out.println("  main catches: " + e.getMessage());
        }
        System.out.println("Locked after the failure? " + service.isLocked());
    }
}

Output:

Correct case:
  [lock acquired]
  Reorganising with criterion 1
  Reorganisation completed
  [lock released]
Locked after success? false

Case with a failure:
  [lock acquired]
  Reorganising with criterion -1
  [lock released]
  main catches: Invalid criterion: -1
Locked after the failure? false

The important part: the exception reached main intact, with its type, its message and its stack, and even so the lock was released. This is what makes try-finally irreplaceable: it completely separates "cleaning up" from "handling", which are different responsibilities and often belong to different layers.

Without finally, the only alternative would be duplicating the unlock() in the normal path and in a catch that rethrows, with the obvious risk of forgetting one of the two:

// BAD: fragile and duplicated
lock();
try {
    reorganiseInternal(criterion);
    unlock();                         // normal path
} catch (RuntimeException e) {
    unlock();                         // failure path... and what if someone adds another return?
    throw e;
}

  1. The classic purpose: releasing resources

The historic use of finally is closing what has been opened: a file, a connection, a lock, a socket.

Scope warning: the following examples use FileReader and BufferedReader in a deliberately minimal way. File input/output is module 7 —reading, writing, streams, NIO.2— and there the API is explained in depth. Here the only thing that matters is the closing: what happens if you do not close, and how to guarantee it is closed.

Why you have to close. An open FileReader consumes a file descriptor, an operating system resource that is limited (on Linux, typically 1024 per process if the limit is not changed). If you open files in a loop and do not close them, the process runs out of descriptors and everything starts failing with Too many open files. And with writing it is worse: the data can stay in the buffer without reaching the disk.

The naive attempt, which is wrong:

// BAD: if readLine throws IOException, the file is NEVER closed
BufferedReader reader = new BufferedReader(new FileReader("catalog.txt"));
String line = reader.readLine();
process(line);                     // if this throws, we skip the close
reader.close();                    // this line may never run

The version with finally:

package com.nexussoftware.bibliotech.service;

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

/**
 * Reading a file with the close guaranteed by finally.
 *
 * NOTE: the I/O API is studied in module 7. Here only the CLOSING matters.
 */
public class ReaderWithFinally {

    public int countLines(String path) throws IOException {
        BufferedReader reader = null;          // declared OUTSIDE so the finally can see it
        int lines = 0;

        try {
            reader = new BufferedReader(new FileReader(path));
            String line;
            while ((line = reader.readLine()) != null) {
                if (!line.isBlank()) {
                    lines++;
                }
            }
            return lines;

        } finally {
            // Runs on success and on failure
            if (reader != null) {              // the constructor itself may have failed
                reader.close();                // close() declares IOException: see section 10
            }
            System.out.println("  [file closed]");
        }
    }
}

Three details already showing here that are developed in section 10:

  • The variable is declared outside the try, because the finally has to see it (block scope, 06-02).
  • You have to check != null, because if the FileReader constructor fails —non-existent file—, reader is still null and the close() would throw a NullPointerException.
  • close() itself declares IOException, so in many cases another try inside the finally is needed. That is where things get ugly.

  1. The trap of return in finally

This is the trap you need to know about so that you never fall into it.

A return inside finally discards the try's return value, and —far worse— it also discards any pending exception.

package com.nexussoftware.bibliotech.presentation;

public class ReturnInFinallyTrap {

    public static void main(String[] args) {
        System.out.println("A) " + discardsTheValue());
        System.out.println("B) " + discardsTheException());
        System.out.println("C) " + correctVersion());
    }

    /** The finally's return WINS: the 10 is lost. */
    static int discardsTheValue() {
        try {
            return 10;
        } finally {
            return 99;              // the compiler warns, but it compiles
        }
    }

    /**
     * FAR WORSE: the finally's return SWALLOWS the exception.
     * The method returns -1 as if everything had gone fine.
     */
    static int discardsTheException() {
        try {
            throw new IllegalStateException("The catalogue is corrupt");
        } finally {
            return -1;              // the exception DISAPPEARS. Without a trace.
        }
    }

    /** How it must be done: the finally only cleans up, it does not decide. */
    static int correctVersion() {
        try {
            return 10;
        } finally {
            System.out.println("   (clean-up, no return)");
        }
    }
}

Output:

A) 99
B) -1
   (clean-up, no return)
C) 10

Stop at case B. The method threw an IllegalStateException saying the catalogue is corrupt, and the caller calmly received a -1. The exception did not go up, was not logged, left no trace. It is a camouflaged empty catch, with the aggravating factor that there is not even a catch in sight to alert you when reading the code.

The same happens with break, continue and with a throw inside the finally: any abrupt exit from the finally discards whatever was in progress.

for (String reference : references) {
    try {
        process(reference);
        throw new IllegalStateException("failure processing " + reference);
    } finally {
        continue;                   // swallows ALL the loop's exceptions
    }
}

Modern compilers and every static analyser (SpotBugs, SonarQube, the IDE's own warnings) flag this as a serious error. The javac warning with -Xlint:finally is:

warning: [finally] finally clause cannot complete normally

The rule, with no nuance:

Never put return, break, continue or throw inside a finally. The finally cleans up; it does not decide, does not return and does not throw.

  1. The finally that throws and loses the original exception

A variant of the previous problem that appears by accident, and which is what directly motivates the next lesson.

When the try throws an exception and the finally throws another, the finally's one wins and the try's one is lost completely. And this happens in the commonest case of all: closing a resource.

package com.nexussoftware.bibliotech.presentation;

public class FinallyThatLosesTheException {

    /** Simulates a resource whose closing can also fail. */
    static class FragileResource {
        private final String name;

        FragileResource(String name) {
            this.name = name;
            System.out.println("  [opened " + name + "]");
        }

        void read() {
            throw new IllegalStateException("REAL FAILURE: the file " + name + " is corrupt");
        }

        void close() {
            throw new IllegalStateException("CLOSING FAILURE: could not release " + name);
        }
    }

    public static void main(String[] args) {
        try {
            operate();
        } catch (IllegalStateException e) {
            System.out.println("\nException received in main:");
            System.out.println("  " + e.getMessage());
            System.out.println("  Cause: " + e.getCause());
            System.out.println("  Suppressed: " + e.getSuppressed().length);
        }
    }

    static void operate() {
        FragileResource resource = new FragileResource("catalog.txt");
        try {
            resource.read();                // throws the REAL FAILURE
        } finally {
            resource.close();               // throws ANOTHER, and the first disappears
        }
    }
}

Output:

  [opened catalog.txt]

Exception received in main:
  CLOSING FAILURE: could not release catalog.txt
  Cause: null
  Suppressed: 0

The real failure has disappeared. The file was corrupt —that is what needs fixing— but the only thing reaching the top is a message about the closing, which is a secondary symptom. And there is no trace left: getCause() is null and getSuppressed() is empty.

It is a serious and surprisingly frequent problem, because the close() of almost any real resource can throw: a BufferedWriter flushing its buffer on close, a network connection that had already dropped, a lock another thread released.

The manual workaround exists, and it is horrible:

static void operateWithWorkaround() {
    FragileResource resource = new FragileResource("catalog.txt");
    IllegalStateException primaryFailure = null;

    try {
        resource.read();
    } catch (IllegalStateException e) {
        primaryFailure = e;                 // we keep the original
        throw e;
    } finally {
        try {
            resource.close();
        } catch (IllegalStateException closeError) {
            if (primaryFailure != null) {
                primaryFailure.addSuppressed(closeError);  // we add it as suppressed
            } else {
                throw closeError;           // there was no original: this is the only one
            }
        }
    }
}

Thirteen lines of manual bookkeeping for a two-line operation. And they have to be repeated for every resource, in every method. Nobody gets it consistently right.

This is exactly why try-with-resources exists, the construct of the next lesson. It automatically does everything above: it preserves the original exception, records the closing one as suppressed —recoverable with getSuppressed()— and does not force you to write a single line of that bookkeeping.

  1. The two cases in which finally does NOT run

finally's guarantee is strong, but not absolute. There are exactly two situations in which it does not run, and both have something in common: the JVM stops existing or the thread stops executing.

Case 1: System.exit().

package com.nexussoftware.bibliotech.presentation;

public class FinallyAndSystemExit {

    public static void main(String[] args) {
        // A shutdown hook DOES run with System.exit
        Runtime.getRuntime().addShutdownHook(new Thread(() ->
                System.out.println("SHUTDOWN HOOK: this one does run")));

        try {
            System.out.println("1. in the try");
            System.exit(0);                     // the JVM ends RIGHT HERE
            System.out.println("2. unreachable");
        } finally {
            System.out.println("3. THIS FINALLY DOES NOT RUN");
        }
    }
}

Output:

1. in the try
SHUTDOWN HOOK: this one does run

System.exit() throws no exception and does not return: it asks the JVM to terminate immediately. There is no stack unwinding, so there is no finally to run.

What does run are the shutdown hooks, threads registered with Runtime.getRuntime().addShutdownHook(...) that the JVM starts before dying. They are the correct mechanism for an application's global clean-up: closing the connection pool, flushing the log buffers, saving state. In BiblioTech, it will be the natural place to persist the catalogue when module 7 arrives. Two warnings: the hooks do not run either on a Runtime.halt() or a SIGKILL signal, and they must be fast, because many environments kill the process if they take too long.

Case 2: the JVM or the thread end abnormally.

  • The JVM goes down through a serious failure (kill -9, a power cut, an error in the native process itself).
  • An Error of the kind that leaves the JVM unusable. A StackOverflowError in a finally that needs stack to run, or an OutOfMemoryError when the finally itself needs to allocate memory.
  • The thread is stopped with the obsolete Thread.stop() (removed in modern Java versions).

From this a practical and important conclusion follows:

finally guarantees consistency inside the process, not durability outside it. If you need something to survive a JVM crash, finally is not enough: you need persistence (module 7) or transactions (module 11).

  1. The manual closing pattern before Java 7

Let us gather all the above into the complete, correct resource-management pattern as it had to be written before 2011. Brace yourself.

package com.nexussoftware.bibliotech.service;

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

/**
 * Manual resource-closing pattern, from before Java 7.
 *
 * It is included so that you see exactly what work the try-with-resources
 * of lesson 06-06 does for you. In new code, IT IS NOT WRITTEN LIKE THIS.
 *
 * (The I/O API is the subject of module 7: here only the closing matters.)
 */
public class ManualReaderPreJava7 {

    /** ONE single resource. It is already awkward. */
    public List<String> readReferences(String path) throws IOException {
        List<String> references = new ArrayList<>();

        BufferedReader reader = null;                    // 1. declare OUTSIDE
        try {
            reader = new BufferedReader(new FileReader(path));
            String line;
            while ((line = reader.readLine()) != null) {
                if (!line.isBlank()) {
                    references.add(line.split(";")[0].trim());
                }
            }
            return references;

        } finally {
            if (reader != null) {                        // 2. check for null
                try {
                    reader.close();                      // 3. close() throws IOException
                } catch (IOException closeError) {
                    // 4. what do we do with this? If we rethrow it, we LOSE
                    //    the original exception from the try (section 8).
                    //    The only reasonable thing without language help is to log it.
                    System.err.println("Warning: could not close " + path
                            + ": " + closeError.getMessage());
                }
            }
        }
    }

    /** TWO resources. Here the disaster begins. */
    public void copyCatalog(String source, String target) throws IOException {
        BufferedReader reader = null;
        java.io.BufferedWriter writer = null;

        try {
            reader = new BufferedReader(new FileReader(source));
            writer = new java.io.BufferedWriter(new java.io.FileWriter(target));

            String line;
            while ((line = reader.readLine()) != null) {
                writer.write(line);
                writer.newLine();
            }

        } finally {
            // REVERSE order of opening: first the writer, then the reader.
            // And each close needs its own try, because if the first throws,
            // the second would NOT run.
            try {
                if (writer != null) { writer.close(); }
            } catch (IOException e) {
                System.err.println("Warning: failed to close the target: " + e.getMessage());
            } finally {
                try {
                    if (reader != null) { reader.close(); }
                } catch (IOException e) {
                    System.err.println("Warning: failed to close the source: " + e.getMessage());
                }
            }
        }
    }
}

Count what you have to remember to write this correctly:

# Requirement What happens if it is forgotten
1 Declare the variable outside the try Does not compile: the finally cannot see it
2 Initialise it to null Does not compile: variable possibly not initialised
3 Check != null in the finally NullPointerException if the opening failed
4 Wrap the close() in its own try Does not compile if close() declares a checked one
5 Do not rethrow from the closing catch The original exception is lost (section 8)
6 Close in reverse order of opening The dependent resource is closed over an already closed one
7 A nested try/finally for each extra resource If the first close fails, the rest are not closed

With two resources that is twenty lines of bookkeeping for four lines of real work. With three, it is unmaintainable. The predictable result: for years, an enormous fraction of the Java code in production leaked descriptors, because hardly anybody wrote all seven points correctly.

Java 7 solved this at a stroke. This is the same copyCatalog with try-with-resources, so that you can see where you are heading:

public void copyCatalog(String source, String target) throws IOException {
    try (BufferedReader reader = new BufferedReader(new FileReader(source));
         BufferedWriter writer = new BufferedWriter(new FileWriter(target))) {

        String line;
        while ((line = reader.readLine()) != null) {
            writer.write(line);
            writer.newLine();
        }
    }
    // Automatic closing, in reverse order, with the closing exceptions
    // recorded as SUPPRESSED without losing the original.
}

Twenty lines turned into zero. That is the next lesson.

  1. BiblioTech: operations that do not leave the state half-done

Now the application that solves module 5's fourth fragility. The problem, specifically:

// FRAGILE VERSION: if something fails in the middle, the state is left inconsistent
public Loan lend(String reference, String employeeId, int day) {
    Material material = catalog.getByReference(reference);
    Employee employee = getEmployee(employeeId);

    material.lend();                        // step 1: the material is BLOCKED
    employee.registerLoan();                // step 2: the employee uses ALLOWANCE
    Loan l = new Loan(nextReference(), material, employee, day);
    registry.record(l);                     // step 3: if THIS fails...
    reservationQueue.notifyLoan(reference);

    return l;
}

If step 3 throws, the material is left marked as lent with no loan to justify it and the employee loses a slot of their allowance for ever. The material is inaccessible: nobody can lend it because it shows as occupied, and nobody can return it because the loan does not exist.

The solution with finally: a success flag and a compensation block.

package com.nexussoftware.bibliotech.service;

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

import com.nexussoftware.bibliotech.domain.*;

/**
 * Orchestrates loans guaranteeing that, if the operation fails halfway,
 * the state of the Catalog and the LoanRegistry is left CONSISTENT.
 *
 * Technique: success flag + compensation block in finally.
 * It is the manual version of the concept of a TRANSACTION; the real one,
 * with a database, arrives in module 11.
 */
public class LoanManager {

    private final Catalog catalog;
    private final Map<String, Employee> employees = new HashMap<>();
    private final Map<String, Loan> loans = new HashMap<>();

    private int counter = 0;

    public LoanManager(Catalog catalog) {
        this.catalog = Objects.requireNonNull(catalog, "The catalogue cannot be null");
    }

    public void enrol(Employee employee) {
        Objects.requireNonNull(employee, "The employee cannot be null");
        employees.put(employee.getIdentifier(), employee);
    }

    /**
     * Lends a material leaving the system consistent whatever happens.
     *
     * The steps that MODIFY state are marked with flags. If on reaching the
     * finally the operation was not completed, they are undone in reverse order.
     */
    public Loan lend(String reference, String employeeId, int day) {
        Objects.requireNonNull(reference, "The reference cannot be null");
        Objects.requireNonNull(employeeId, "The identifier cannot be null");
        if (day < 1) {
            throw new IllegalArgumentException("The day must be 1 or later, and it was: " + day);
        }

        // --- Phase 1: READING. It modifies nothing, so a failure here is harmless ---
        Material material = catalog.getByReference(reference);           // may throw
        Employee employee = getEmployee(employeeId);                     // may throw

        if (!material.isAvailable()) {
            throw new MaterialNotAvailableException(reference, material.getTitle(),
                    "unknown", day + Loan.LOAN_DAYS);
        }
        if (!employee.canBorrow()) {
            throw new LoanLimitExceededException(employee.getIdentifier(),
                    employee.getName(), employee.getTotalLoans(),
                    Employee.MAX_CONCURRENT_LOANS);
        }

        // --- Phase 2: WRITING. Each step modifies state and is flagged ---
        boolean materialMarked = false;
        boolean allowanceUsed = false;
        boolean loanRecorded = false;
        String loanReference = null;

        try {
            material.lend();
            materialMarked = true;

            employee.registerLoan();
            allowanceUsed = true;

            loanReference = nextReference();
            Loan loan = new Loan(loanReference, material, employee, day);

            loans.put(loanReference, loan);
            loanRecorded = true;

            // Last step, also inside the protected try
            notifyReservationQueue(reference);

            return loan;

        } finally {
            // If the last step was NOT completed, we undo what was done, in reverse order.
            // NOTE: there is NO return, no throw, no break here. Only compensation.
            if (!loanRecorded) {
                System.out.println("  [compensation] the operation failed halfway, undoing...");

                if (loanReference != null) {
                    loans.remove(loanReference);
                    System.out.println("    - loan " + loanReference + " withdrawn");
                }
                if (allowanceUsed) {
                    employee.registerReturn();
                    System.out.println("    - allowance of " + employeeId + " restored");
                }
                if (materialMarked) {
                    material.returnItem();
                    System.out.println("    - material " + reference + " released");
                }
                System.out.println("  [compensation] state restored");
            }
        }
    }

    /**
     * Returns a material leaving the system consistent.
     * The same pattern, in reverse.
     */
    public void returnItem(String loanReference, int day) {
        Objects.requireNonNull(loanReference, "The reference cannot be null");

        Loan loan = loans.get(loanReference);
        if (loan == null) {
            throw new MaterialNotFoundException(loanReference, loans.size());
        }
        if (loan.isReturned()) {
            throw new LoanAlreadyReturnedException(loanReference, loan.getStartDay());
        }

        boolean returnRecorded = false;
        boolean allowanceReleased = false;
        boolean completed = false;

        try {
            loan.registerReturn(day);                // marks the loan and releases the material
            returnRecorded = true;

            loan.getBorrower().registerReturn();
            allowanceReleased = true;

            notifyReservationQueue(loan.getMaterial().getReference());
            completed = true;

        } finally {
            if (!completed) {
                System.out.println("  [compensation] incomplete return, undoing...");
                if (allowanceReleased) {
                    loan.getBorrower().registerLoan();
                }
                if (returnRecorded) {
                    loan.cancelReturn();             // compensation method in Loan
                }
                System.out.println("  [compensation] state restored");
            }
        }
    }

    private Employee getEmployee(String employeeId) {
        Employee employee = employees.get(employeeId);
        if (employee == null) {
            throw new MaterialNotFoundException(employeeId, employees.size());
        }
        return employee;
    }

    /** Simulates the step that can fail (notifying the next one in the reservation queue). */
    private void notifyReservationQueue(String reference) {
        if ("BK-0003".equals(reference)) {
            throw new IllegalStateException(
                    "The notice service is not responding for " + reference);
        }
    }

    private String nextReference() {
        counter++;
        return String.format("LN-%04d", counter);
    }

    public int registeredLoans() { return loans.size(); }
}

And the demonstration that the state is left consistent:

package com.nexussoftware.bibliotech.presentation;

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

public class ConsistencyDemo {

    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-0003", "Refactoring", "Fowler", 1999, "978-0000000003"));

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

        System.out.println("=== INITIAL STATE ===");
        state(catalog, marta, manager);

        System.out.println("\n=== Loan that WORKS ===");
        manager.lend("BK-0001", "EMP-001", 10);
        state(catalog, marta, manager);

        System.out.println("\n=== Loan that FAILS on the last step ===");
        try {
            manager.lend("BK-0003", "EMP-001", 10);
        } catch (IllegalStateException e) {
            System.out.println("  Exception received: " + e.getMessage());
        }

        System.out.println("\n=== STATE AFTER THE FAILURE ===");
        state(catalog, marta, manager);
        System.out.println("""

                CHECK:
                - BK-0003 is available again (it was not left blocked).
                - Marta keeps 1 loan, not 2 (she did not lose allowance).
                - There is no phantom loan in the registry.
                - And the exception reached main INTACT: the finally did not hide it.""");
    }

    private static void state(Catalog catalog, Employee employee, LoanManager manager) {
        System.out.println("  BK-0001 available: "
                + catalog.getByReference("BK-0001").isAvailable());
        System.out.println("  BK-0003 available: "
                + catalog.getByReference("BK-0003").isAvailable());
        System.out.println("  " + employee);
        System.out.println("  Registered loans: " + manager.registeredLoans());
    }
}

Output:

=== INITIAL STATE ===
  BK-0001 available: true
  BK-0003 available: true
  EMP-001 (Marta Ruiz): 0/3 loans
  Registered loans: 0

=== Loan that WORKS ===
  BK-0001 available: false
  BK-0003 available: true
  EMP-001 (Marta Ruiz): 1/3 loans
  Registered loans: 1

=== Loan that FAILS on the last step ===
  [compensation] the operation failed halfway, undoing...
    - loan LN-0002 withdrawn
    - allowance of EMP-001 restored
    - material BK-0003 released
  [compensation] state restored
  Exception received: The notice service is not responding for BK-0003

=== STATE AFTER THE FAILURE ===
  BK-0001 available: false
  BK-0003 available: true
  EMP-001 (Marta Ruiz): 1/3 loans
  Registered loans: 1

CHECK:
- BK-0003 is available again (it was not left blocked).
- Marta keeps 1 loan, not 2 (she did not lose allowance).
- There is no phantom loan in the registry.
- And the exception reached main INTACT: the finally did not hide it.

The four design decisions that make this pattern work:

  1. Separating reading from writing. All the validation and searching happen before touching anything. If something fails in phase 1, there is nothing to undo. It is the direct application of the fail-fast from 06-03: the later you start modifying, the less you will have to compensate.
  2. One flag for each step that modifies state. Without them, the finally would not know how far the operation got, and undoing too much would be as serious as not undoing at all.
  3. Undoing in reverse order. Just as resources are closed: the last completed step is the first to be reverted.
  4. The finally does not throw, does not return and does not catch. It only compensates. That is why the original exception reaches main intact —and why, if the compensation itself could fail, it would have to be wrapped in its own inner try so as not to lose the main exception, exactly the problem of section 8.

And a necessary honesty about the limits of this technique: this is manual compensation, not a real transaction. It is not atomic against another thread looking at the state in the middle of the operation (module 8), and it does not survive a JVM crash (section 9). Real transactions arrive in module 11 with Spring and Hibernate. But for a single-threaded application like BiblioTech, it solves exactly the fragility we had.

Common Mistakes and Tips

Putting a return in the finally. It discards the try's value and —the serious part— discards any pending exception, turning a failure into a normal result. The same with break, continue and throw. The finally cleans up; it does not decide.

Letting the finally throw unprotected. If the try had already thrown, the finally's exception replaces it and the original disappears without a trace. If the finally's code can fail, wrap it in its own inner try... or use try-with-resources (06-06), which is what it exists for.

Declaring the resource inside the try. The finally cannot see it: it does not compile. It goes outside, initialised to null.

Forgetting the null check in the finally. If opening the resource failed, the variable is still null and the close() throws a NullPointerException that also covers up the original failure.

Closing the resources in the opening order. It has to be done the other way round. Closing the FileReader first and then the BufferedReader wrapping it leaves the second operating over an already closed stream.

Believing finally always runs, with no exceptions. It does not with System.exit() nor when the JVM or the thread die abnormally. For the application's global clean-up there are the shutdown hooks; for durability, persistence.

Using finally for business logic. If the block does anything more than clean up or compensate, it is in the wrong place. finally is for undoing and releasing, not for "the final step of the process".

Compensating without flags. Undoing a step that never ran is as destructive as not undoing the one that did. One flag per modifying step.

Tip: try-finally without catch is one of the most elegant forms in Java. It means exactly "I do not know how to handle this, but I clean up before it leaves", and it leaves the exception intact for whoever does know.

Tip: validate everything before modifying anything. The more code there is between the first modification and the end of the operation, the more compensation surface you have. The fail-fast of 06-03 reduces the problem at source.

Tip: enable -Xlint:finally when compiling. It warns about finally blocks that cannot complete normally, which is exactly the error of section 7.

Tip: in new code, do not write the manual pattern of section 10. It is here so that you understand what the next lesson automates, not for you to use it.

Exercises

Exercise 1: tracing the order of execution

Without running the code, predict the exact output of each method and the value it returns. Then run it and compare. For each discrepancy, explain the rule you were missing.

public class GuessIt {

    static int methodA() {
        int x = 1;
        try {
            x = 2;
            return x;
        } finally {
            x = 3;
            System.out.println("A-finally, x=" + x);
        }
    }

    static int methodB() {
        try {
            return 1;
        } finally {
            return 2;
        }
    }

    static String methodC() {
        StringBuilder sb = new StringBuilder("start");
        try {
            return sb.toString();
        } finally {
            sb.append("-modified");
        }
    }

    static StringBuilder methodD() {
        StringBuilder sb = new StringBuilder("start");
        try {
            return sb;
        } finally {
            sb.append("-modified");
        }
    }

    static int methodE() {
        try {
            throw new IllegalStateException("failure");
        } finally {
            System.out.println("E-finally");
        }
    }

    static int methodF() {
        try {
            throw new IllegalStateException("failure");
        } finally {
            return -1;
        }
    }

    static int methodG() {
        int total = 0;
        for (int i = 1; i <= 3; i++) {
            try {
                if (i == 2) { continue; }
                total += i;
            } finally {
                total += 10;
                System.out.println("G-finally i=" + i + " total=" + total);
            }
        }
        return total;
    }
}

Also answer: why do methodC and methodD behave differently if the finally is identical?

Exercise 2: CatalogSession with a guaranteed lock

Write CatalogSession in com.nexussoftware.bibliotech.service, simulating a work session over the catalogue with an exclusive lock.

Requirements:

  • Fields: locked (boolean), operationsPerformed (int), sessionsOpened (static counter).
  • void open(): if it is already locked, throws IllegalStateException; if not, it locks and increments the session counter.
  • void close(): releases the lock. It must be idempotent: calling it twice must not fail.
  • T execute(String operationName, Supplier<T> operation): a generic method using java.util.function.Supplier (04-06) that opens the session, runs the operation and guarantees the close with try-finally, even if the operation throws. It records the operation in operationsPerformed only if it succeeded.
  • void executeWithoutResult(String name, Runnable operation): a variant for operations with no return value.

In main, demonstrate:

  1. A correct operation: the session closes and the counter goes up.
  2. An operation that throws: the exception reaches the caller intact and the session closes all the same.
  3. That after the failure a new session can be opened (the lock was not left stuck).
  4. That close() twice in a row does not fail.
  5. An attempt to open two sessions at once, which must fail with a clear message.

Exercise 3: loan transfer with compensation

At Nexus Software it is common for an employee to hand a material over to another without returning it to the library. Implement LoanTransfer.transfer(String loanReference, String targetId, int day), which passes an active loan from one employee to another.

The operation has five steps that modify state:

  1. Release the current borrower's allowance.
  2. Use up the target employee's allowance.
  3. Change the loan's borrower.
  4. Record a transfer-type Incident on the loan.
  5. Notify the notice queue (this step can fail).

Requirements:

  • Validate everything before modifying anything: the loan exists, it is not returned, the target exists, the target is not the same current borrower and the target has allowance.
  • Use flags and a compensation finally that undoes the completed steps in reverse order.
  • The original exception must reach the caller intact.
  • The compensation can itself fail: protect it so as not to lose the main exception, and if the compensation fails, add it as suppressed to the original with addSuppressed() (anticipating 06-06).
  • Write a main demonstrating: a correct transfer, one that fails on step 5 with the state restored, and one that fails in validation (with no compensation, because nothing was modified).

Verify at the end that the sum of accumulated loans across all employees matches the number of active loans, in every scenario.

Solutions

Solution 1

Outputs and values:

Method Console output Returns Rule applied
methodA A-finally, x=3 2 The return value is copied before the finally. Modifying the variable afterwards does not change what is returned
methodB (nothing) 2 The finally's return wins and discards the try's
methodC (nothing) "start" toString() creates a new, immutable String; modifying the StringBuilder afterwards does not affect it
methodD (nothing) "start-modified" The reference is returned; the finally modifies the object pointed to, and the receiver sees the change
methodE E-finally (throws IllegalStateException) The finally runs and the exception carries on up
methodF (nothing) -1 The finally's return swallows the exception. The failure disappears
methodG three lines (below) 34 The finally runs with continue too

Detailed trace of methodG:

i=1: no continue, total += 1 -> 1;       finally: total += 10 -> 11
G-finally i=1 total=11
i=2: continue, total += 2 is skipped;    finally: total += 10 -> 21
G-finally i=2 total=21
i=3: total += 3 -> 24;                   finally: total += 10 -> 34
G-finally i=3 total=34
Returns 34

Why methodC and methodD differ with an identical finally:

It is the distinction between value and reference, and the key is in what is copied when the return is evaluated:

  • In methodC, sb.toString() builds a new String object with the current content ("start"). That String is immutable and has no connection at all to the StringBuilder. The finally modifies the StringBuilder, which no longer matters to anybody.
  • In methodD the reference to the StringBuilder is returned. What is copied is the address, not the content. The finally modifies the object that address points to, and whoever receives the reference sees the change.

It is exactly the pass-by-value of references from 03-03: the return value is frozen, but if that value is a reference, the object pointed to remains mutable.

And the warning methodB and methodF give when compiling with -Xlint:finally:

warning: [finally] finally clause cannot complete normally

It is the compiler literally telling you that this finally is not going to let out whatever was in progress.

Solution 2

package com.nexussoftware.bibliotech.service;

import java.util.function.Supplier;

/**
 * Work session with an exclusive lock over the catalogue.
 *
 * The lock is ALWAYS released thanks to try-finally, even when the
 * operation throws. The exception, on the other hand, reaches the caller intact:
 * this class cleans up, it does not handle.
 */
public class CatalogSession {

    private static int sessionsOpened = 0;

    private boolean locked = false;
    private int operationsPerformed = 0;

    /**
     * Opens the session acquiring the lock.
     *
     * @throws IllegalStateException if there is already an open session
     */
    public void open() {
        if (locked) {
            throw new IllegalStateException(
                    "There is already an open session over the catalogue. Close it before opening another.");
        }
        locked = true;
        sessionsOpened++;
        System.out.println("    [session #" + sessionsOpened + " opened]");
    }

    /**
     * Closes the session. It is IDEMPOTENT: calling it on an already closed session
     * does nothing and does not fail. It is a good practice picked up again in 06-06
     * when discussing the contract of close().
     */
    public void close() {
        if (!locked) {
            return;                      // it was already closed: not an error
        }
        locked = false;
        System.out.println("    [session closed]");
    }

    /**
     * Runs an operation with a result inside a session.
     *
     * Supplier<T> from java.util.function is used (04-06). The <T> is an existing
     * generic type; generics of your own are the subject of 10-01.
     */
    public <T> T execute(String operationName, Supplier<T> operation) {
        System.out.println("  Running '" + operationName + "'");
        open();

        boolean completed = false;
        try {
            T result = operation.get();
            completed = true;            // only gets here if it did NOT throw
            return result;

        } finally {
            // Guaranteed clean-up. No return, no throw: the exception passes intact.
            if (completed) {
                operationsPerformed++;
                System.out.println("    [operation '" + operationName + "' counted]");
            } else {
                System.out.println("    [operation '" + operationName
                        + "' failed: NOT counted]");
            }
            close();
        }
    }

    /** Variant for operations with no result. */
    public void executeWithoutResult(String operationName, Runnable operation) {
        execute(operationName, () -> {
            operation.run();
            return null;                 // Supplier needs to return something
        });
    }

    public boolean isLocked()            { return locked; }
    public int getOperationsPerformed()  { return operationsPerformed; }
    public static int getSessionsOpened() { return sessionsOpened; }

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

    public static void main(String[] args) {
        CatalogSession session = new CatalogSession();

        System.out.println("=== 1. Correct operation ===");
        int total = session.execute("count materials", () -> {
            System.out.println("    counting...");
            return 3;
        });
        System.out.println("  Result: " + total);
        System.out.println("  Locked: " + session.isLocked()
                + " | Operations: " + session.getOperationsPerformed());

        System.out.println("\n=== 2. Operation that throws ===");
        try {
            session.execute("reindex", () -> {
                System.out.println("    reindexing...");
                throw new IllegalStateException("Corrupt index at position 7");
            });
        } catch (IllegalStateException e) {
            System.out.println("  Exception received INTACT: " + e.getMessage());
        }
        System.out.println("  Locked: " + session.isLocked()
                + " | Operations: " + session.getOperationsPerformed());

        System.out.println("\n=== 3. New session after the failure ===");
        String title = session.execute("read title", () -> "Effective Java");
        System.out.println("  Result: " + title);

        System.out.println("\n=== 4. close() twice ===");
        session.close();
        session.close();
        System.out.println("  It did not fail: close() is idempotent");

        System.out.println("\n=== 5. Two sessions at once ===");
        try {
            session.execute("outer", () -> {
                // Inside the operation ANOTHER session is opened
                session.open();
                return "impossible";
            });
        } catch (IllegalStateException e) {
            System.out.println("  " + e.getMessage());
        }
        System.out.println("  Locked after the attempt: " + session.isLocked());

        System.out.println("\n=== Summary ===");
        System.out.println("  Sessions opened in total: " + getSessionsOpened());
        System.out.println("  Operations completed    : " + session.getOperationsPerformed());
    }
}

Output:

=== 1. Correct operation ===
  Running 'count materials'
    [session #1 opened]
    counting...
    [operation 'count materials' counted]
    [session closed]
  Result: 3
  Locked: false | Operations: 1

=== 2. Operation that throws ===
  Running 'reindex'
    [session #2 opened]
    reindexing...
    [operation 'reindex' failed: NOT counted]
    [session closed]
  Exception received INTACT: Corrupt index at position 7
  Locked: false | Operations: 1

=== 3. New session after the failure ===
  Running 'read title'
    [session #3 opened]
    [operation 'read title' counted]
    [session closed]
  Result: Effective Java

=== 4. close() twice ===
  It did not fail: close() is idempotent

=== 5. Two sessions at once ===
  Running 'outer'
    [session #4 opened]
    [operation 'outer' failed: NOT counted]
    [session closed]
  There is already an open session over the catalogue. Close it before opening another.
  Locked after the attempt: false

=== Summary ===
  Sessions opened in total: 4
  Operations completed    : 2

Point 2 is the one demonstrating the value of try-finally without catch: the session closed, the operation was not counted, and the exception reached main with its original message and its stack. The class cleaned up without deciding.

And notice point 5: even though the operation attempted something illegal, the lock was left released. Without the finally, the catalogue would have been left locked for ever and the application useless.

Solution 3

package com.nexussoftware.bibliotech.service;

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

import com.nexussoftware.bibliotech.domain.*;

/**
 * Transfer of an active loan between employees, with compensation
 * guaranteed if the operation fails halfway.
 *
 * It demonstrates:
 *  - Validating everything before modifying anything.
 *  - One flag per modifying step.
 *  - Compensation in reverse order inside the finally.
 *  - Protecting the compensation itself with addSuppressed (anticipating 06-06).
 */
public class LoanTransfer {

    private final Map<String, Loan> loans;
    private final Map<String, Employee> employees;

    /** Reference that will make step 5 fail, for the demonstration. */
    private String referenceThatFailsOnNotify = null;

    public LoanTransfer(Map<String, Loan> loans,
                        Map<String, Employee> employees) {
        this.loans = Objects.requireNonNull(loans);
        this.employees = Objects.requireNonNull(employees);
    }

    public void simulateNotificationFailure(String loanReference) {
        this.referenceThatFailsOnNotify = loanReference;
    }

    /**
     * Transfers an active loan to another employee.
     *
     * @throws MaterialNotFoundException if the loan or the target do not exist
     * @throws LoanAlreadyReturnedException if the loan was already returned
     * @throws IllegalArgumentException     if the target is the current borrower
     * @throws LoanLimitExceededException if the target has no allowance
     * @throws IllegalStateException        if the notification fails (step 5)
     */
    public void transfer(String loanReference, String targetId, int day) {

        // ================== PHASE 1: VALIDATION (modifies nothing) ==================
        Objects.requireNonNull(loanReference, "The loan reference cannot be null");
        Objects.requireNonNull(targetId, "The target identifier cannot be null");
        if (day < 1) {
            throw new IllegalArgumentException("The day must be 1 or later, and it was: " + day);
        }

        Loan loan = loans.get(loanReference);
        if (loan == null) {
            throw new MaterialNotFoundException(loanReference, loans.size());
        }
        if (loan.isReturned()) {
            throw new LoanAlreadyReturnedException(loanReference, loan.getStartDay());
        }

        Employee target = employees.get(targetId);
        if (target == null) {
            throw new MaterialNotFoundException(targetId, employees.size());
        }

        Employee source = loan.getBorrower();
        if (source.getIdentifier().equals(targetId)) {
            throw new IllegalArgumentException(
                    "The loan " + loanReference + " already belongs to " + targetId);
        }
        if (!target.canBorrow()) {
            throw new LoanLimitExceededException(target.getIdentifier(),
                    target.getName(), target.getTotalLoans(),
                    Employee.MAX_CONCURRENT_LOANS);
        }

        // ================== PHASE 2: MODIFICATION (with flags) ==================
        boolean sourceAllowanceReleased = false;
        boolean targetAllowanceUsed = false;
        boolean borrowerChanged = false;
        boolean incidentRecorded = false;
        boolean completed = false;

        try {
            // Step 1
            source.registerReturn();
            sourceAllowanceReleased = true;

            // Step 2
            target.registerLoan();
            targetAllowanceUsed = true;

            // Step 3
            loan.changeBorrower(target);
            borrowerChanged = true;

            // Step 4
            loan.recordIncident(new Loan.Incident(day,
                    "Transferred from " + source.getIdentifier() + " to " + targetId,
                    Severity.ON_TIME));
            incidentRecorded = true;

            // Step 5: the one that can fail
            notifyNoticeQueue(loanReference, targetId);

            completed = true;
            System.out.println("  Transfer " + loanReference + ": "
                    + source.getIdentifier() + " -> " + targetId + " OK");

        } finally {
            if (!completed) {
                compensate(loan, source, target,
                        sourceAllowanceReleased, targetAllowanceUsed,
                        borrowerChanged, incidentRecorded);
            }
        }
    }

    /**
     * Undoes the completed steps, in REVERSE ORDER.
     *
     * If the compensation fails, it is NOT rethrown: that would replace the
     * original exception (section 8). It is recorded as SUPPRESSED on the exception in flight.
     */
    private void compensate(Loan loan, Employee source, Employee target,
                            boolean sourceAllowanceReleased, boolean targetAllowanceUsed,
                            boolean borrowerChanged, boolean incidentRecorded) {

        System.out.println("  [compensation] incomplete transfer, undoing...");
        RuntimeException compensationFailure = null;

        // Reverse order: 4 -> 3 -> 2 -> 1
        if (incidentRecorded) {
            try {
                loan.removeLastIncident();
                System.out.println("    - incident removed");
            } catch (RuntimeException e) {
                compensationFailure = accumulate(compensationFailure, e);
            }
        }
        if (borrowerChanged) {
            try {
                loan.changeBorrower(source);
                System.out.println("    - borrower restored to " + source.getIdentifier());
            } catch (RuntimeException e) {
                compensationFailure = accumulate(compensationFailure, e);
            }
        }
        if (targetAllowanceUsed) {
            try {
                target.registerReturn();
                System.out.println("    - allowance of " + target.getIdentifier() + " restored");
            } catch (RuntimeException e) {
                compensationFailure = accumulate(compensationFailure, e);
            }
        }
        if (sourceAllowanceReleased) {
            try {
                source.registerLoan();
                System.out.println("    - allowance of " + source.getIdentifier() + " restored");
            } catch (RuntimeException e) {
                compensationFailure = accumulate(compensationFailure, e);
            }
        }

        if (compensationFailure != null) {
            // We cannot throw: we would lose the original exception.
            // We leave it recorded. In 06-07 this will be a logger.log(SEVERE, ...).
            System.err.println("  [compensation] WARNING: the compensation failed partially: "
                    + compensationFailure.getMessage());
        } else {
            System.out.println("  [compensation] state fully restored");
        }
    }

    private RuntimeException accumulate(RuntimeException accumulated, RuntimeException fresh) {
        if (accumulated == null) {
            return fresh;
        }
        accumulated.addSuppressed(fresh);     // they will be seen with getSuppressed() (06-06)
        return accumulated;
    }

    private void notifyNoticeQueue(String loanReference, String targetId) {
        if (loanReference.equals(referenceThatFailsOnNotify)) {
            throw new IllegalStateException(
                    "The notice service is not responding when notifying " + loanReference
                            + " to " + targetId);
        }
        System.out.println("    (notice sent to " + targetId + ")");
    }

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

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

        Employee marta = new Employee("Marta Ruiz", "EMP-001");
        Employee diego = new Employee("Diego Alonso", "EMP-002");
        Employee nuria = new Employee("Nuria Vidal", "EMP-003");

        Map<String, Employee> employees = new HashMap<>();
        employees.put("EMP-001", marta);
        employees.put("EMP-002", diego);
        employees.put("EMP-003", nuria);

        // Loan's constructor already calls material.lend() and
        // borrower.registerLoan(): there is no need to do it by hand (06-03).
        Map<String, Loan> loans = new HashMap<>();
        loans.put("LN-0001", new Loan("LN-0001", b1, marta, 10));
        loans.put("LN-0002", new Loan("LN-0002", b2, marta, 10));

        LoanTransfer service = new LoanTransfer(loans, employees);

        System.out.println("=== INITIAL STATE ===");
        state(employees, loans);

        System.out.println("\n=== 1. Correct transfer ===");
        service.transfer("LN-0001", "EMP-002", 12);
        state(employees, loans);

        System.out.println("\n=== 2. Transfer that fails on step 5 ===");
        service.simulateNotificationFailure("LN-0002");
        try {
            service.transfer("LN-0002", "EMP-003", 13);
        } catch (IllegalStateException e) {
            System.out.println("  Exception received INTACT: " + e.getMessage());
        }
        state(employees, loans);

        System.out.println("\n=== 3. Validation failure (no compensation) ===");
        try {
            service.transfer("LN-9999", "EMP-003", 14);
        } catch (MaterialNotFoundException e) {
            System.out.println("  " + e.getMessage());
            System.out.println("  (there was no compensation: nothing was modified)");
        }
        state(employees, loans);
    }

    /** Checks the system's global invariant. */
    private static void state(Map<String, Employee> employees, Map<String, Loan> loans) {
        int allowanceSum = 0;
        for (Employee e : employees.values()) {
            System.out.println("  " + e);
            allowanceSum += e.getTotalLoans();
        }
        int active = 0;
        for (Loan l : loans.values()) {
            if (!l.isReturned()) { active++; }
            System.out.println("  " + l.getReference() + " -> "
                    + l.getBorrower().getIdentifier());
        }
        System.out.println("  INVARIANT: sum of allowances (" + allowanceSum
                + ") == active loans (" + active + ") -> "
                + (allowanceSum == active ? "OK" : "BROKEN"));
    }
}

Output:

=== INITIAL STATE ===
  EMP-001 (Marta Ruiz): 2/3 loans
  EMP-002 (Diego Alonso): 0/3 loans
  EMP-003 (Nuria Vidal): 0/3 loans
  LN-0001 -> EMP-001
  LN-0002 -> EMP-001
  INVARIANT: sum of allowances (2) == active loans (2) -> OK

=== 1. Correct transfer ===
    (notice sent to EMP-002)
  Transfer LN-0001: EMP-001 -> EMP-002 OK
  EMP-001 (Marta Ruiz): 1/3 loans
  EMP-002 (Diego Alonso): 1/3 loans
  EMP-003 (Nuria Vidal): 0/3 loans
  LN-0001 -> EMP-002
  LN-0002 -> EMP-001
  INVARIANT: sum of allowances (2) == active loans (2) -> OK

=== 2. Transfer that fails on step 5 ===
  [compensation] incomplete transfer, undoing...
    - incident removed
    - borrower restored to EMP-001
    - allowance of EMP-003 restored
    - allowance of EMP-001 restored
  [compensation] state fully restored
  Exception received INTACT: The notice service is not responding when notifying LN-0002 to EMP-003
  EMP-001 (Marta Ruiz): 1/3 loans
  EMP-002 (Diego Alonso): 1/3 loans
  EMP-003 (Nuria Vidal): 0/3 loans
  LN-0001 -> EMP-002
  LN-0002 -> EMP-001
  INVARIANT: sum of allowances (2) == active loans (2) -> OK

=== 3. Validation failure (no compensation) ===
  There is no material with the reference 'LN-9999' (the catalogue has 2 materials)
  (there was no compensation: nothing was modified)
  INVARIANT: sum of allowances (2) == active loans (2) -> OK

The three things the exercise demonstrates:

  1. The global invariant holds in all three scenarios. The sum of consumed allowances always matches the number of active loans, even after a failure halfway through a five-step operation.
  2. The exception arrives intact. The finally compensated and hid nothing: main received the IllegalStateException with its original message.
  3. The compensation is protected. Each undoing step goes in its own try, and the compensation failures accumulate with addSuppressed() instead of being rethrown. If they were rethrown, they would replace the original exception —the error of section 8— and we would lose the real reason for the failure.

That use of addSuppressed() is no accident: it is exactly the mechanism try-with-resources applies automatically, and which you will see in detail in the next lesson.

Conclusion

You now know finally's guarantee and its exact limits. You know it runs whenever the try has been entered: with no exception, with a caught exception, with an uncaught exception —right before it carries on up— and also when the try or the catch do return, break or continue. And you know that with nested try blocks the finally blocks fire from the inside out, at the same pace at which frames are popped, so that each level cleans up its own without knowing anything about the others.

You understand the precise mechanics of the return: the value is computed and saved before running the finally, so modifying the variable afterwards does not change what is returned —unless what is returned is a reference to a mutable object, in which case the object can indeed change, as methodC and methodD demonstrated.

You know the valid combinations —try-catch, try-catch-finally and try-finally, always with the finally last— and the specific value of try-finally without catch: "I do not know how to handle this, but I clean up before it leaves", which separates cleaning up from handling and leaves the exception intact for whoever does know how to decide.

You have the two traps that must be avoided without exception. The first: a return —or break, continue, throw— inside the finally discards the try's value and, far worse, swallows any pending exception, turning a corrupt catalogue into a calm -1. The second: a finally that throws its own exception replaces the original, which disappears with no cause and no suppressed ones —the all-too-real case of a close() that fails and erases the true failure. You have seen the thirteen-line manual workaround that avoids it, and you know a construct exists that does all that for you.

You know the guarantee is not absolute: finally does not run with System.exit() nor when the JVM or the thread die abnormally, and that for the application's global clean-up there are the shutdown hooks —which do run with System.exit, but not with a halt() or a SIGKILL either. Hence the conclusion worth retaining: finally guarantees consistency inside the process, not durability outside it.

And you have seen the manual closing pattern from before Java 7 in all its length: declare outside, initialise to null, check != null, wrap the close() in its own try, do not rethrow from there, close in reverse order and nest a try/finally for each extra resource. Seven requirements almost nobody met, and twenty lines of bookkeeping for four of real work.

BiblioTech has solved module 5's fourth fragility. LoanManager.lend and returnItem separate the reading phase from the writing phase, flag every step that modifies state and compensate in reverse order inside a finally that does not throw, does not return and does not catch. The result, demonstrated with the invariant "sum of consumed allowances == active loans": when the notification to the notice queue fails on the last step, the material is available again, the employee gets their allowance back, no phantom loan is left and the exception reaches main intact. With the honesty of acknowledging that this is manual compensation, not a real transaction: it is not atomic against another thread (module 8) and it does not survive a JVM crash, and real transactions arrive with Spring and Hibernate in module 11.

From module 5's list of fragilities only the last one remains, that of persistence, which is module 7. But first the debt this lesson has left open twice has to be settled: the finally that loses the original exception when closing, and the twenty lines of the manual pattern.

That is the next lesson, Try-with-resources and AutoCloseable: Java 7's automatic resource management syntax and its exact equivalence to the manual try-finally, shown side by side; the AutoCloseable and Closeable interfaces and their difference; several resources in the same declaration with a closing order the reverse of the opening order, demonstrated with traces; the implicitly final variable and the Java 9 form that accepts an already existing variable; suppressed exceptions, which solve exactly the problem of section 8 by preserving the original and leaving the closing one accessible with getSuppressed(); how to combine try-with-resources with catch and finally; which resources must not be closed this way —starting with System.in and the module 1 Scanner—; and how to make your own classes closeable, with a LibrarySession that opens a work shift and on closing consolidates the statistics and releases the locks.

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