You already have all the technical pieces: you know how to read a failure, catch it, throw it, give it a name of its own, guarantee the clean-up and close resources without losing information. What is missing is the part you do not learn by reading the language specification: deciding where each thing goes.

Because a system with perfectly designed exceptions can still be unmanageable if every layer catches what is not its business, if the user gets a stack trace in their face, if one failure appears five times in the log or —BiblioTech's case so far— if there is no log at all and the whole diagnosis is System.out.println calls that vanish when the terminal closes.

This lesson closes the module with two halves. The first is strategy: where to catch, what each layer does, how the error boundary in main is built, what information reaches the user and what only the log, and when it is better to validate than to throw. The second is logging: why System.out.println is not an option in production, how java.util.logging is really used, what to log at each level, what must never be logged, and what the real ecosystem you will see in 11-07 looks like.

By the end, BiblioTech will not have a single diagnostic System.out.println, its main will have a boundary stopping anything escaping unlogged, and the warnings will be records with a timestamp, a level and context, filterable and archivable. In other words: software you can put into production.

Contents

  1. Where an exception is caught
  2. BiblioTech's layers and what each does with errors
  3. The error boundary in main
  4. The global uncaught exception handler
  5. What the user sees and what the log sees
  6. Recoverable and unrecoverable errors: graceful degradation
  7. Retries with waits and their limit
  8. Prior validation versus exception
  9. The "result object" pattern and Optional
  10. Idempotence and consistent state
  11. Why System.out.println is no good in production
  12. The log levels and what to log at each one
  13. java.util.logging in practice
  14. Handler, Formatter and logging.properties
  15. Logging an exception correctly
  16. What must NEVER be logged
  17. Useful log messages and correlation
  18. The real ecosystem: SLF4J, Logback and Log4j2
  19. BiblioTech: the final refactoring
  20. Common Mistakes and Tips
  21. Exercises

  1. Where an exception is caught

The rule that summarises everything else:

Catch an exception where you can take a decision about it, not where it happens.

It sounds obvious and yet most badly written code breaks it. The reflex of surrounding any line that can fail with a try-catch produces this:

// BAD: catches where it happens, unable to decide anything
public Material find(String reference) {
    try {
        return catalog.getByReference(reference);
    } catch (MaterialNotFoundException e) {
        e.printStackTrace();       // not a decision
        return null;               // and now we return the null that took so much effort to remove
    }
}

That catch decides nothing: it does not recover, does not retry, does not translate and adds no context. All it does is downgrade an exception with a name and data to a mute null, undoing all the work of 06-03 and 06-04.

The correct version is not to catch:

// GOOD: nothing is caught. It goes up to whoever can decide.
public Material find(String reference) {
    return catalog.getByReference(reference);
}

The three questions deciding whether you should catch here:

  1. Can I do something other than propagate? Recover with a default value, retry, degrade the service. If not, do not catch.
  2. Do I have context to add? If I can wrap adding information the lower layer did not know, catch and translate preserving the cause (06-03).
  3. Am I the boundary? If above me there is only the JVM or the user, I have to catch no matter what, because nobody else will.

If the answer to all three is no, let it go up. Not catching is an active decision and very often the right one.

  1. BiblioTech's layers and what each does with errors

A real application is organised in layers, and each has a different error policy:

flowchart TB
    U["USER<br/>Marta, Diego, Nuria"]

    subgraph P["PRESENTATION - BiblioTechMenu, ConsoleReceipt"]
        P1["CATCHES everything the user can understand<br/>Translates into clear messages<br/>Logs the unexpected<br/>NEVER shows a stack trace"]
    end

    subgraph S["SERVICE - LoanManager, Catalog, ReservationQueue"]
        S1["Applies business rules and THROWS domain exceptions<br/>Translates persistence ones preserving the cause<br/>Guarantees consistent state with finally<br/>Hardly ever catches"]
    end

    subgraph D["DOMAIN - Material, Book, Employee, Loan"]
        D1["Validates invariants and THROWS<br/>Fail-fast in constructors and setters<br/>NEVER catches or logs"]
    end

    subgraph PE["PERSISTENCE - module 7"]
        PE1["Throws IOException and similar<br/>The service translates them"]
    end

    U -->|"enters data"| P1
    P1 -->|"invokes"| S1
    S1 -->|"uses"| D1
    S1 -->|"reads and writes"| PE1

    D1 -.->|"BiblioTechException"| S1
    PE1 -.->|"IOException"| S1
    S1 -.->|"BiblioTechException<br/>with the cause preserved"| P1
    P1 -.->|"clear message,<br/>no internal details"| U

In table form:

Layer Catches? Throws? Logs? What does it show?
Domain Hardly ever Yes, whenever an invariant is violated No Nothing: it does not know the interface
Service Only to translate or compensate Yes, in the domain's vocabulary Only what is relevant to the business Nothing
Persistence Only to translate Yes, I/O ones Technical details Nothing
Presentation Yes, everything expectable Rarely Everything unexpected Messages to the user

The two rules that follow and are worth engraving:

The domain never logs or prints. A Book does not know whether its application is console, web or mobile, nor whether anybody is watching. Its only way of communicating a problem is to throw. It is the abstraction of 03-08 applied to error handling: each layer speaks the vocabulary of its level and assumes nothing about those above.

Presentation is the filter. It is the only layer that knows who is on the other side and what they can understand, and therefore the only one that can decide which message to show. It is also the only one that should log unexpected failures in full detail.

  1. The error boundary in main

However well responsibilities are distributed, something can always escape. The error boundary is the application's outermost try-catch, and its mission is that nothing ever reaches the JVM unlogged and without a decent message.

package com.nexussoftware.bibliotech.presentation;

import java.util.logging.Level;
import java.util.logging.Logger;

import com.nexussoftware.bibliotech.domain.BiblioTechException;

/**
 * BiblioTech's entry point with an error boundary.
 *
 * Structure of the boundary, from most specific to most general:
 *   1. BiblioTechException : expected domain failure -> clean message, exit 1
 *   2. RuntimeException    : bug -> generic message with an incident, full log, exit 2
 *   3. Error               : JVM failure -> minimal log and immediate exit, exit 3
 */
public class BiblioTechApp {

    private static final Logger LOG = Logger.getLogger(BiblioTechApp.class.getName());

    public static void main(String[] args) {
        LogConfiguration.initialise();
        installGlobalHandler();

        LOG.info("BiblioTech starting up");

        try {
            new BiblioTechMenu().start();
            LOG.info("BiblioTech finished correctly");

        } catch (BiblioTechException e) {
            // EXPECTED domain failure that nobody handled. Rare, but readable.
            System.err.println();
            System.err.println("The operation could not be completed: " + e.getMessage());
            LOG.log(Level.WARNING, "Unhandled domain exception [" + e.getCode() + "]", e);
            System.exit(1);

        } catch (RuntimeException e) {
            // UNEXPECTED failure: it is a bug. The user must not see the details.
            String incident = generateIncidentId();
            System.err.println();
            System.err.println("An internal error has occurred.");
            System.err.println("Incident: " + incident);
            System.err.println("Please report this code to the Nexus Software support team.");
            LOG.log(Level.SEVERE, "Unhandled error. Incident " + incident, e);
            System.exit(2);

        } catch (Error e) {
            // JVM failure: it cannot be recovered from. Log the minimum and leave.
            // It is caught HERE and only here, to leave a record before dying.
            System.err.println();
            System.err.println("Unrecoverable virtual machine error: "
                    + e.getClass().getSimpleName());
            try {
                LOG.log(Level.SEVERE, "Unrecoverable error", e);
            } catch (Throwable ignored) {
                // If it cannot even be logged (e.g. OutOfMemoryError),
                // there is nothing more to do. It is not propagated so as not to hide the Error.
            }
            System.exit(3);
        }
    }

    /** Short identifier the user can report to support. */
    private static String generateIncidentId() {
        return "INC-" + Long.toHexString(System.nanoTime()).toUpperCase().substring(0, 8);
    }

    private static void installGlobalHandler() {
        Thread.setDefaultUncaughtExceptionHandler((thread, failure) ->
                LOG.log(Level.SEVERE, "Uncaught exception in thread '"
                        + thread.getName() + "'", failure));
    }
}

The decisions that make this boundary a good one:

Element Why
Three ordered catch blocks From specific to general (06-02). BiblioTechException first, because it is what produces a useful message
Distinguishing domain from bug A business failure deserves its message; a bug deserves an incident and silence about the details
Incident identifier The user reports INC-A3F91C0B, and the log holds the exact stack trace. Without exposing anything
Different exit codes 1 business, 2 bug, 3 fatal. A script or a continuous integration system can react differently
Catching Error only here It is the only exception to the rule from 06-01: it is caught to log before dying, never to carry on
The Error logging, protected With an OutOfMemoryError, even writing to the log can fail

That is why it is said that catch (Exception e) is only legitimate at the boundary: here there is nobody above, so catching broadly hides nothing from anybody.

  1. The global uncaught exception handler

The main boundary covers the main thread. But as soon as there are more threads —module 8—, an exception escaping from a secondary thread kills that thread silently and the program carries on as if nothing had happened. It is a classic source of invisible failures.

Thread.setDefaultUncaughtExceptionHandler installs a safety net for all threads:

package com.nexussoftware.bibliotech.presentation;

import java.util.logging.Level;
import java.util.logging.Logger;

public final class GlobalHandler {

    private static final Logger LOG = Logger.getLogger(GlobalHandler.class.getName());

    private GlobalHandler() { }

    public static void install() {
        Thread.setDefaultUncaughtExceptionHandler((thread, failure) -> {
            // This code runs when an exception escapes from ANY thread
            LOG.log(Level.SEVERE,
                    "Uncaught exception in thread '" + thread.getName()
                            + "' (id " + thread.threadId() + ")", failure);

            if (failure instanceof Error) {
                LOG.severe("Unrecoverable error: application shutdown requested");
                System.exit(3);
            }
        });

        // Shutdown hook: last chance for clean-up (06-05)
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            LOG.info("BiblioTech shutting down: consolidating state");
            // In module 7, the catalogue will be persisted here
        }, "bibliotech-shutdown"));
    }

    public static void main(String[] args) {
        install();

        Thread secondaryThread = new Thread(() -> {
            throw new IllegalStateException("Failure in the maintenance task");
        }, "maintenance");

        secondaryThread.start();

        try { secondaryThread.join(); } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }

        System.out.println("The main thread is still alive, but the failure WAS LOGGED");
    }
}

Without that handler, the maintenance thread would die printing a stack trace to System.err that in production probably nobody would read, and the application would carry on working without the maintenance task and without anybody knowing.

A detail about precedence: if a specific thread has its own handler (thread.setUncaughtExceptionHandler(...)), that one takes priority over the global one.

  1. What the user sees and what the log sees

A separation that is constantly broken, with real consequences:

User Log
Message in their language and vocabulary Yes Not necessarily
What to do next Yes No
Incident identifier Yes Yes (to correlate)
Exception class No Yes
Stack trace NEVER Yes, complete
File paths, table names, queries NEVER Yes
Internal system data No Yes
Timestamp and thread No Yes

A stack trace in the user's face is a problem for three reasons, in order of seriousness:

  1. It is a security risk. It reveals packages, classes, library versions, file system paths and sometimes fragments of queries. It is free reconnaissance information for anyone wanting to attack the system.
  2. It is of no use to them. Marta Ruiz cannot do anything with NullPointerException at Loan.java:97.
  3. It destroys trust. A wall of red text communicates "this is broken and nobody is in control".

The correct version, applied:

// What the user sees
System.err.println("The loan could not be completed.");
System.err.println("Incident: INC-A3F91C0B");
System.err.println("Please report this code to the support team.");

// What goes to the log
LOG.log(Level.SEVERE, "Failure lending BK-0001 to EMP-001 on day 12. Incident INC-A3F91C0B", e);

The incident identifier is the piece joining both worlds: the user reports it, support looks it up in the log, and the complete stack trace appears with all the context. Without exposing anything.

And an additional, subtler rule: the messages of domain exceptions can be shown to the user, because they are written in their vocabulary —"Material BK-0001 is on loan and its return is expected on day 27". The messages of technical exceptions cannot. It is exactly the distinction enabled by the isUserFixable() method you put in BiblioTechException in 06-04.

  1. Recoverable and unrecoverable errors: graceful degradation

Recoverable Unrecoverable
Definition There is an acceptable alternative There is no sensible way to carry on
Response Degrade the service and carry on Log and terminate in an orderly way
Examples in BiblioTech The catalogue file does not exist → start empty Required configuration is missing → do not start
The notice queue does not respond → note it and retry later OutOfMemoryError
The exporter fails → report it and keep operating Corrupt data in the main index

Graceful degradation consists of carrying on providing whatever service can be provided, reporting what is unavailable:

package com.nexussoftware.bibliotech.service;

import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * BiblioTech start-up with graceful degradation: the application works
 * even if optional components are missing, and it only aborts if something essential is.
 */
public class BiblioTechStartup {

    private static final Logger LOG = Logger.getLogger(BiblioTechStartup.class.getName());

    private boolean noticesAvailable = true;
    private boolean exportAvailable = true;

    public Catalog start(String catalogPath) {
        Catalog catalog = new Catalog();

        // --- OPTIONAL component 1: previous catalogue ---
        try {
            loadCatalog(catalog, catalogPath);
            LOG.info(() -> "Catalogue loaded: " + catalog.size() + " materials");

        } catch (CatalogNotAccessibleException e) {
            // DEGRADATION: work is possible with no previous catalogue; we start empty.
            LOG.log(Level.WARNING,
                    "Could not load the catalogue from '" + e.getPath()
                            + "'. Starting with an empty catalogue.", e);
            System.out.println("Notice: there was no previous catalogue. Starting from scratch.");
        }

        // --- OPTIONAL component 2: notice service ---
        try {
            connectNoticeService();
            LOG.info("Notice service available");
        } catch (RuntimeException e) {
            noticesAvailable = false;
            LOG.log(Level.WARNING, "Notice service unavailable; notices will queue up", e);
            System.out.println("Notice: due-date notices will not be sent in this session.");
        }

        // --- ESSENTIAL component: configuration ---
        // If this fails, there is NO degradation: it aborts. Carrying on with an
        // unknown rate would produce incorrect fines, which is worse than not starting.
        int loanDays = readRequiredConfiguration("loan.days");
        LOG.config(() -> "Configuration: loan.days=" + loanDays);

        return catalog;
    }

    public boolean canSendNotices()  { return noticesAvailable; }
    public boolean canExport()       { return exportAvailable; }

    private void loadCatalog(Catalog catalog, String path) throws CatalogNotAccessibleException {
        throw new CatalogNotAccessibleException(path,
                new java.io.FileNotFoundException(path + " (No such file or directory)"));
    }

    private void connectNoticeService() {
        throw new IllegalStateException("The notice service is not responding on port 8080");
    }

    private int readRequiredConfiguration(String key) {
        return 15;   // in module 7 this will come from a .properties file
    }
}

The criterion for deciding between degrading and aborting:

Degrade when the reduced service is still correct. Abort when carrying on would produce incorrect results.

Starting with no previous catalogue gives a reduced but correct service: the library is empty and that is true. Starting without knowing the daily rate would produce incorrect fines, which is far worse than not starting: a system that gives wrong numbers with complete confidence is more dangerous than one that does not start.

  1. Retries with waits and their limit

You already saw the pattern in 06-02. Here come the decision rules, which is what experience teaches.

What to retry and what not:

Retry Do not retry
Network or connection failure IllegalArgumentException: the data will still be invalid
Temporarily locked file MaterialNotFoundException: it will still not exist
Service returning "unavailable" LoanLimitExceededException: the rule does not change on its own
Database lock Any programming error

The rule: retry what depends on the environment; never what depends on the data or the rules.

The four conditions of a correct retry:

  1. A limit on attempts. Without it, a downed service turns your application into an infinite loop.
  2. An increasing wait (exponential backoff): 200, 400, 800, 1600 ms. Retrying in bursts worsens the remote service's outage.
  3. Definitive failure when they run out, keeping the last error as the cause. Never return null pretending success.
  4. Idempotence of the operation. This is the most forgotten one: if the loan was registered and only the confirmation failed, retrying will create two loans. Only retry what can be repeated with no side effects.

A refinement you will see in real systems: the circuit breaker. If a service fails repeatedly, you stop calling it for a while instead of retrying on every operation. It is an architectural pattern outside this course's scope, but it is worth knowing the name.

  1. Prior validation versus exception

Very often you can choose between asking first or trying and catching. The table of criteria:

Criterion Prior validation (if) Exception
Failure frequency High: it is a normal case Low: it is exceptional
Cost of checking Cheap (isEmpty, containsKey) Expensive or impossible without trying
Race condition Can change between the check and the use Immune: it is atomic
Readability Better with one or two conditions Better with many or scattered ones
Performance ~70 times faster (06-02) Irrelevant if it is rare

Applied examples:

// PRIOR VALIDATION: cheap, the case is frequent and there is no race
if (catalog.exists(reference)) {
    Material m = catalog.getByReference(reference);
    // ...
}

// Better still with the method returning Optional
catalog.findByReference(reference)
       .ifPresent(m -> System.out.println(m.getTitle()));

// EXCEPTION: the conversion cannot be validated without doing it
try {
    int days = Integer.parseInt(input);
} catch (NumberFormatException e) {
    // Writing a correct integer validator (sign, range, overflow)
    // is more error-prone than catching
}

// COMPULSORY EXCEPTION: race condition
// With files or shared resources, "check then use" is not atomic:
// between the exists() and the open() another process may have deleted the file.
try (BufferedReader r = new BufferedReader(new FileReader(path))) {
    // ...
} catch (FileNotFoundException e) {
    // The only reliable way
}

That last case has a name of its own, TOCTOU (time-of-check to time-of-use), and it is a well-known source of failures and vulnerabilities. Whenever the resource is shared —files, database, memory between threads—, the prior check guarantees nothing and the exception has to be handled anyway.

The final practical rule: validate what is cheap and stable to check; catch what cannot be known without trying.

  1. The "result object" pattern and Optional

Not every failure has to be an exception. There are two legitimate alternatives.

Optional, for the absence of a value:

// You have already been using it in BiblioTech since 06-03
public Optional<Material> findByReference(String reference) {
    return Optional.ofNullable(indexByReference.get(reference));
}

It communicates "there may be nothing, and that is normal" in a way the compiler helps you not to ignore. It is developed in 10-04.

The result object, when you need to carry the reason for the failure without throwing. It is especially useful in batch validations, where you want to collect all the errors and not just the first:

package com.nexussoftware.bibliotech.service;

import java.util.ArrayList;
import java.util.List;

/**
 * Result of an operation that can fail, WITHOUT throwing.
 *
 * Useful when you want to accumulate SEVERAL errors instead of aborting on the first:
 * validating a form, importing a file, checking business rules.
 */
public class Result {

    private final boolean success;
    private final List<String> errors;

    private Result(boolean success, List<String> errors) {
        this.success = success;
        this.errors = List.copyOf(errors);
    }

    public static Result ok() {
        return new Result(true, List.of());
    }

    public static Result withErrors(List<String> errors) {
        return new Result(false, errors);
    }

    public boolean isSuccess()       { return success; }
    public List<String> getErrors()  { return errors; }

    public String describe() {
        if (success) { return "OK"; }
        return "Rejected (" + errors.size() + " problems):\n  - "
                + String.join("\n  - ", errors);
    }

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

    /** Example: validating a registration accumulating ALL the problems. */
    public static Result validateRegistration(String reference, String title, int year, String isbn) {
        List<String> errors = new ArrayList<>();

        if (reference == null || !reference.matches("BK-\\d{4}")) {
            errors.add("The reference must have the format BK-NNNN, and it was: " + reference);
        }
        if (title == null || title.isBlank()) {
            errors.add("The title cannot be empty");
        }
        if (year < 1450 || year > 2100) {
            errors.add("The year must be between 1450 and 2100, and it was: " + year);
        }
        if (isbn == null || !isbn.matches("97[89]-\\d{10}")) {
            errors.add("The ISBN must have the format 978-NNNNNNNNNN, and it was: " + isbn);
        }

        return errors.isEmpty() ? ok() : withErrors(errors);
    }

    public static void main(String[] args) {
        System.out.println(validateRegistration("BK-0001", "Effective Java", 2018, "978-0000000001")
                .describe());
        System.out.println();
        System.out.println(validateRegistration("B-1", "", 1200, "12345").describe());
    }
}

Output:

OK

Rejected (4 problems):
  - The reference must have the format BK-NNNN, and it was: B-1
  - The title cannot be empty
  - The year must be between 1450 and 2100, and it was: 1200
  - The ISBN must have the format 978-NNNNNNNNNN, and it was: 12345

With exceptions, the user would have corrected the reference, resubmitted, discovered the title problem, corrected, resubmitted... four rounds. With the result object, they see all four problems at once.

When to use each mechanism:

Mechanism When
Exception The failure is exceptional and the caller normally cannot carry on
Optional The absence of a value is a normal result
Result object There are several errors to report at once, or the failure is so frequent that it is part of the flow
boolean Only when "no" is a legitimate and single answer (Set.add)

  1. Idempotence and consistent state

Two properties that make a system survive failures.

Idempotent means that repeating the operation produces the same result as doing it once.

// NOT idempotent: two calls, two loans
manager.lend("BK-0001", "EMP-001", 12);

// Idempotent: the second call does nothing
session.close();
reservationQueue.cancel("BK-0001", "EMP-001");   // if there was none, nothing happens

Why it matters here: an idempotent operation can be retried without fear. If it is not, a retry after a network failure can duplicate the effect. It is the fourth condition of section 7.

The usual technique for making idempotent something that is not naturally so: an operation identifier allowing the duplicate to be detected.

public Loan lend(String operationId, String reference, String employeeId, int day) {
    // If this operation was already processed, the previous result is returned
    Loan alreadyDone = processedOperations.get(operationId);
    if (alreadyDone != null) {
        LOG.info(() -> "Operation " + operationId + " already processed; returning the previous result");
        return alreadyDone;
    }
    // ... normal processing ...
}

Consistent state is what you solved in 06-05 with the flags and compensation-in-finally pattern. The rule that sums it up:

An operation must leave the system in a valid state, whether it finishes or fails. Never halfway.

And the order that makes it easier, applying what you saw in 06-03:

  1. Validate everything (modifies nothing).
  2. Prepare the new objects (not yet visible to anybody).
  3. Apply the changes, as late and as close together as possible.
  4. Compensate in finally if the end was not reached.

  1. Why System.out.println is no good in production

The second half begins. All of BiblioTech's diagnostics so far have been System.out.println, and they have to be replaced. These are the seven reasons:

Problem Real consequence
It cannot be filtered Either you see everything or you see nothing. With a thousand lines a minute, finding the relevant one is impossible
It cannot be disabled Debugging diagnostics keep printing in production, with their cost
It has no timestamp "When did this happen? Before or after that?" No answer
It has no level A routine warning and a serious failure look exactly the same
It does not say where it comes from No class, no method, no thread. In a multithreaded program, it is unreadable
It is not archived or rotated It is lost when the terminal closes. And if you redirect to a file, it grows without limit until the disk fills
It is synchronous and blocking Writing to the console blocks the thread. In a hot loop, it destroys performance

Compare the same information:

// System.out.println
System.out.println("WARNING: duplicate reference");
WARNING: duplicate reference
// Logger
LOG.warning("Registration rejected: reference BK-0001 duplicate, already used by 'Effective Java'");
2026-08-05 11:42:07.312 [main] WARNING com.nexussoftware.bibliotech.service.Catalog register
  Registration rejected: reference BK-0001 duplicate, already used by 'Effective Java'

The second can be filtered by level, by class or by thread; it can be archived and rotated; it can be disabled without touching the code; and it says when, where and what.

And a rule that confuses a lot of people:

System.out.println is still correct for the program's OUTPUT, that is, for what the user has asked to see: the menu, the catalogue listing, the receipt. What has to be replaced is the diagnostics: the warnings, the traces, the errors.

The test to tell them apart: did the user ask for this? If yes, it is output. If not, it is a log.

  1. The log levels and what to log at each one

java.util.logging defines seven levels. Here they are, with their SLF4J/Logback equivalent, which is what you will see in the real ecosystem:

java.util.logging SLF4J/Logback When to use it Example in BiblioTech
SEVERE ERROR The system cannot do its job. Requires intervention Corrupt catalogue index; unhandled error
WARNING WARN Something is wrong but it was possible to carry on There was no previous catalogue; the notice service does not respond
INFO INFO Milestones of normal operation Start-up, shutdown, loan completed, import finished
CONFIG DEBUG Configuration parameters at start-up loan.days=15, daily.rate=0.25
FINE DEBUG Detail useful for debugging Entry and exit of the service methods
FINER TRACE Even more detail Every iteration of an import loop
FINEST TRACE Maximum detail Dumps of internal structures

The practical guide, which is what really has to sink in:

SEVERE/ERROR — Somebody has to look at this. If you log something at this level that does not require intervention, you are training the team to ignore errors. It is the commonest failing of badly configured logging systems: when everything is an error, nothing is.

WARNING/WARN — It went wrong but we carry on. Degradations, retries, suspicious configurations, use of default values because a parameter was missing.

INFO — The business milestones. It must be readable as the story of what the application has done. If it has so much noise that it cannot be read, there are things in there that should be FINE.

CONFIG/FINE/FINER/FINEST — Disabled in production by default. They are switched on when something specific has to be investigated. That is why they must be cheap when disabled, which is what the next section is about.

And a very frequent error of judgement: an exception caught and handled correctly is not a SEVERE. If the catalogue did not exist and you started empty, that is a WARNING: the system did what was planned. SEVERE is for what nobody planned.

  1. java.util.logging in practice

java.util.logging (JUL) comes in the JDK, with no dependencies. It is the choice for this course precisely for that reason: you can run everything without downloading anything. In 11-07 you will see the real standard of the ecosystem.

Getting a Logger:

package com.nexussoftware.bibliotech.service;

import java.util.logging.Logger;

public class Catalog {

    /**
     * One Logger per class, static and final, named with the class's full
     * name. It is the universal convention, and it allows filtering by package:
     * switching on level FINE for com.nexussoftware.bibliotech.service and leaving
     * the rest at INFO.
     */
    private static final Logger LOG = Logger.getLogger(Catalog.class.getName());

    // ...
}

Why static final: because the Logger does not depend on the instance, obtaining it is relatively expensive and this way it is done once per class.

Logging messages:

LOG.severe("Serious message");
LOG.warning("Warning");
LOG.info("Information");
LOG.config("Configuration");
LOG.fine("Debugging detail");

// General form, with the explicit level
LOG.log(Level.INFO, "Message");

// WITH AN EXCEPTION: the correct way to log a failure
LOG.log(Level.SEVERE, "Descriptive message", exception);

The lazy form with Supplier (Java 8+), which is important:

// BAD: the concatenation is done ALWAYS, even if FINE is disabled
LOG.fine("Searching for " + reference + " in a catalogue of " + materials.size()
        + " materials, index with " + index.size() + " entries");

// GOOD: the lambda is only evaluated if level FINE is active
LOG.fine(() -> "Searching for " + reference + " in a catalogue of " + materials.size()
        + " materials, index with " + index.size() + " entries");

It is the same principle of lazy evaluation as the lambdas of 04-05 and the Objects.requireNonNull with Supplier of 06-03. In a heavily called method, the difference between building that string a million times and not building it at all is perfectly measurable.

The classic alternative, before lambdas, was to check first:

if (LOG.isLoggable(Level.FINE)) {
    LOG.fine("Expensive message: " + computeDiagnostics());
}

It is still valid and sometimes necessary —when computing the message is genuinely expensive—, but the Supplier form is cleaner.

The logger hierarchy. Loggers are organised by their name, separated by dots, forming a tree:

flowchart TB
    R["'' (root)<br/>level INFO, ConsoleHandler"]
    R --> C["com"]
    C --> N["com.nexussoftware"]
    N --> B["com.nexussoftware.bibliotech"]
    B --> D["...bibliotech.domain"]
    B --> S["...bibliotech.service<br/>level FINE"]
    B --> P["...bibliotech.presentation"]
    S --> S1["...service.Catalog"]
    S --> S2["...service.LoanManager"]

A Logger with no level of its own inherits its parent's, and its messages propagate to the Handlers of all its ancestors. This is what allows precise configuration:

// Switch on the detail ONLY in the service layer
Logger.getLogger("com.nexussoftware.bibliotech.service").setLevel(Level.FINE);
// The rest stays at INFO

  1. Handler, Formatter and logging.properties

Three pieces that must be distinguished:

Piece What it does
Logger Receives the messages and decides whether they pass the level filter
Handler Sends them to a destination: console, file, socket, memory
Formatter Turns the record into text
Filter An additional criterion finer than the level

The Handlers included in the JDK:

Handler Destination
ConsoleHandler System.err
FileHandler A file, with rotation by size and count
StreamHandler Any OutputStream
SocketHandler A remote host and port
MemoryHandler A circular buffer in memory

Complete programmatic configuration for BiblioTech:

package com.nexussoftware.bibliotech.presentation;

import java.io.IOException;
import java.util.logging.ConsoleHandler;
import java.util.logging.FileHandler;
import java.util.logging.Formatter;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.util.logging.Logger;

/**
 * Configuration of BiblioTech's logging system.
 *
 * Two destinations:
 *   - CONSOLE: only INFO and above, in a compact one-line format.
 *   - FILE: everything from FINE, with rotation, for later diagnosis.
 */
public final class LogConfiguration {

    private static final String LOG_PATH = "bibliotech-%g.log";   // %g = rotation number
    private static final int MAX_SIZE = 1_000_000;                // 1 MB per file
    private static final int ROTATION_FILES = 5;                  // 5 files at most

    private static boolean initialised = false;

    private LogConfiguration() { }

    public static synchronized void initialise() {
        if (initialised) {
            return;                          // idempotent (06-06)
        }
        initialised = true;

        Logger root = Logger.getLogger("");

        // 1. Remove the default handlers: we replace them with our own
        for (Handler h : root.getHandlers()) {
            root.removeHandler(h);
        }

        // 2. The root logger accepts everything; each handler filters on its own
        root.setLevel(Level.ALL);

        // 3. CONSOLE: only the important things, compact format
        ConsoleHandler console = new ConsoleHandler();
        console.setLevel(Level.INFO);
        console.setFormatter(new CompactFormat());
        root.addHandler(console);

        // 4. FILE: all the detail, with rotation
        try {
            FileHandler file = new FileHandler(LOG_PATH, MAX_SIZE, ROTATION_FILES, true);
            file.setLevel(Level.FINE);
            file.setFormatter(new DetailedFormat());
            root.addHandler(file);
        } catch (IOException e) {
            // If the file cannot be created, the application must NOT fall over:
            // it degrades to console only and warns. Graceful degradation.
            root.log(Level.WARNING,
                    "Could not create the log file; there will only be console logging", e);
        }

        // 5. Specific level per package
        Logger.getLogger("com.nexussoftware.bibliotech.service").setLevel(Level.FINE);
        Logger.getLogger("com.nexussoftware.bibliotech.domain").setLevel(Level.INFO);

        Logger.getLogger(LogConfiguration.class.getName()).config("Logging system initialised");
    }

    /** One line per event: level, short class name and message. */
    private static class CompactFormat extends Formatter {
        @Override
        public String format(LogRecord logRecord) {
            String className = logRecord.getSourceClassName();
            String shortName = (className == null) ? "?" : className.substring(className.lastIndexOf('.') + 1);

            StringBuilder sb = new StringBuilder();
            sb.append(String.format("[%-7s] %-18s %s%n",
                    logRecord.getLevel().getName(), shortName, formatMessage(logRecord)));

            if (logRecord.getThrown() != null) {
                sb.append("          cause: ").append(logRecord.getThrown()).append('\n');
            }
            return sb.toString();
        }
    }

    /** Full format for the file, with timestamp, thread and stack trace. */
    private static class DetailedFormat extends Formatter {
        @Override
        public String format(LogRecord logRecord) {
            StringBuilder sb = new StringBuilder();

            // Timestamp. The instant in milliseconds is used and formatted
            // without java.time, which is the subject of 10-05.
            sb.append(String.format("%tF %<tT.%<tL", logRecord.getMillis()));
            sb.append(String.format(" [%-6d] %-7s ",
                    logRecord.getLongThreadID(), logRecord.getLevel().getName()));
            sb.append(logRecord.getSourceClassName()).append('.')
              .append(logRecord.getSourceMethodName()).append(" - ");
            sb.append(formatMessage(logRecord)).append('\n');

            // The complete stack trace, indented
            Throwable t = logRecord.getThrown();
            if (t != null) {
                sb.append("    ").append(t).append('\n');
                for (StackTraceElement frame : t.getStackTrace()) {
                    sb.append("        at ").append(frame).append('\n');
                }
                // Causes and suppressed ones (06-01, 06-06)
                Throwable cause = t.getCause();
                int level = 1;
                while (cause != null && level <= 5) {
                    sb.append("    Caused by: ").append(cause).append('\n');
                    cause = cause.getCause();
                    level++;
                }
                for (Throwable suppressed : t.getSuppressed()) {
                    sb.append("    Suppressed: ").append(suppressed).append('\n');
                }
            }
            return sb.toString();
        }
    }
}

Configuration by file. The alternative to configuring in code is a logging.properties file, activated when the JVM starts:

# BiblioTech's logging.properties

# Global handlers
handlers = java.util.logging.ConsoleHandler, java.util.logging.FileHandler

# Root logger level: let everything through, the handlers filter
.level = ALL

# --- Console: only INFO and above ---
java.util.logging.ConsoleHandler.level = INFO
java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter

# --- File: everything from FINE, with rotation ---
java.util.logging.FileHandler.pattern = bibliotech-%g.log
java.util.logging.FileHandler.limit = 1000000
java.util.logging.FileHandler.count = 5
java.util.logging.FileHandler.append = true
java.util.logging.FileHandler.level = FINE
java.util.logging.FileHandler.formatter = java.util.logging.SimpleFormatter

# SimpleFormatter format: date, level, class.method, message, exception
java.util.logging.SimpleFormatter.format = %1$tF %1$tT.%1$tL %4$-7s [%2$s] %5$s%6$s%n

# --- Levels per package ---
com.nexussoftware.bibliotech.level = INFO
com.nexussoftware.bibliotech.service.level = FINE
com.nexussoftware.bibliotech.domain.level = INFO

And it is started like this:

java -Djava.util.logging.config.file=logging.properties \
     -cp . com.nexussoftware.bibliotech.presentation.BiblioTechApp

The decisive advantage of file configuration: the log level can be changed without recompiling or touching the code. In production, when a problem has to be investigated, the level is raised to FINE in the affected package, the problem is reproduced, and it is lowered again.

  1. Logging an exception correctly

The correct way and the incorrect ones, side by side:

try {
    manager.lend(reference, employeeId, day);

} catch (BiblioTechException e) {

    // ==================== CORRECT ====================
    // The Throwable goes as the THIRD argument: the Formatter prints the complete
    // stack trace, with its causes and suppressed ones, in the same log event.
    LOG.log(Level.WARNING, "Could not lend " + reference + " to " + employeeId, e);

    // ==================== INCORRECT ====================

    // 1. printStackTrace: goes to System.err, no level, no date, no filter
    e.printStackTrace();

    // 2. Only the message: loses the stack trace and all the causes
    LOG.warning("Error: " + e.getMessage());

    // 3. Concatenating the exception: gives only toString(), no stack
    LOG.warning("Error: " + e);

    // 4. Logging AND rethrowing: the same failure will appear twice
    LOG.log(Level.SEVERE, "Error", e);
    throw e;
}

About the fourth case, which deserves its own rule:

Log where you handle. If you are going to rethrow, do not log the complete stack trace: the layer that finally handles the error will do it, and with more context. At most, add a line of context at level FINE.

If every layer logs and rethrows, one failure produces five SEVERE events with five nearly identical stack traces, and finding the first becomes archaeology.

And a warning about the level: an exception caught and handled correctly is rarely SEVERE. The MaterialNotFoundException that happens because the user mistyped a reference is INFO or FINE, not a system error. Reserve SEVERE for what requires somebody to intervene.

  1. What must NEVER be logged

A log is a file that gets copied, emailed, aggregated into a centralised system and kept for months. Everything you write there leaves your application's control.

Never log:

Category Examples
Credentials Passwords, even encrypted or "only the first few characters"
Tokens and keys Session tokens, API keys, authentication cookies
Financial data Card numbers, CVV, bank accounts
Sensitive personal data ID numbers, address, health, biometric data
Complete request contents They can carry any of the above inside
Dumps of whole objects LOG.fine("User: " + user) will print whatever is in its toString(), today and in two years' time

That last point is the most treacherous, because the failure appears later:

// Today: Employee.toString() returns "EMP-001 (Marta Ruiz)". It looks harmless.
LOG.fine(() -> "Processing " + employee);

// In a year's time somebody adds the ID number and the phone to toString()...
// and suddenly you are logging personal data into every log file,
// without anybody having touched this line.

The defence: log specific fields, not objects.

LOG.fine(() -> "Processing employee " + employee.getIdentifier());

When you need to log something partially identifiable, mask it:

/** Leaves the last 4 characters visible: 978-0000000001 -> ***0001 */
private static String mask(String value) {
    if (value == null || value.length() <= 4) {
        return "****";
    }
    return "***" + value.substring(value.length() - 4);
}

Formal warning: in a real system, which personal data may be logged, for how long and with what protection measures is not a technical decision. It is regulated —in the European Union, by the GDPR— and it must be reviewed by your organisation's data protection officer or compliance department. When in doubt, do not log it and ask. A log with badly managed personal data is a security incident and can lead to penalties.

In BiblioTech, EMP-001 is an internal identifier that is perfectly loggable; a person's full name is already personal data, and in a real system it would have to be checked before putting it in the log.

  1. Useful log messages and correlation

A log message answers five questions: what happened, with what data, in which operation, with what result and —if it failed— why.

Message Assessment
"Error" Useless
"Error while lending" All the context is missing
"Error while lending BK-0001" Who and when are missing
"Loan rejected: BK-0001 to EMP-001 on day 12. Reason: material unavailable until day 27" Complete

The identifiers that must always appear in BiblioTech:

  • Material reference (BK-0001) and loan reference (LN-0007).
  • Employee identifier (EMP-001), not their name.
  • Day of the operation.
  • Operation identifier, to correlate several lines of the same flow.

About that last point: in a system with many concurrent operations, the log lines of different flows interleave and there is no way of knowing which belong to the same one. The solution is a correlation identifier propagated through every layer and appearing in every message:

LOG.info(() -> "[" + operationId + "] Loan " + loanReference + " completed");

With it, filtering by INC-A3F91C0B in the file returns the complete story of that operation, from start to finish, even if there were a hundred more in parallel. Modern systems automate it with SLF4J's MDC or with distributed tracing (OpenTelemetry), and structured logging —events in JSON with named fields instead of free text— also allows them to be queried as if they were a database. Both topics are picked up again in 11-07 and in 12-07.

  1. The real ecosystem: SLF4J, Logback and Log4j2

java.util.logging is what you have used here because it comes in the JDK and requires no installation. But in a professional project you will find something else.

Component What it is
SLF4J A facade: a logging API with no implementation. Your code depends only on it
Logback The most used implementation, by the same author as SLF4J. The one Spring Boot brings by default
Log4j2 The other big implementation, with very good performance thanks to its asynchronous logging
java.util.logging The JDK's own. Less flexible, but with no dependencies

Why the facade exists: your code writes against SLF4J, and the implementation is chosen at deployment time. If tomorrow you switch from Logback to Log4j2, you do not touch a single line of your code: you change a dependency. It is exactly the principle of programming against interfaces from 04-01, applied to libraries.

This is how the same code looks with SLF4J:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class Catalog {

    private static final Logger LOG = LoggerFactory.getLogger(Catalog.class);

    public void register(Material material) {
        // Placeholder syntax {}: no concatenation and no lambda.
        // The message is only composed if the level is active.
        LOG.debug("Registering {} in a catalogue of {} materials",
                  material.getReference(), materials.size());
        // ...
    }
}

The advantages over JUL: the {} placeholder syntax solves the lazy evaluation problem without lambdas, configuration in XML or YAML is far more expressive, there are appenders for every imaginable destination, and support for structured JSON logging comes as standard.

Everything you have learned in this lesson carries over unchanged: the levels, one logger per class, logging the exception as an argument and not concatenating it, not logging sensitive data, logging where you handle. Only the API changes. The ecosystem's essential libraries, this one included, are covered in 11-07.

  1. BiblioTech: the final refactoring

Now everything together. First, Catalog with logging instead of println:

package com.nexussoftware.bibliotech.service;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.logging.Level;
import java.util.logging.Logger;

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, final version of module 6.
 *
 * Changes with respect to 06-04:
 *   - Zero System.out.println: all the diagnostics go to the Logger.
 *   - The log messages carry identifiers, not people's names.
 *   - Expensive messages use Supplier so they are not built if the level is disabled.
 */
public class Catalog {

    private static final Logger LOG = Logger.getLogger(Catalog.class.getName());

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

    private String lockOwner = null;

    public void register(Material material) {
        Objects.requireNonNull(material, "The material to register cannot be null");
        String reference = material.getReference();

        // FINE: debugging detail, disabled in production.
        // With a Supplier, the concatenation does not run if FINE is off.
        LOG.fine(() -> "Registering " + reference + " (current catalogue: "
                + materials.size() + " materials)");

        Material existing = indexByReference.get(reference);
        if (existing != null) {
            // It is logged at FINE and THROWN. It is not logged at a higher level
            // because whoever catches it will decide its severity: in a bulk import
            // a duplicate is routine, in a manual registration it is a warning.
            LOG.fine(() -> "Registration rejected: reference " + reference + " duplicate");
            throw new DuplicateReferenceException(
                    DuplicateReferenceException.Kind.REFERENCE, reference,
                    existing.getTitle());
        }

        if (material instanceof Book book) {
            String existingTitle = titlesByIsbn.get(book.getIsbn());
            if (existingTitle != null) {
                LOG.fine(() -> "Registration rejected: duplicate ISBN in " + reference);
                throw new DuplicateReferenceException(
                        DuplicateReferenceException.Kind.ISBN, book.getIsbn(), existingTitle);
            }
            titlesByIsbn.put(book.getIsbn(), book.getTitle());
        }

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

        // INFO: a business milestone. It reads as the story of what the application does.
        LOG.info(() -> "Material registered: " + reference
                + " (total " + materials.size() + ")");
    }

    public Material getByReference(String reference) {
        Objects.requireNonNull(reference, "The reference cannot be null");

        Material found = indexByReference.get(reference);
        if (found == null) {
            LOG.fine(() -> "Reference not found: " + reference);
            throw new MaterialNotFoundException(reference, materials.size());
        }
        return found;
    }

    public Optional<Material> findByReference(String reference) {
        if (reference == null) { return Optional.empty(); }
        return Optional.ofNullable(indexByReference.get(reference));
    }

    public void lock(String ownerId) {
        if (lockOwner != null && !lockOwner.equals(ownerId)) {
            LOG.warning(() -> "Lock denied to " + ownerId
                    + ": it is held by " + lockOwner);
            throw new IllegalStateException(
                    "The catalogue is locked by " + lockOwner);
        }
        lockOwner = ownerId;
        LOG.fine(() -> "Catalogue lock acquired by " + ownerId);
    }

    public void unlock(String ownerId) {
        if (!Objects.equals(lockOwner, ownerId)) {
            // WARNING: something odd is going on, but it does not stop us carrying on
            LOG.warning(() -> "Unlock attempt by " + ownerId
                    + " who is not the owner (" + lockOwner + ")");
            return;
        }
        lockOwner = null;
        LOG.fine(() -> "Catalogue lock released by " + ownerId);
    }

    public boolean isLocked()       { return lockOwner != null; }
    public boolean exists(String r) { return r != null && indexByReference.containsKey(r); }
    public int size()               { return materials.size(); }
    public List<Material> list()    { return List.copyOf(materials); }
}

Now LoanManager, with logging, an operation identifier and compensation:

package com.nexussoftware.bibliotech.service;

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;

import com.nexussoftware.bibliotech.domain.*;

/** Orchestrates loans with complete logging and consistent state. */
public class LoanManager {

    private static final Logger LOG = Logger.getLogger(LoanManager.class.getName());

    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);
        // The IDENTIFIER is logged, not the name: the name is personal data
        LOG.info(() -> "Employee enrolled: " + employee.getIdentifier());
    }

    public Loan lend(String operationId, String reference, String employeeId, int day) {
        Objects.requireNonNull(operationId, "The operation identifier cannot be null");
        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);
        }

        LOG.fine(() -> "[" + operationId + "] Starting loan of " + reference
                + " to " + employeeId + " on day " + day);

        // --- Phase 1: reading and validation (modifies nothing) ---
        Material material = catalog.getByReference(reference);
        Employee employee = employees.get(employeeId);
        if (employee == null) {
            throw new MaterialNotFoundException(employeeId, employees.size());
        }
        if (!material.isAvailable()) {
            throw new MaterialNotAvailableException(reference, material.getTitle(),
                    "another employee", day + Loan.LOAN_DAYS);
        }
        if (!employee.canBorrow()) {
            throw new LoanLimitExceededException(employeeId, employee.getName(),
                    employee.getTotalLoans(), Employee.MAX_CONCURRENT_LOANS);
        }

        // --- Phase 2: modification with compensation (06-05) ---
        boolean materialMarked = false;
        boolean allowanceUsed = false;
        boolean completed = false;
        String loanReference = nextReference();

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

            employee.registerLoan();
            allowanceUsed = true;

            Loan loan = new Loan(loanReference, material, employee, day);
            loans.put(loanReference, loan);
            completed = true;

            LOG.info(() -> "[" + operationId + "] Loan " + loanReference
                    + " registered: " + reference + " -> " + employeeId + " (day " + day + ")");
            return loan;

        } finally {
            if (!completed) {
                // WARNING, not SEVERE: compensation is the planned behaviour
                LOG.warning(() -> "[" + operationId + "] Incomplete loan of " + reference
                        + "; compensating");
                try {
                    if (allowanceUsed)   { employee.registerReturn(); }
                    if (materialMarked)  { material.returnItem(); }
                    LOG.fine(() -> "[" + operationId + "] State restored");
                } catch (RuntimeException failure) {
                    // A real SEVERE: the compensation failed and the state may
                    // have been left inconsistent. This DOES require intervention.
                    LOG.log(Level.SEVERE, "[" + operationId
                            + "] THE COMPENSATION FAILED. State possibly inconsistent in "
                            + reference, failure);
                }
            }
        }
    }

    public void returnItem(String operationId, String loanReference, int day) {
        Loan loan = loans.get(loanReference);
        if (loan == null) {
            throw new MaterialNotFoundException(loanReference, loans.size());
        }
        if (loan.isReturned()) {
            throw new LoanAlreadyReturnedException(loanReference, loan.getStartDay());
        }

        loan.registerReturn(day);
        loan.getBorrower().registerReturn();

        int daysLate = loan.calculateDaysLate(day);
        if (daysLate > 0) {
            LOG.info(() -> "[" + operationId + "] Return of " + loanReference
                    + " with " + daysLate + " days late");
        } else {
            LOG.info(() -> "[" + operationId + "] Return of " + loanReference + " on time");
        }
    }

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

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

And the presentation layer, which is where things are caught and translated:

package com.nexussoftware.bibliotech.presentation;

import java.util.logging.Level;
import java.util.logging.Logger;

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

/**
 * Presentation layer: the ONLY one that catches for the user, the only one that
 * prints to the console what the user asked to see, and the only one that decides
 * which message to show in each case.
 */
public class ConsoleOperations {

    private static final Logger LOG = Logger.getLogger(ConsoleOperations.class.getName());

    private final LoanManager manager;
    private int operationCounter = 0;

    public ConsoleOperations(LoanManager manager) {
        this.manager = manager;
    }

    /**
     * Runs a loan translating every failure into an understandable message.
     *
     * HERE things are caught: it is the boundary with the user and there are decisions to take.
     */
    public void lend(String reference, String employeeId, int day) {
        String operationId = nextOperationId();

        try {
            manager.lend(operationId, reference, employeeId, day);
            // Program OUTPUT, not a log: it is what the user asked to see
            System.out.println("Lent " + reference + " to " + employeeId
                    + ". Return expected: day " + (day + Loan.LOAN_DAYS));

        } catch (MaterialNotFoundException e) {
            // Expected and fixable failure: friendly message, low-level log
            System.out.println("Material '" + e.getSearchedReference() + "' does not exist.");
            if (e.isCatalogEmpty()) {
                System.out.println("The catalogue is empty: load the materials first.");
            }
            LOG.fine(() -> "[" + operationId + "] Non-existent reference: "
                    + e.getSearchedReference());

        } catch (MaterialNotAvailableException e) {
            System.out.println("'" + e.getTitle() + "' is on loan.");
            System.out.println("Expected to be available in " + e.waitDays(day) + " days.");
            System.out.println("You can reserve it with option 5.");
            LOG.fine(() -> "[" + operationId + "] Material unavailable: " + e.getReference());

        } catch (LoanLimitExceededException e) {
            System.out.printf("You have reached the limit of %d concurrent loans.%n",
                    e.getLimit());
            System.out.printf("You must return %d material(s) before borrowing another.%n",
                    e.returnsNeeded());
            LOG.fine(() -> "[" + operationId + "] Limit exceeded by " + e.getEmployeeId());

        } catch (BiblioTechException e) {
            // Domain safety net: catchable thanks to the common root (06-04)
            System.out.println("The operation could not be completed: " + e.getMessage());
            LOG.log(Level.WARNING, "[" + operationId + "] Unforeseen domain failure ["
                    + e.getCode() + "]", e);

        } catch (RuntimeException e) {
            // BUG: the user does not see the details, the log sees them all
            System.out.println("An internal error has occurred.");
            System.out.println("Incident: " + operationId);
            LOG.log(Level.SEVERE, "[" + operationId + "] Unhandled error lending "
                    + reference + " to " + employeeId, e);
        }
    }

    private String nextOperationId() {
        operationCounter++;
        return String.format("OP-%05d", operationCounter);
    }

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

    public static void main(String[] args) {
        LogConfiguration.initialise();

        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"));

        ConsoleOperations console = new ConsoleOperations(manager);

        System.out.println("\n----- Operations -----");
        console.lend("BK-0001", "EMP-001", 10);
        console.lend("BK-0001", "EMP-002", 11);        // not available
        console.lend("BK-9999", "EMP-001", 11);        // not found
        console.lend("BK-0002", "EMP-001", 11);
        console.lend("BK-0003", "EMP-001", 11);
        console.lend("BK-0002", "EMP-001", 12);        // not available (already has it)
    }
}

Console output (log level INFO and above, plus the program's output):

[INFO   ] Catalog            Material registered: BK-0001 (total 1)
[INFO   ] Catalog            Material registered: BK-0002 (total 2)
[INFO   ] Catalog            Material registered: BK-0003 (total 3)
[INFO   ] LoanManager        Employee enrolled: EMP-001
[INFO   ] LoanManager        Employee enrolled: EMP-002

----- Operations -----
[INFO   ] LoanManager        [OP-00001] Loan LN-0001 registered: BK-0001 -> EMP-001 (day 10)
Lent BK-0001 to EMP-001. Return expected: day 25
'Effective Java' is on loan.
Expected to be available in 14 days.
You can reserve it with option 5.
Material 'BK-9999' does not exist.
[INFO   ] LoanManager        [OP-00004] Loan LN-0002 registered: BK-0002 -> EMP-001 (day 11)
Lent BK-0002 to EMP-001. Return expected: day 26
[INFO   ] LoanManager        [OP-00005] Loan LN-0003 registered: BK-0003 -> EMP-001 (day 11)
Lent BK-0003 to EMP-001. Return expected: day 26
'Design Patterns' is on loan.
Expected to be available in 14 days.
You can reserve it with option 5.

And in bibliotech-0.log, with all the detail:

2026-08-05 11:42:07.104 [1     ] CONFIG  ...LogConfiguration.initialise - Logging system initialised
2026-08-05 11:42:07.118 [1     ] FINE    ...Catalog.register - Registering BK-0001 (current catalogue: 0 materials)
2026-08-05 11:42:07.119 [1     ] INFO    ...Catalog.register - Material registered: BK-0001 (total 1)
...
2026-08-05 11:42:07.201 [1     ] FINE    ...LoanManager.lend - [OP-00002] Starting loan of BK-0001 to EMP-002 on day 11
2026-08-05 11:42:07.202 [1     ] FINE    ...ConsoleOperations.lend - [OP-00002] Material unavailable: BK-0001
2026-08-05 11:42:07.205 [1     ] FINE    ...Catalog.getByReference - Reference not found: BK-9999
2026-08-05 11:42:07.206 [1     ] FINE    ...ConsoleOperations.lend - [OP-00003] Non-existent reference: BK-9999

Compare the console and the file. The console shows what the user needs: clear messages and the important milestones. The file contains the complete story, with timestamp, thread, class, method and operation identifier, filterable by any of those fields. And there is not a single diagnostic System.out.println in the whole project.

Common Mistakes and Tips

Catching where it happens instead of where it is decided. A catch that only prints and returns null downgrades an exception with a name and data to the mute null that took two lessons to remove. If you cannot decide, do not catch.

Showing a stack trace to the user. It is a security risk —it reveals packages, versions and paths—, it is of no use to them and it destroys trust. A clear message plus an incident identifier; the trace goes to the log.

Logging and rethrowing in every layer. One failure appears five times with five nearly identical traces. Log where you handle.

Logging everything as SEVERE. When everything is an error, nobody looks at the errors. SEVERE is only for what requires somebody to intervene.

Concatenating in low-level log calls. LOG.fine("... " + x + " ...") builds the string even if FINE is disabled. Use the Supplier form: LOG.fine(() -> "...").

Logging complete objects. LOG.fine("Employee: " + employee) will print whatever toString() returns today and in two years' time, when somebody adds an ID number to it. Log specific fields.

Logging credentials, tokens or personal data. Logs are copied, sent and archived for months. It is a security incident, and in a real system the policy is set by the data protection officer, not by you.

printStackTrace() in production. It goes to System.err with no level, no date, no filter and no context. Use LOG.log(Level.X, "message", e).

Retrying non-idempotent operations. If the loan was registered and only the confirmation failed, the retry creates a second loan. Only retry what can be repeated with no side effects.

Trusting prior validation with shared resources. Between the exists() and the open() the file can vanish. That is TOCTOU, and the exception has to be handled anyway.

A non-static Logger or one created on every call. Obtaining it has a cost. private static final Logger LOG = Logger.getLogger(MyClass.class.getName());, once per class.

Tip: use an operation identifier in every message of the same flow. It turns an unreadable log into a story you can follow with a single filter.

Tip: read your own log as if you were the one on call at three in the morning. If you cannot reconstruct what happened, data is missing. If there is so much noise that you cannot find anything, there are too many lines at INFO.

Tip: configure by file, not in code. Being able to raise the level to FINE in a specific package without recompiling is what saves an investigation in production.

Tip: when you move to SLF4J in 11-07, all of this carries over unchanged. Only the API changes; the criteria are the same.

Exercises

Exercise 1: each layer's error policy

For each of the eight scenarios, answer: (a) in which layer it is detected, (b) in which layer it must be caught, (c) what log level corresponds, (d) what exact message the user sees and (e) what exact message goes to the log. Justify each decision.

  1. Marta types "twelve" where the number of days was asked for.
  2. Book receives publicationYear = 1200 in its constructor.
  3. The file catalog.txt does not exist at start-up.
  4. A NullPointerException in Loan.borrowerDescription because the borrower is null.
  5. Diego attempts his fourth concurrent loan.
  6. The notice service does not respond when notifying a due date.
  7. The disk fills up while the catalogue is being exported.
  8. OutOfMemoryError during a bulk import.

Then write the class ErrorPolicy with a method String decide(Throwable t) that, given a Throwable, returns a recommendation with the log level, whether the message is fit for the user and the suggested action. Lean on BiblioTechException.isUserFixable().

Exercise 2: OperationLog with java.util.logging

Write OperationLog in com.nexussoftware.bibliotech.service, wrapping any BiblioTech operation and adding complete logging and measurement.

Requirements:

  • <T> T execute(String operationName, String employeeId, Supplier<T> operation):
    • Generates an operation identifier OP-NNNNN.
    • Logs the start at FINE with the identifier, the name and the employee.
    • Measures the duration with System.nanoTime().
    • On success: logs the end at INFO with the duration in milliseconds.
    • If it throws a BiblioTechException: logs it at WARNING with the exception as the third argument and rethrows it.
    • If it throws any other RuntimeException: logs it at SEVERE with the identifier as the incident and wraps it in an IllegalStateException whose message includes the incident, preserving the cause.
    • Uses try-finally so that the duration is logged also when it fails.
  • A method void dumpStats() showing, per operation name: number of runs, successes, failures and average duration.
  • Every message must use the lazy Supplier form.
  • It must not log any employee's name, only their identifier.

In main, configure logging with two handlers (console at INFO, file at FINE), run at least six operations —correct ones, with a domain failure and with a bug— and show the statistics.

Exercise 3: error handling audit

This is the BiblioTechMenu that has survived the whole module without being reviewed. It has ten error handling and logging problems. Find them, explain the damage each one does and rewrite the class applying everything learned in the module.

public class BiblioTechMenu {

    public void start() {
        while (true) {
            try {
                System.out.print("Option: ");
                int option = Integer.parseInt(new Scanner(System.in).nextLine());
                switch (option) {
                    case 1: lend(); break;
                    case 2: returnItem(); break;
                    case 0: System.exit(0);
                }
            } catch (Throwable t) {
                t.printStackTrace();
            }
        }
    }

    private void lend() throws Exception {
        Scanner sc = new Scanner(System.in);
        System.out.print("Reference: ");
        String ref = sc.nextLine();
        System.out.print("Employee: ");
        String emp = sc.nextLine();

        try {
            manager.lend(ref, emp, 1);
        } catch (Exception e) {
            System.out.println("Error: " + e.getMessage());
            System.out.println(e.getStackTrace()[0]);
            throw e;
        } finally {
            sc.close();
        }
    }

    private void returnItem() {
        try {
            manager.returnItem(readReference(), 1);
        } catch (Exception e) {
        }
        System.out.println("Return processed");
    }
}

A hint about two of them that are easy to miss: one has to do with where the Scanner is created and another with what the last method claims to the user.

Solutions

Solution 1

Table of decisions:

# Scenario (a) Detected (b) Caught (c) Level (d) User (e) Log
1 "twelve" as the number of days Presentation (parseInt) Presentation FINE "'twelve' is not a number. Type an integer between 1 and 30." [OP-00012] Non-numeric input for days: 'twelve'
2 publicationYear = 1200 Domain (constructor) Presentation or importer WARNING in bulk, FINE in manual registration "The year must be between 1450 and 2100. You typed 1200." Registration of BK-0007 rejected: year 1200 out of range
3 catalog.txt does not exist Persistence main/start-up WARNING "There was no previous catalogue. Starting from scratch." Could not load the catalogue from 'catalog.txt' + stack trace
4 NullPointerException from a null borrower Domain Boundary SEVERE "Internal error. Incident INC-A3F91C0B." [INC-A3F91C0B] Unhandled error issuing the receipt + full trace
5 Diego's fourth loan Domain (Employee) Presentation FINE "You have reached the limit of 3 loans. Return 1 material." [OP-00018] Limit exceeded by EMP-002 (3/3)
6 Notice service not responding Service Service (degradation) WARNING (nothing, or "notices will be sent later") Notice service unavailable; 3 notices queued + trace
7 Disk full while exporting Persistence (IOException) Presentation SEVERE "The catalogue could not be exported. Check the disk space." I/O failure exporting to 'catalog-export.txt' + trace
8 OutOfMemoryError JVM Only the boundary SEVERE "Unrecoverable error. The application will close." Unrecoverable error + class and trace, with the logging protected

Justifications of the less obvious cases:

Case 2: the level depends on the context, not on the type. In a bulk import, an invalid year in one line out of a thousand is routine and deserves WARNING in the aggregate report; in a manual registration it is a user error and FINE is enough. It is the same exception with two policies, as in exercise 3 of 06-02.

Case 4: a NullPointerException is always a bug, never something the user can correct. Hence SEVERE and the generic message with an incident: the user must not see Loan.java:97 nor know that a Loan class exists.

Case 6: it is caught in service, not in presentation, because that is where the knowledge to degrade lives: queue the notices and carry on. Presentation has no reason to find out that a notice service exists.

Case 7: SEVERE even though the user can act, because a full disk affects the whole system and somebody in operations must find out.

Case 8: the only case where an Error is caught, and only at the boundary, only to leave a record before dying, and with the logging itself protected because writing the log can also fail with no memory.

package com.nexussoftware.bibliotech.service;

import java.io.IOException;
import java.util.NoSuchElementException;
import java.util.logging.Level;

import com.nexussoftware.bibliotech.domain.BiblioTechException;

/**
 * Classifies a Throwable and recommends how to treat it.
 * It materialises this lesson's table of decisions.
 */
public final class ErrorPolicy {

    /** Complete recommendation for a specific failure (record, 04-07). */
    public record Recommendation(Level level, boolean userSafeMessage,
                                 boolean requiresIncident, String action) {

        @Override
        public String toString() {
            return String.format("level=%-7s | user=%-5s | incident=%-5s | %s",
                    level.getName(), userSafeMessage, requiresIncident, action);
        }
    }

    private ErrorPolicy() { }

    public static Recommendation decide(Throwable t) {
        if (t == null) {
            return new Recommendation(Level.INFO, false, false, "Nothing to do");
        }

        // 1. JVM error: unrecoverable
        if (t instanceof Error) {
            return new Recommendation(Level.SEVERE, false, true,
                    "Log (protected) and terminate in an orderly way. Do NOT carry on.");
        }

        // 2. Domain exception: expected failure, message already written for the user
        if (t instanceof BiblioTechException bte) {
            if (bte.isUserFixable()) {
                return new Recommendation(Level.FINE, true, false,
                        "Show the message as it is and allow a retry.");
            }
            return new Recommendation(Level.WARNING, true, false,
                    "Show the message and state that it does not depend on the user.");
        }

        // 3. I/O failures: an environment condition
        if (t instanceof IOException) {
            return new Recommendation(Level.SEVERE, false, true,
                    "Degrade if there is an alternative; if not, abort the operation. Alert operations.");
        }

        // 4. Technical validations frequent at the boundary with the user
        if (t instanceof NumberFormatException) {
            return new Recommendation(Level.FINE, true, false,
                    "Ask for the data again stating the expected format.");
        }
        if (t instanceof NoSuchElementException) {
            return new Recommendation(Level.FINE, true, false,
                    "Report that it was not found and offer alternatives.");
        }

        // 5. Programming failures: the user must not see the details
        if (t instanceof NullPointerException
                || t instanceof IndexOutOfBoundsException
                || t instanceof ClassCastException) {
            return new Recommendation(Level.SEVERE, false, true,
                    "BUG: generic message with an incident, full stack trace to the log.");
        }

        // 6. Remaining unchecked ones: probably a bug too
        if (t instanceof RuntimeException) {
            return new Recommendation(Level.SEVERE, false, true,
                    "Treat as a bug until proven otherwise.");
        }

        // 7. Checked ones not covered
        return new Recommendation(Level.WARNING, false, true,
                "External condition: log in detail and decide in the layer above.");
    }

    public static void main(String[] args) {
        Throwable[] cases = {
                new NumberFormatException("For input string: \"twelve\""),
                new IllegalArgumentException("The year must be between 1450 and 2100, and it was: 1200"),
                new IOException("catalog.txt (No such file or directory)"),
                new NullPointerException("Cannot invoke \"Employee.getName()\""),
                new OutOfMemoryError("Java heap space"),
                new NoSuchElementException("No line found")
        };

        System.out.printf("%-32s %s%n", "EXCEPTION", "RECOMMENDATION");
        System.out.println("-".repeat(120));
        for (Throwable t : cases) {
            System.out.printf("%-32s %s%n", t.getClass().getSimpleName(), decide(t));
        }
    }
}

Output:

EXCEPTION                        RECOMMENDATION
------------------------------------------------------------------------------------------------------------------------
NumberFormatException            level=FINE    | user=true  | incident=false | Ask for the data again stating the expected format.
IllegalArgumentException         level=SEVERE  | user=false | incident=true  | Treat as a bug until proven otherwise.
IOException                      level=SEVERE  | user=false | incident=true  | Degrade if there is an alternative; if not, abort the operation. Alert operations.
NullPointerException             level=SEVERE  | user=false | incident=true  | BUG: generic message with an incident, full stack trace to the log.
OutOfMemoryError                 level=SEVERE  | user=false | incident=true  | Log (protected) and terminate in an orderly way. Do NOT carry on.
NoSuchElementException           level=FINE    | user=true  | incident=false | Report that it was not found and offer alternatives.

An interesting finding from the exercise itself: the IllegalArgumentException with the invalid year message falls into the "bug" category, when it was really a perfectly expected business validation. It is exactly the argument in favour of the custom exceptions of 06-04: if that validation threw InvalidPublicationYearException extends CatalogException, the policy would classify it correctly and automatically. With standard exceptions, automatic classification is impossible.

Solution 2

package com.nexussoftware.bibliotech.service;

import java.util.HashMap;
import java.util.Map;
import java.util.function.Supplier;
import java.util.logging.Level;
import java.util.logging.Logger;

import com.nexussoftware.bibliotech.domain.BiblioTechException;

/**
 * Wraps BiblioTech operations adding logging, measurement and a
 * uniform error policy.
 *
 * It is an example of the "service decorator" pattern that in module 11
 * the frameworks do with aspects and annotations.
 */
public class OperationLog {

    private static final Logger LOG = Logger.getLogger(OperationLog.class.getName());

    /** Accumulated statistics of one operation (mutable on purpose). */
    private static class Stats {
        int runs;
        int successes;
        int failures;
        long totalNanos;

        double averageMs() {
            return (runs == 0) ? 0.0 : (totalNanos / 1_000_000.0) / runs;
        }
    }

    private final Map<String, Stats> stats = new HashMap<>();
    private int counter = 0;

    /**
     * Runs an operation logging the start, the end, the duration and the failures.
     *
     * @param operationName logical name (LEND, RETURN, IMPORT...)
     * @param employeeId    the employee's IDENTIFIER, never their name
     * @param operation     the operation to run
     */
    public <T> T execute(String operationName, String employeeId, Supplier<T> operation) {
        String operationId = nextId();
        long start = System.nanoTime();
        boolean success = false;

        // Lazy form: if FINE is disabled, the string is not built
        LOG.fine(() -> "[" + operationId + "] START " + operationName
                + " (employee " + employeeId + ")");

        try {
            T result = operation.get();
            success = true;
            return result;

        } catch (BiblioTechException e) {
            // EXPECTED domain failure: WARNING with the exception as the
            // third argument, and it is RETHROWN so the caller decides.
            LOG.log(Level.WARNING, "[" + operationId + "] " + operationName
                    + " rejected [" + e.getCode() + "]", e);
            throw e;

        } catch (RuntimeException e) {
            // UNEXPECTED failure: it is a bug. SEVERE with the identifier as the
            // incident, and it is WRAPPED preserving the cause (06-03).
            LOG.log(Level.SEVERE, "[" + operationId + "] Unhandled error in "
                    + operationName, e);
            throw new IllegalStateException(
                    "Internal error in " + operationName + ". Incident: " + operationId, e);

        } finally {
            // The duration is ALWAYS logged, on success and on failure (06-05)
            long duration = System.nanoTime() - start;
            accumulate(operationName, success, duration);

            final boolean succeeded = success;
            final double ms = duration / 1_000_000.0;

            if (succeeded) {
                LOG.info(() -> String.format("[%s] END %s OK (%.2f ms)",
                        operationId, operationName, ms));
            } else {
                LOG.fine(() -> String.format("[%s] END %s FAILED (%.2f ms)",
                        operationId, operationName, ms));
            }
        }
    }

    /** Variant with no result. */
    public void executeWithoutResult(String operationName, String employeeId, Runnable operation) {
        execute(operationName, employeeId, () -> {
            operation.run();
            return null;
        });
    }

    private synchronized void accumulate(String name, boolean success, long nanos) {
        Stats s = stats.computeIfAbsent(name, n -> new Stats());
        s.runs++;
        s.totalNanos += nanos;
        if (success) { s.successes++; } else { s.failures++; }
    }

    private synchronized String nextId() {
        counter++;
        return String.format("OP-%05d", counter);
    }

    public void dumpStats() {
        System.out.println();
        System.out.printf("%-14s %-12s %-9s %-9s %s%n",
                "OPERATION", "RUNS", "SUCCESSES", "FAILURES", "AVERAGE (ms)");
        System.out.println("-".repeat(62));

        stats.forEach((name, s) ->
                System.out.printf("%-14s %-12d %-9d %-9d %.2f%n",
                        name, s.runs, s.successes, s.failures, s.averageMs()));
    }

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

    public static void main(String[] args) {
        com.nexussoftware.bibliotech.presentation.LogConfiguration.initialise();

        Catalog catalog = new Catalog();
        catalog.register(new com.nexussoftware.bibliotech.domain.Book(
                "BK-0001", "Effective Java", "Bloch", 2018, "978-0000000001"));
        catalog.register(new com.nexussoftware.bibliotech.domain.Book(
                "BK-0002", "Design Patterns", "GoF", 1994, "978-0000000002"));

        OperationLog operationLog = new OperationLog();

        System.out.println("\n----- Operations -----");

        // 1 and 2: correct
        operationLog.execute("QUERY", "EMP-001",
                () -> catalog.getByReference("BK-0001").getTitle());
        operationLog.execute("QUERY", "EMP-002",
                () -> catalog.getByReference("BK-0002").getTitle());

        // 3: expected domain failure, rethrown as it is
        try {
            operationLog.execute("QUERY", "EMP-001",
                    () -> catalog.getByReference("BK-9999").getTitle());
        } catch (BiblioTechException e) {
            System.out.println("Rejected: " + e.getMessage());
        }

        // 4: correct registration
        operationLog.executeWithoutResult("REGISTER", "EMP-003", () ->
                catalog.register(new com.nexussoftware.bibliotech.domain.Book(
                        "BK-0003", "Refactoring", "Fowler", 1999, "978-0000000003")));

        // 5: duplicate registration, domain failure
        try {
            operationLog.executeWithoutResult("REGISTER", "EMP-003", () ->
                    catalog.register(new com.nexussoftware.bibliotech.domain.Book(
                            "BK-0001", "Copy", "X", 2020, "978-0000000009")));
        } catch (BiblioTechException e) {
            System.out.println("Rejected: " + e.getMessage());
        }

        // 6: simulated BUG, wrapped with an incident
        try {
            operationLog.execute("REPORT", "EMP-001", () -> {
                String nothing = null;
                return nothing.length();       // NullPointerException
            });
        } catch (IllegalStateException e) {
            System.out.println("Internal error: " + e.getMessage());
            System.out.println("  logged cause: " + e.getCause().getClass().getSimpleName());
        }

        operationLog.dumpStats();
    }
}

Output (console, level INFO):

----- Operations -----
[INFO   ] OperationLog       [OP-00001] END QUERY OK (0.08 ms)
[INFO   ] OperationLog       [OP-00002] END QUERY OK (0.01 ms)
[WARNING] OperationLog       [OP-00003] QUERY rejected [MATERIALNOTFOUND]
          cause: com.nexussoftware.bibliotech.domain.MaterialNotFoundException: There is no...
Rejected: There is no material with the reference 'BK-9999' (the catalogue has 2 materials)
[INFO   ] Catalog            Material registered: BK-0003 (total 3)
[INFO   ] OperationLog       [OP-00004] END REGISTER OK (0.15 ms)
[WARNING] OperationLog       [OP-00005] REGISTER rejected [DUPLICATEREFERENCE]
Rejected: A material already exists with the reference BK-0001: 'Effective Java'
[SEVERE ] OperationLog       [OP-00006] Unhandled error in REPORT
Internal error: Internal error in REPORT. Incident: OP-00006
  logged cause: NullPointerException

OPERATION      RUNS         SUCCESSES FAILURES  AVERAGE (ms)
--------------------------------------------------------------
QUERY          3            2         1         0.04
REGISTER       2            1         1         0.09
REPORT         1            0         1         0.05

The three design decisions of the exercise:

  1. Domain failures are rethrown as they are; bugs are wrapped. The former already have a message fit for the user and a type that presentation can distinguish; the latter need an incident and to hide the details.
  2. The level reflects who should look at it. WARNING for a business rejection —somebody might want to know how many there are— and SEVERE for a bug, which requires development intervention.
  3. The duration is measured in the finally, so it is logged for failures too. Knowing that an operation took 4 seconds before failing is valuable information: it suggests a timeout and not an immediate rejection.

Solution 3

The ten problems:

# Problem Damage
1 catch (Throwable t) in the loop Traps OutOfMemoryError and StackOverflowError and carries on looping, with the JVM already useless
2 t.printStackTrace() as handling Dumps a raw trace in the user's face: a security risk, useless to them and unlogged
3 new Scanner(System.in) inside the loop Creates a new Scanner on every round. With lend()'s sc.close(), System.in is closed and every later read throws NoSuchElementException
4 sc.close() in finally closing System.in The 06-06 mistake: it closes standard input for the whole process
5 System.exit(0) inside the try It skips the finally blocks (06-05) and any pending clean-up
6 switch with no default An invalid option produces no effect and no message: the user thinks the keyboard is broken
7 catch (Exception e) + throw e in lend Catches far too broadly and rethrows after printing: duplicate logging, and the throws Exception pollutes the signature
8 System.out.println(e.getStackTrace()[0]) Leaks the internal class and line to the user. And it throws ArrayIndexOutOfBoundsException if the trace is empty (the fast throw of 06-01)
9 Empty catch (Exception e) { } in returnItem The module's worst mistake: the failure disappears without a trace
10 "Return processed" outside the try It is printed even if the return failed. The application lies to the user

There is an eleventh, structural problem: there is no Logger at all. All the diagnostics are printStackTrace and println, so there is nothing to filter, archive or analyse.

Corrected version:

package com.nexussoftware.bibliotech.presentation;

import java.util.NoSuchElementException;
import java.util.Scanner;
import java.util.logging.Level;
import java.util.logging.Logger;

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

/**
 * BiblioTech's main menu, final version of module 6.
 *
 * Error policy:
 *   - A single instance Scanner over System.in, which is NEVER closed.
 *   - Catching is by TYPE, from most specific to most general.
 *   - Domain failures produce user messages and a FINE log entry.
 *   - Bugs produce a generic message with an incident and a SEVERE log entry.
 *   - Throwable is not caught: Errors go up to the main boundary.
 *   - Zero diagnostic System.out.println.
 */
public class BiblioTechMenu {

    private static final Logger LOG = Logger.getLogger(BiblioTechMenu.class.getName());

    private static final int LOAN_DAYS = 15;

    /** A SINGLE Scanner, created once and never closed (06-06). */
    private final Scanner scanner = new Scanner(System.in);

    private final LoanManager manager;
    private int operationCounter = 0;
    private boolean exit = false;

    public BiblioTechMenu(LoanManager manager) {
        this.manager = manager;
    }

    public void start() {
        LOG.info("BiblioTech menu started");

        while (!exit) {
            showMenu();
            int option = readOption(0, 3);

            // Each option handles ITS OWN errors: there is no net here
            // catching everything, because such a net would hide bugs.
            switch (option) {
                case 1 -> lend();
                case 2 -> returnItem();
                case 3 -> list();
                case 0 -> exit();
                default -> System.out.println("Unrecognised option: " + option);
            }
        }
        LOG.info("BiblioTech menu finished");
    }

    private void showMenu() {
        System.out.println("""

                ===== BiblioTech - Nexus Software =====
                1. Lend material
                2. Return material
                3. List catalogue
                0. Exit
                =======================================""");
    }

    /** Unbounded retry: there is a person in front who can correct it (06-02). */
    private int readOption(int min, int max) {
        while (true) {
            System.out.print("Option: ");
            String line = scanner.nextLine().trim();
            try {
                int value = Integer.parseInt(line);
                if (value < min || value > max) {
                    System.out.printf("It must be between %d and %d.%n", min, max);
                    continue;
                }
                return value;
            } catch (NumberFormatException e) {
                System.out.println("'" + line + "' is not a number. Type a digit from "
                        + min + " to " + max + ".");
                LOG.fine(() -> "Non-numeric input in the menu: '" + line + "'");
            }
        }
    }

    private void lend() {
        String operationId = nextOperationId();

        System.out.print("Material reference: ");
        String reference = scanner.nextLine().trim();
        System.out.print("Employee identifier: ");
        String employeeId = scanner.nextLine().trim();
        int day = readIntWithLimit("Day of the loan (1-365)", 1, 365);
        if (day == -1) {
            System.out.println("Operation cancelled.");
            return;
        }

        try {
            manager.lend(operationId, reference, employeeId, day);
            // Program OUTPUT (what the user asked to see), not a log
            System.out.println("Lent " + reference + ". Return expected: day "
                    + (day + LOAN_DAYS));

        } catch (MaterialNotFoundException e) {
            System.out.println("Material '" + e.getSearchedReference() + "' does not exist.");
            LOG.fine(() -> "[" + operationId + "] Non-existent reference: "
                    + e.getSearchedReference());

        } catch (MaterialNotAvailableException e) {
            System.out.println("'" + e.getTitle() + "' is on loan.");
            System.out.println("Available in about " + e.waitDays(day) + " days.");
            LOG.fine(() -> "[" + operationId + "] Material unavailable: " + e.getReference());

        } catch (LoanLimitExceededException e) {
            System.out.printf("Limit of %d loans reached. Return %d material(s).%n",
                    e.getLimit(), e.returnsNeeded());
            LOG.fine(() -> "[" + operationId + "] Limit exceeded by " + e.getEmployeeId());

        } catch (BiblioTechException e) {
            System.out.println("The loan could not be completed: " + e.getMessage());
            LOG.log(Level.WARNING, "[" + operationId + "] Unforeseen domain failure ["
                    + e.getCode() + "]", e);

        } catch (RuntimeException e) {
            // BUG. The user sees an incident; the log, all the detail.
            System.out.println("Internal error. Incident: " + operationId);
            LOG.log(Level.SEVERE, "[" + operationId + "] Unhandled error lending "
                    + reference + " to " + employeeId, e);
        }
        // Throwable is NOT caught: an Error must go up to the main boundary.
    }

    private void returnItem() {
        String operationId = nextOperationId();

        System.out.print("Loan reference (LN-NNNN): ");
        String loanReference = scanner.nextLine().trim();
        int day = readIntWithLimit("Day of the return (1-365)", 1, 365);
        if (day == -1) {
            System.out.println("Operation cancelled.");
            return;
        }

        try {
            manager.returnItem(operationId, loanReference, day);
            // INSIDE the try: success is only claimed if there really was any
            System.out.println("Return of " + loanReference + " processed.");

        } catch (LoanAlreadyReturnedException e) {
            System.out.println("That loan was already returned on day "
                    + e.getOriginalReturnDay() + ".");
            LOG.fine(() -> "[" + operationId + "] Duplicate return of "
                    + e.getLoanReference());

        } catch (MaterialNotFoundException e) {
            System.out.println("Loan '" + e.getSearchedReference() + "' does not exist.");
            LOG.fine(() -> "[" + operationId + "] Non-existent loan: "
                    + e.getSearchedReference());

        } catch (BiblioTechException e) {
            System.out.println("The return could not be completed: " + e.getMessage());
            LOG.log(Level.WARNING, "[" + operationId + "] Domain failure on return", e);

        } catch (RuntimeException e) {
            System.out.println("Internal error. Incident: " + operationId);
            LOG.log(Level.SEVERE, "[" + operationId + "] Unhandled error returning "
                    + loanReference, e);
        }
    }

    private void list() {
        System.out.println("(catalogue listing)");
    }

    /**
     * ORDERLY exit: the flag is set and the loop ends on its own.
     * No System.exit here: that would skip the finally blocks and the clean-up (06-05).
     */
    private void exit() {
        exit = true;
        System.out.println("Goodbye.");
    }

    private int readIntWithLimit(String prompt, int min, int max) {
        for (int attempt = 1; attempt <= 3; attempt++) {
            System.out.print(prompt + ": ");
            String line;
            try {
                line = scanner.nextLine().trim();
            } catch (NoSuchElementException e) {
                // Happens if System.in is closed or reaches the end (redirected input)
                LOG.log(Level.WARNING, "Standard input exhausted; cancelling the operation", e);
                return -1;
            }
            try {
                int value = Integer.parseInt(line);
                if (value < min || value > max) {
                    System.out.printf("Out of range [%d..%d]. Attempt %d of 3.%n",
                            min, max, attempt);
                    continue;
                }
                return value;
            } catch (NumberFormatException e) {
                System.out.printf("'%s' is not a number. Attempt %d of 3.%n", line, attempt);
            }
        }
        return -1;
    }

    private String nextOperationId() {
        operationCounter++;
        return String.format("OP-%05d", operationCounter);
    }
}

The eleven problems solved, one by one:

  1. Catching is by type, never Throwable. Errors go up to the main boundary.
  2. Not a single printStackTrace: everything goes to the Logger with its level.
  3. A single instance Scanner, created in the field.
  4. System.in is never closed.
  5. System.exit replaced by a flag and an orderly exit from the loop.
  6. switch with a default that reports.
  7. Specific catches, no rethrowing, and lend() no longer declares throws.
  8. Zero internal information on screen: an incident identifier and nothing else.
  9. No empty catch.
  10. The success confirmation is inside the try.
  11. There is a Logger per class, with distinguished levels and lazy messages.

Conclusion

You have closed the module with the part that turns technical knowledge into production software.

In strategy, you have the rule that governs everything else: catch where you can decide, not where it happens, with its three questions —can I do something other than propagate? do I have context to add? am I the boundary?— and the consequence that not catching is an active decision and very often the right one. You know the error policy of each BiblioTech layer: the domain validates, throws and never logs or prints, because it does not know who is on the other side; the service applies rules, translates preserving the cause and guarantees consistent state; presentation is the filter that catches everything expectable and decides which message to show.

You know how to build the error boundary in main, with its three ordered catch blocks distinguishing a domain failure (clean message) from a bug (incident and silence about the details) and from an Error (protected logging and immediate exit) —the only legitimate exception to the rule of not catching Error—, with different exit codes so a script can react. And you know how to install the global handler with Thread.setDefaultUncaughtExceptionHandler, which stops an exception escaping from a secondary thread killing that thread silently, together with the shutdown hook from 06-05.

You are clear about the separation between what the user sees —a message in their vocabulary, what to do, an incident identifier— and what goes to the log —class, stack trace, paths, context—, with the underlying reason: a stack trace on screen is a security risk before it is an annoyance. And the incident identifier as the piece joining both worlds without exposing anything.

You distinguish recoverable from unrecoverable with the deciding criterion: degrade when the reduced service is still correct; abort when carrying on would produce incorrect results —starting with no catalogue is correct, computing fines with an unknown rate is not. You know the four conditions of a sensible retry, including the most forgotten one: the idempotence of the operation. You know when to validate first and when to catch, with the TOCTOU warning that makes the exception compulsory when the resource is shared. And you know the alternatives to throwing: Optional for the normal absence of a value and the result object for accumulating several errors at once instead of aborting on the first.

In logging, you have the seven reasons why System.out.println is no good in production —it cannot be filtered, cannot be disabled, has no date, level or origin, is not archived and blocks—, with the distinction that avoids the opposite excess: System.out is still correct for the output the user asked for; what gets replaced is the diagnostics. You know the table of levels and what deserves each one, with the warning that when everything is SEVERE, nobody looks at the errors and that an exception handled correctly rarely is one.

You have mastered java.util.logging in practice: a static final Logger per class named with the full class name, the hierarchy by packages that allows switching FINE on only in the service layer, the lazy Supplier form that avoids building messages nobody is going to read, the console and file Handlers with rotation, your own Formatters, and configuration by logging.properties which lets you raise the level in production without recompiling. You know how to log an exception correctly —LOG.log(Level.X, "message", e), with the Throwable as the third argument— and why the four alternatives are worse, including logging and rethrowing in every layer. You know what must never be logged, with the toString() trap that is harmless today and includes an ID number in two years' time, the masking technique, and the formal warning that in a real system this is reviewed by the data protection officer, not by you. And you know how to write useful messages with identifiers instead of names, and to correlate them with an operation identifier. With the map of the real ecosystem: SLF4J as the facade, Logback and Log4j2 as implementations, and the certainty that everything learned here carries over unchanged —only the API changes—, which is what you will see in 11-07.

BiblioTech, at the close of module 6, is another system. Its exception hierarchy —abstract BiblioTechException as the root, CatalogException and LoanException as branches, and five leaves with structured data— names every failure in the language of the business and carries what whoever catches needs in order to decide. Its constructors reject invalid data instead of correcting it with warnings, so there are no longer loans with the reference LN-0000 nor books published in year 0. Catalog has eliminated null as a return value: getByReference throws and findByReference returns Optional. LoanManager validates before modifying, flags each step and compensates in finally, so that a failure halfway leaves the material available, the allowance restored and no phantom loan, with the exception arriving intact. LibrarySession and CatalogExporter are AutoCloseable, close whatever happens and lose no exception along the way. BiblioTechMenu catches by type, translates every failure into an understandable message, never shows an internal detail and uses a single Scanner that is never closed. And main has an error boundary with incidents, exit codes and a global handler. There is not a single diagnostic System.out.println left in the whole project: all logging goes through Logger, with timestamp, level, class, thread and operation identifier, on the console for the important things and in a rotated file for all the detail.

Of the five fragilities you declared at the close of module 5, four are solved: invalid data no longer aborts the program, errors are not reported with mute null or false, warnings do not go through System.out.println, and operations do not leave the state inconsistent when they fail halfway.

The fifth remains, and it is the simplest to state: nothing is saved on exit. The catalogue, the loans, the fines, the reservations and the history live entirely in memory. Close BiblioTech and three modules of work disappear. The next run starts with an empty catalogue, as if it had never existed. And there is a second shortcoming, more discreet: the application cannot read anything from outside beyond what is typed, so loading an inventory of a thousand materials means typing them in one by one.

In module 7, File Input/Output, that is solved. You will see reading and writing files with the API this module has only brushed against —FileReader, FileWriter and the guaranteed closing you have already mastered—; the byte and character streams and why they are two different hierarchies; BufferedReader and BufferedWriter, and exactly what the buffer gains you; object serialisation, which will pick up the serialVersionUID you declared in your exceptions; the NIO.2 API with Path and Files, the modern way of working with the file system; and the interchange formats CSV and Properties, with which BiblioTech will at last load its configuration —those LOAN_DAYS, DAILY_RATE, MAX_FINE and MINOR_THRESHOLD constants that have been hard-coded for six modules— from an external file. By the end of it, BiblioTech will remember. And everything you have learned in this module —the checked I/O exceptions, try-with-resources, translation between layers, graceful degradation when a file does not exist and logging what happens— will be exactly the tool you need from the very first line.

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