The previous lesson ended with a diagnosis: BiblioTech's domain can no longer be corrupted from outside, but the public surface of its classes has grown out of control. Loan exposes more than twenty methods, among them a getEmployeeDoNotUse() whose very name confesses that it should not exist. Encapsulation answers the question how do I hide what is inside; what is left is the harder one: what deserves to be outside. That is the territory of abstraction, OOP's fourth pillar and, unlike the other three, not a language mechanism but a design discipline: keeping what is essential to the problem being solved and discarding everything else. A metro map is a masterful abstraction —it lies about distances, ignores geography and yet gets you to your destination better than a scale map does—. This lesson teaches you to design like that: to decide what you expose, not to mix levels inside a method, to document contracts and to recognise both leaky abstractions and the opposite vice, abstracting what nobody asked for.

Contents

  1. Abstraction as a design discipline
  2. Abstraction versus encapsulation
  3. Levels of abstraction and why they must not be mixed
  4. Refactoring: separating the rule from the presentation
  5. Design by contract: preconditions, postconditions and invariants
  6. The vocabulary of the domain
  7. Separating the what from the how
  8. Minimal public API and the cost of exposing too much
  9. Leaky abstractions
  10. Over-abstraction and YAGNI
  11. The missing mechanisms: interfaces and abstract classes
  12. Common Mistakes and Tips
  13. Exercises

  1. Abstraction as a design discipline

To abstract is to keep what is essential and discard what is accidental. The key phrase is "for the problem": there is no absolutely correct abstraction, only the one that is correct for a purpose.

The same physical book is abstracted differently depending on who models it:

System What is essential What is discarded
BiblioTech (loans) Title, ISBN, availability, term Weight, page count, cover colour
Online shop Price, stock, cover image, shipping Loan availability
Printing house Paper weight, ink, format, binding ISBN, author
Removals company Weight and volume Absolutely everything else

None is more "true" than the others. A Book in BiblioTech has no weight because BiblioTech does not care about weight, and adding it "just in case" would pollute the model.

Hence the first question you must ask yourself about any class:

What does this object need to know and do for the problem we are solving? Everything else is surplus.

  1. Abstraction versus encapsulation

The two concepts are constantly confused because they collaborate, but they answer different questions:

Abstraction Encapsulation
Question it answers What do I expose? How do I hide it?
Nature A design decision A language mechanism
Moment When modelling, before writing code When implementing
Tools Choosing classes, operations and names private, protected, getters, business operations
It goes wrong when... You expose what does not matter or hide what does You leave doors open to the internal state
In BiblioTech Deciding that Loan offers registerReturn(int) Making the appliedFine field private
Car analogy The steering wheel and pedals are the abstraction of "driving" The closed bonnet stops you touching the engine

You can have one without the other, and both failures are real:

  • Good encapsulation, bad abstraction: every field private, but with thirty validated getters and setters. Nothing gets corrupted, but the class means nothing; it is a spreadsheet with Java syntax.
  • Good abstraction, bad encapsulation: a magnificent public API —lend(), returnItem(), calculateFine()— alongside public fields that let you bypass it. The design is right and it is not enforced.

You need both. Abstraction decides which doors there are; encapsulation guarantees that there are no others.

  1. Levels of abstraction and why they must not be mixed

A program has layers of detail, from the concept to the bit:

flowchart TD
    A["Level 4: use case
    'register the return of a book'"] --> B["Level 3: business rules
    calculate delay, apply cap, classify"]
    B --> C["Level 2: domain operations
    material.returnItem(), employee.registerReturn()"]
    C --> D["Level 1: language mechanics
    Math.min, printf, loops, arithmetic"]

The rule, known as the single level of abstraction principle:

Inside a single method, every statement should belong to roughly the same level.

Why? Because reading a method that jumps between levels forces you to switch mental frames on every line. A method that says "register the return, and by the way here is the VAT formula and the receipt's column width" cannot be skim-read: it has to be read in full, every time.

This is what mixing looks like, and you will probably recognise the style because it is exactly that of your module 2 main:

// BAD: four levels of abstraction in fourteen lines
public void processReturn(Loan loan, int days) {

    System.out.println("========================================");     // level 1
    System.out.println("   RETURN RECEIPT - BIBLIOTECH");               // level 1

    int late = Math.max(0, days - loan.getMaterial().getLoanDays());       // level 3
    double fine = late * loan.getMaterial().getDailyRate();                // level 3
    if (fine > 20.0) {                                                     // level 3
        fine = 20.0;
    }

    System.out.printf("  %-20s %s%n", "Material:", loan.getMaterialTitle());   // level 1
    System.out.printf("  %-20s %d days%n", "Days late:", late);                // level 1
    System.out.printf("  %-20s %.2f EUR%n", "Fine:", fine);                    // level 1

    loan.getMaterial().returnItem();                                           // level 2
    System.out.println("========================================");            // level 1
}

The concrete problems, beyond aesthetics:

  1. The rule cannot be reused. If another point in the program needs the fine, it has to copy the formula or call this method and put up with the console receipt.
  2. It cannot be tested. To verify the calculation you have to capture standard output (module 11).
  3. The channel cannot be changed. If tomorrow the receipt is a web page or a PDF, the business logic has to be rewritten along with the format.
  4. The €20 cap is duplicated. It already lives in Material.MAX_FINE, and here it appears as a literal. Two truths for one fact.
  5. It violates the Law of Demeter three times with loan.getMaterial().getX().

  1. Refactoring: separating the rule from the presentation

Let us rewrite the previous method, distributing each line to its level.

Level 3, business rules: they are already in the domain. Loan.registerReturn(int) computes the fine, marks the loan, releases the material and discounts the employee (lesson 03-07). Nothing new needs writing.

Level 1, presentation: a separate class, outside the domain.

package com.nexussoftware.bibliotech.presentation;

import com.nexussoftware.bibliotech.domain.Loan;

/** Generates the texts BiblioTech shows on the console. */
public class ConsoleReceipt {

    private static final String SEPARATOR = "========================================";

    /** Prints the receipt of an already registered return. */
    public void printReceipt(Loan loan) {
        System.out.println(SEPARATOR);
        System.out.println("   RETURN RECEIPT - BIBLIOTECH");
        System.out.printf("  %-20s %s%n",       "Reference:", loan.getReference());
        System.out.printf("  %-20s %s%n",       "Material:",  loan.getMaterialTitle());
        System.out.printf("  %-20s %s%n",       "Employee:",  loan.getEmployeeName());
        System.out.printf("  %-20s %d days%n",  "Days late:", loan.calculateDaysLate());
        System.out.printf("  %-20s %.2f EUR%n", "Fine:",      loan.getAppliedFine());
        System.out.printf("  %-20s %s%n",       "Severity:",  loan.classifySeverity());
        System.out.println(SEPARATOR);
    }
}

Level 4, the use case: it coordinates, it does not calculate or print.

/** Registers the return of a loan and hands the receipt to the employee. */
public void processReturn(Loan loan, int elapsedDays) {
    loan.registerReturn(elapsedDays);
    receipt.printReceipt(loan);
}

Two lines. And each one at its own level.

Compare before and after:

Criterion Before After
Levels mixed in one method 4 1
Where the fine formula lives Duplicated in the method Only in Material
The literal 20.0 Hand-written MAX_FINE
Switching to web output Rewrite the method Another presentation class
Testing the calculation By capturing System.out By calling calculateFine()
Demeter violations 3 0
Lines in the use case 14 2

Note the new package, presentation. BiblioTech's structure begins to reflect its abstraction levels:

com.nexussoftware.bibliotech
├── BiblioTechApp.java          startup
├── domain/                     WHAT the business is and its rules
├── presentation/               HOW it is shown
└── service/                    (module 5) use cases

This separation is the seed of the layered architecture you will see in full in module 12.

  1. Design by contract: preconditions, postconditions and invariants

An abstraction is a promise: "call me this way and I will give you this back". The more explicit the promise, the less room for misunderstanding. Design by contract formalises that promise in three parts:

Element What it is Who guarantees it
Precondition What must be true before the call The caller
Postcondition What will be true after the call The method
Invariant What is always true, before and after The class

Java has no syntax for contracts (other languages do), so they are documented with Javadoc, which you already know from module 1:

/**
 * Registers the return of the lent material and applies the corresponding fine.
 *
 * <p><b>Preconditions:</b>
 * <ul>
 *   <li>{@code elapsedDays >= 0}.</li>
 *   <li>The loan must not have been returned already; if it has, the call has
 *       no effect and the previously applied fine is returned.</li>
 * </ul>
 *
 * <p><b>Postconditions:</b>
 * <ul>
 *   <li>{@code isReturned()} becomes {@code true}.</li>
 *   <li>The material becomes available again.</li>
 *   <li>The employee has one active loan fewer.</li>
 *   <li>The returned value is between 0 and {@code Material.MAX_FINE}.</li>
 * </ul>
 *
 * <p><b>Class invariant maintained:</b> {@code elapsedDays >= 0} and
 * {@code dueDay == loanDay + material.getLoanDays()}.
 *
 * @param elapsedDays days elapsed since handover, never negative
 * @return amount of the applied fine, in euros
 */
public double registerReturn(int elapsedDays) { ... }

Advantages of writing it:

  • The caller knows what they must satisfy without reading the implementation. That is, literally, the definition of abstraction.
  • It forces you, as you write it, to think about the edge cases: what happens if it had already been returned? And if the days are negative? Many contracts reveal design gaps before they exist as bugs.
  • The IDE shows it when autocompleting, so the contract travels with the code.

And a practical guide about preconditions: there are two strategies, and it is worth choosing consciously.

Strategy How When
Defensive Check and reject (warnings now, exceptions in module 6) Public API, user input
Strict contract Document the precondition and assume it holds private methods, internal code

Checking everything everywhere multiplies the code without adding safety: if a private method is called only by two other methods of the same class that already validated, validating again is noise.

  1. The vocabulary of the domain

A good abstraction speaks the language of the business. If a Nexus Software librarian read your code over your shoulder, they should recognise the words.

Compare:

// Technical vocabulary: it means nothing to the business
DataManager dm = new DataManager();
dm.process(item, 20);
if (dm.getStatus() == 2) { ... }

// Domain vocabulary
double fine = loan.registerReturn(20);
if (loan.isOverdue()) { ... }

The idea of using a single vocabulary shared between the code, the documentation and the conversations with the business is called the ubiquitous language. Its practical rule is simple: if the business says "fine", the code says fine, not penaltyAmount, charge or levy.

BiblioTech's vocabulary, which you have been using since module 1, is exactly this: title, author, isbn, available, daysLate, fine, severity, reference, lend, returnItem. No technical word has crept into the domain, and that is why loan.registerReturn(20) is understandable without opening the class.

  1. Separating the what from the how

The tangible result of a good abstraction is that the caller stops knowing how things are done.

Walk through what has happened to the fine calculation over the course:

Moment Who knows how it is computed
Module 1 main: the formula is written there
Module 2 main: the formula, the cap and the classification, inside a switch
Lesson 03-03 Loan: main calls calculateFine()
Lesson 03-05 Material and its subclasses: each format supplies its rate
Lesson 03-07 Only the domain: main cannot even touch the result
Now main does not even mention the word "fine" except to display it

The definitive test that the what and the how are separated is this: can you change the implementation without touching the callers? If Nexus Software decides tomorrow that the rate should be progressive —€0.25 for the first seven days and €0.50 from the eighth on—, how many files have to be touched?

// New rule, only in Material
@Override
public double calculateFine(int elapsedDays) {
    int late = calculateDaysLate(elapsedDays);
    int minorBand  = Math.min(late, MINOR_THRESHOLD);
    int severeBand = Math.max(0, late - MINOR_THRESHOLD);
    double amount = minorBand * getDailyRate()
                  + severeBand * getDailyRate() * 2;
    return Math.min(amount, MAX_FINE);
}

One file. Neither main, nor the receipt, nor Loan finds out. That is what abstraction buys, and it is the reason its cost in indirection is worth paying.

  1. Minimal public API and the cost of exposing too much

A class's public API is everything others can use: public methods and fields, and its constructors. It is its promise to the outside world.

And that promise has a cost that almost nobody calculates while writing the code:

Cost Explanation
Compatibility Every public method is a commitment: if you delete it or change its signature, you break all its users
Cognitive load A class with 25 public methods forces you to read 25 names to know which one to use
Error surface Every public method is a route by which someone can misuse the class
Lost freedom You cannot change what you have promised; only the private part is free

The golden rule:

It is easy to add a public method later; it is extremely expensive to remove one. When in doubt, leave it private.

Applied to Loan, let us review its current API with a severe eye:

Member Public? Reason
registerReturn(int) Yes It is the use case
calculateFine(), calculateDaysLate(), classifySeverity() Yes Queries the presentation needs
getReference(), getMaterialTitle(), getEmployeeName() Yes They identify the loan on screen
isReturned(), isOverdue(), getDueDay() Yes State queried in listings
getAppliedFine() Yes A fact recorded after the return
setElapsedDays(int) No → private It is an internal detail of registerReturn
getEmployeeDoNotUse() No → remove It allows mutating the employee through the back door
getMaterial() Debatable Needed in some listings; can be replaced by delegations
getLoanDay(), daysRemaining() Yes They are shown in the summary
employeeAtLimit() Yes A legitimate business query
canBeLent(Material, Employee) Yes Pre-check for the use case

With that pruning, Loan goes from more than twenty public members to fourteen, and none of them lets you unbalance the system. Exercise 1 asks you to take it all the way.

  1. Leaky abstractions

A leaky abstraction is one that promises to hide a detail but forces you to know it anyway. The law stated by Joel Spolsky says, with some black humour, that all non-trivial abstractions leak: the point is not to avoid it entirely, but not to make it worse.

Examples you have already seen or will see:

Leak How it shows
Loan.getMaterial() returns the mutable object Whoever receives it can call returnItem() bypassing the loan
A getIncidents() with no defensive copy The caller discovers their array is the internal one
A method called save() that sometimes fails on the network The "save" abstraction cannot hide that there is a network underneath (module 9)
String.substring and memory usage In old Java versions, a substring retained the whole original array

How to reduce leaks:

  • Return immutable types or copies instead of internal state.
  • Do not expose implementation types in the public signature. If tomorrow you want to change an array for a list, your signature should not force you to break anyone.
  • Document what leaks when you cannot avoid it. A documented leak is a contract; a silent leak is a trap.

  1. Over-abstraction and YAGNI

The opposite vice exists too, and in the hands of somebody who has just discovered OOP it is the more frequent one: abstracting what nobody asked for.

Symptoms of over-abstraction:

  • Five-level hierarchies for three concrete cases.
  • Classes called AbstractBaseGenericProcessorFactory.
  • Configuration parameters that are always the same value.
  • Extension points "in case one day" that are never used.
  • One interface per class, with a single implementation.

The principle that contains it is called YAGNI: You Aren't Gonna Need It. Formulated as a rule:

Do not add flexibility until you have two real cases that require it. With one, it is guesswork.

Applied to BiblioTech, an honest contrast:

Abstraction Justified? Why
Material with three subclasses Yes There are three real formats with different rules
Separating domain from presentation Yes Module 12 will bring a web interface
An overridable getLoanDays() Yes Each format has its own term, verified
A PhysicalMaterial / DigitalMaterial hierarchy Not yet There is no digital material in the catalog
A rate system configurable from a file No There is one rate per format and it does not change
A Lendable interface with a single implementer No It contributes nothing until there is a second

And a warning about balance: over-abstracting is an expensive but visible mistake; under-abstracting produces module 2's two-hundred-line main, which is expensive too. The professional answer is not to pick an extreme, but to refactor when the second case appears. That is exactly what you have done: Material did not exist until magazines and DVDs turned up.

  1. The missing mechanisms: interfaces and abstract classes

This whole lesson has talked about abstraction as a discipline: what to model, what to expose, how to separate levels. But Java also offers two language mechanisms designed specifically to materialise it, and both arrive in the next module:

Mechanism What it contributes Lesson
Interfaces They declare what can be done without saying how; a class can implement several 04-01
Abstract classes Classes that cannot be instantiated and that can declare bodiless methods, forcing subclasses to implement them 04-02

Two concrete shortcomings of the current design that those mechanisms will solve:

First: Material can be instantiated. Today nothing stops you writing new Material("Something", "REF-1", true), and that means nothing: the library has books, magazines and DVDs, not "generic materials". An abstract class forbids it at compiler level.

Second: getType() returns "Material" by default. If somebody creates a new subclass and forgets to override it, "Material" will appear in the listings without anyone detecting it. An abstract method turns that omission into a compile error:

// A preview of lesson 04-02
public abstract class Material {
    public abstract String getType();     // no body: forces implementation
}

Take this away: in module 4 you will learn how they are written; in this lesson you have learned why they exist. The order matters, because whoever learns interface without having understood abstraction ends up creating one interface per class without knowing what for.

Common Mistakes and Tips

  • Confusing abstraction with encapsulation. Abstraction is what I expose; encapsulation is how I hide it. The table in section 2 separates them.
  • Mixing levels in a method. Business rules and printf in the same block is the most frequent mistake in beginners' code, and the most expensive to undo.
  • Putting presentation in the domain classes. A Book that knows how to print its card on the console cannot be reused on a website. Presentation goes in another package.
  • Making things public "just in case". Every public method is a permanent commitment. Start private and raise it only when somebody genuinely needs it.
  • Generic names. Manager, Processor, Helper, Data, Info, Util. If you cannot give a class a concrete name, you probably do not yet know what it is.
  • Documenting the how instead of the what. A Javadoc saying "walks the array and sums" becomes obsolete the moment you change the implementation. Document the contract: what it receives, what it returns, what it guarantees.
  • Abstracting without a second case. YAGNI. Wait for the second format, the second database, the second file layout to show up.
  • Tip: describe the method in one sentence. If you need "and" or "also", the method does two things and probably mixes levels.
  • Tip: read your code as if you were the librarian. If l.registerReturn(20) is understandable and dm.process(i, 20) is not, you know which one is well named.
  • Tip: the change test. For every design decision, ask yourself what would have to be touched if the rule changed. The fewer files, the better the abstraction.

Exercises

Exercise 1: redesign Loan's public API

This is the lesson's central exercise. Take the Loan class as it ended up in 03-07 and reduce its public surface to the necessary minimum.

  1. List all its current public members.
  2. For each one, decide: keep, make private or remove, justifying the decision in one sentence.
  3. Remove getEmployeeDoNotUse() and replace its uses with appropriate delegations.
  4. Write the complete Javadoc, with preconditions and postconditions, of the three methods you consider the class's core.
  5. Check that BiblioTechApp and ConsoleReceipt still work with the reduced API.

Exercise 2: separate abstraction levels

Refactor this method by distributing each line to its level. It should end up with: the rules in the domain, the presentation in ConsoleReceipt and the use case reduced to three or four readable lines.

public static void processFullLoan(Material m, Employee e, int day, Scanner sc) {
    System.out.println("--- NEW LOAN ---");
    if (m.isAvailable() == false) {
        System.out.println("ERROR: not available");
        return;
    }
    if (e.getTotalLoans() >= 3) {
        System.out.println("ERROR: limit reached");
        return;
    }
    System.out.print("Confirm (yes/no): ");
    String r = sc.nextLine().trim();
    if (!r.equalsIgnoreCase("yes")) {
        System.out.println("Cancelled");
        return;
    }
    Loan l = new Loan(m, e, day);
    int due = day + m.getLoanDays();
    System.out.println("OK. Due on day " + due
                       + ". Late rate: " + m.getDailyRate() + " EUR/day"
                       + ". Cap: " + 20.0 + " EUR");
}

Exercise 3: judge abstractions

For each proposal from a colleague, decide whether it is justified, whether it is over-abstraction (YAGNI) or whether it is a leaky abstraction. Justify it in two or three sentences and, where appropriate, propose the alternative.

  1. Creating PhysicalMaterial and DigitalMaterial as intermediate levels between Material and the three current classes.
  2. Adding to Material a method public String[] getInternalFields() returning the raw state "for debugging".
  3. Extracting the four business constants to a BusinessRules class with static methods.
  4. Adding to Loan a boolean silentMode parameter in registerReturn so as not to print warnings.

Solutions

Solution 1

1 and 2. Member-by-member review:

Member Decision Justification
Loan(Material, Employee, int, int) Keep Canonical constructor
Loan(Material, Employee, int) Keep Usual entry, with zero days
getReference() Keep Identifies the loan on screen and in notices
getMaterialTitle() Keep Delegation compliant with Demeter
getEmployeeName() Keep Delegation compliant with Demeter
getMaterial() Remove Exposes a mutable collaborator; replaced by delegations
getEmployeeDoNotUse() Remove Allows mutating the employee bypassing the loan
getLoanDay() Keep Shown in the summary
getDueDay() Keep Key data for the user
getElapsedDays() Keep Shown and queried
setElapsedDays(int) To private Internal detail of registerReturn
isReturned() Keep State queried in listings
isOverdue() Keep Business query
daysRemaining() Keep Shown in due-date notices
calculateDaysLate() Keep The presentation needs it
calculateFine() Keep Query made before the return
classifySeverity() Keep Shown on the receipt
getAppliedFine() Keep A fact recorded after returning
getIncidents() Keep With a defensive copy
recordIncident(String) Keep A legitimate business operation
employeeAtLimit() Keep Replaces navigating to the employee
getLoansCreated() Keep Session statistic
canBeLent(Material, Employee) Keep Pre-check for the use case

3. Replacing the two removed getters. Where l.getMaterial().getType() or l.getEmployeeDoNotUse().getIdentifier() used to be written, delegations are added:

public String getMaterialType()      { return material.getType(); }
public String getMaterialReference() { return material.getReference(); }
public String getEmployeeIdentifier() { return employee.getIdentifier(); }

They are three trivial methods, and that is the usual objection: "I have swapped one getter for three". The answer is that now the class controls what can be known about the employee, and nobody can call registerLoan() behind its back. One open door has been swapped for three barred windows.

4. Javadoc of the three core methods:

/**
 * Registers the return of the material and applies the corresponding fine.
 *
 * <p>Preconditions: {@code elapsedDays >= 0}. If the loan had already been
 * returned, the call has no effect.
 *
 * <p>Postconditions: {@code isReturned()} is {@code true}; the material
 * becomes available again; the employee frees up an active loan; the returned
 * value lies in the interval [0, {@code Material.MAX_FINE}].
 *
 * @param elapsedDays days since handover, never negative
 * @return applied fine in euros
 */
public double registerReturn(int elapsedDays) { ... }

/**
 * Calculates the fine that would apply if the material were returned now,
 * according to the format's own term and rate.
 *
 * <p>Preconditions: none. It can be called at any time.
 * <p>Postconditions: it does not modify the loan's state; the result lies
 * in [0, {@code Material.MAX_FINE}].
 *
 * @return amount in euros
 */
public double calculateFine() { ... }

/**
 * Says whether it is possible to register a loan of the given material to the
 * given employee, without creating it.
 *
 * <p>Preconditions: none; it accepts null arguments and answers
 * {@code false}.
 * <p>Postconditions: it modifies no object.
 *
 * @param material the requested material
 * @param employee the requesting employee
 * @return {@code true} if the material is available and the employee has not
 *         reached {@code Employee.MAX_CONCURRENT_LOANS}
 */
public static boolean canBeLent(Material material, Employee employee) { ... }

Notice a detail in the third one: documenting "accepts nulls and answers false" is a design decision. Without it, every caller would have to check on their own, or discover the behaviour through NullPointerExceptions.

5. ConsoleReceipt already used only delegations (getMaterialTitle(), getEmployeeName()), so it still compiles unchanged. That is the sign the pruning was right: what was removed was not used by anyone legitimate.

Solution 2

The original method mixes four levels: interaction with the user, rule validation, creation of the loan and presentation of results. It is distributed like this.

Domain. The combined check already exists (Loan.canBeLent) and the due day is computed by the constructor. No new rules need adding, only a method that explains why it cannot be lent:

/**
 * @return the reason why it cannot be lent, or an empty string if it can
 */
public static String rejectionReason(Material material, Employee employee) {
    if (material == null || employee == null) return "Incomplete data";
    if (!material.isAvailable())              return "The material is not available";
    if (!employee.canBorrow())                return "The employee has reached their limit";
    return "";
}

Presentation. All the text, in ConsoleReceipt:

public void printLoanHeader() {
    System.out.println("--- NEW LOAN ---");
}

public void printRejection(String reason) {
    System.out.println("ERROR: " + reason);
}

public void printConditions(Material m) {
    System.out.printf("  Term: %d days | Rate: %.2f EUR/day | Cap: %.2f EUR%n",
                      m.getLoanDays(), m.getDailyRate(), Material.MAX_FINE);
}

public void printLoanRegistered(Loan l) {
    System.out.printf("OK. %s registered. Due on day %d.%n",
                      l.getReference(), l.getDueDay());
}

Interaction with the user. That is presentation too, so the confirmation is isolated:

/** @return true if the user confirms the operation. */
public boolean confirm(Scanner sc) {
    System.out.print("Confirm (yes/no): ");
    return sc.nextLine().trim().equalsIgnoreCase("yes");
}

Use case. It coordinates and nothing else:

public static void processFullLoan(Material m, Employee e, int day, Scanner sc) {

    receipt.printLoanHeader();

    String reason = Loan.rejectionReason(m, e);
    if (!reason.isEmpty()) {
        receipt.printRejection(reason);
        return;
    }

    receipt.printConditions(m);
    if (!receipt.confirm(sc)) {
        System.out.println("Cancelled");
        return;
    }

    receipt.printLoanRegistered(new Loan(m, e, day));
}

Improvements achieved, beyond the length:

Original problem How it is resolved
m.isAvailable() == false !reason.isEmpty(), and the reason comes with text
The literal 3 duplicating the limit Supplied by Employee.MAX_CONCURRENT_LOANS
The literal 20.0 duplicating the cap Material.MAX_FINE
day + m.getLoanDays() recomputed outside Computed by the constructor; read with getDueDay()
Error messages scattered around All in the presentation class
Impossible to change channel Replace ConsoleReceipt with another class

Solution 3

1. PhysicalMaterial / DigitalMaterial — over-abstraction (YAGNI). Today the catalog's three materials are all physical, so DigitalMaterial would have no subclass and PhysicalMaterial would contribute neither a field nor a method that Material does not already have. It would add a level to the hierarchy —exactly what 03-05 advises against— in exchange for nothing. Alternative: wait. The day an on-demand video course arrives, with its own access rules, the intermediate level is introduced with a real case in hand.

2. getInternalFields() — a leaky abstraction, and a serious one. A method returning "the raw state" publishes the internal representation as part of the API: as soon as somebody uses it in production "just for a log", you will no longer be able to change the fields without breaking it. It also invites parsing the output instead of using the methods. Alternative: a properly overridden toString(), which is exactly what this is for and is studied in the next lesson. Debugging is done with the debugger (02-05), which sees private fields without needing to expose them.

3. A BusinessRules class with the constants — it depends, and that is why it is the most interesting. As formulated, it is debatable: the constants already live in Material, the class that uses them, and moving them into a separate container would take them away from their meaning; besides, getLoanDays() and getDailyRate() are polymorphic per format, so a single class could not represent them without going back to a switch on the type, that is, undoing what was achieved in 03-06. It would be justified if those values had to be read from a configuration file or vary by site, because then there would be a second real case. Today there is not. Verdict: YAGNI, with a review date.

4. A boolean silentMode parameter — wrong for two reasons. The first is the one from the good-practice section of 03-03: a boolean parameter at the call site says nothing (registerReturn(20, true) is unreadable). The second is deeper: its existence proves the abstraction is broken, because a domain method should not print anything, and therefore there should be nothing to silence. Alternative: have registerReturn write nothing to the console at all and return the information —it does, it returns the fine—, letting the presentation layer decide what to show and when. "Silent mode" disappears because the problem disappears.

A pattern worth recognising in all four answers: proposals 2 and 4 are patches over symptoms; once the abstraction levels are separated, neither of them has any reason to exist.

Conclusion

You have completed the four pillars. Abstraction is not a keyword you type, but the discipline of keeping what is essential to the problem, and that is why the same book is modelled differently in a library, a shop and a printing house. You know how to distinguish it from encapsulation —what I expose versus how I hide it— and why you need both. You have learned to detect and correct the mixing of abstraction levels, the vice that turned your module 2 main into an unreadable block, separating the business rules into the domain, the presentation into its own package and leaving the use case at two lines. You know how to write contracts in Javadoc with preconditions, postconditions and invariants, and to choose consciously between validating defensively or documenting and trusting. You model with the vocabulary of the domain, so that a Nexus Software librarian would recognise your code. You have verified that separating the what from the how pays for itself: changing the fine policy to a progressive rate affects one file. And you have judgement about both extremes: the minimal public API versus the permanent cost of exposing too much, leaky abstractions and the YAGNI that reins in hierarchies invented for cases nobody asked for.

You also know what is missing: the two mechanisms with which Java materialises all of this —interfaces (04-01) and abstract classes (04-02)— arrive in the next module, and now you will know what they are for, which is exactly what is missing from anyone who learns them too early.

One lesson remains to close the module, and it is the one that makes your objects behave as first-class citizens of the language. Right now, printing a Book produces an unreadable Book@1b6d3586, and two copies with the same ISBN are considered different because equals compares memory addresses. In The Object Class: equals, hashCode and toString you will learn the role of the root of the whole hierarchy, the complete equals contract with its five properties, why hashCode must always be overridden alongside it and what breaks if you do not. And you will keep the promise that closes module 2: the three loose variables highestDelay, highestDelayEmployee and highestDelayBook will finally become a single coherent object.

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