This lesson settles every debt in the course.

Since module 3 you have been writing design patterns without calling them by their name. In 03-04, on seeing constructors with too many parameters, a Builder was promised "for later on". In 04-02, when talking about abstract classes with a template method, we said "this has a name, and we will see it". In 07-03, the I/O streams wrapped inside one another were "a pattern we will study". In 10-03 you wrote complete dynamic proxies without mentioning that this was the Proxy pattern. In 11-02, dependency injection was "the D of a set of principles we will see at the end". That end is now.

But first, a warning that matters more than the entire catalogue.

Patterns are not goals. Nobody should set out to "use the Observer pattern" any more than anybody sets out to "use a while loop". A pattern is a recognised answer to a recurring problem, and applying one without having the problem does exactly the same damage as not applying it when you do: code that is harder to understand, with more pieces and more indirection, and no advantage in return. Over-design is as real a problem as no design, and in young projects it is more common.

The real usefulness of knowing patterns is twofold, and neither half is "applying them". The first is vocabulary: saying "CachingCatalog is a Decorator over the repository" saves three minutes of explanation and conveys exactly the structure and its consequences. The second is recognition: when a problem makes you uncomfortable, knowing that somebody has already solved it gives you a starting point instead of a blank page.

By the end of this lesson you will know the SOLID principles with real before-and-after examples from BiblioTech, you will have mastered the GoF patterns that genuinely get used in modern Java, you will know how to implement them and —above all— when not to, you will recognise the architectural patterns that Spring and JPA apply for you, and you will identify the most common anti-patterns, including one BiblioTech has and that we are going to discuss honestly.

Contents

  1. What a pattern is and what it is not
  2. The GoF catalogue and its historical context
  3. Principles before patterns
  4. SOLID: single responsibility (S)
  5. SOLID: open/closed (O)
  6. SOLID: Liskov substitution (L)
  7. SOLID: interface segregation (I)
  8. SOLID: dependency inversion (D)
  9. DRY, KISS, YAGNI and the Law of Demeter
  10. Creational patterns: Singleton
  11. Creational patterns: Factory Method and Abstract Factory
  12. Creational patterns: Builder
  13. Creational patterns: Prototype
  14. Structural patterns: Adapter
  15. Structural patterns: Decorator
  16. Structural patterns: Proxy
  17. Structural patterns: Facade and Composite
  18. Behavioural patterns: Strategy
  19. Behavioural patterns: Template Method
  20. Behavioural patterns: Observer
  21. Behavioural patterns: State
  22. Behavioural patterns: Command
  23. Behavioural patterns: Chain of Responsibility and Iterator
  24. Architectural and enterprise patterns
  25. Anti-patterns
  26. Final table: problem → candidate pattern
  27. Common Mistakes and Tips
  28. Exercises
  29. Conclusion

  1. What a pattern is and what it is not

A design pattern has four elements:

Element What it is Example (Strategy)
Name Shared vocabulary "Strategy"
Problem When to apply it Several variants of an algorithm, chosen at runtime
Solution The structure, not the code An interface holding the algorithm, interchangeable implementations, a context that delegates
Consequences What you gain and what you pay You gain extensibility; you pay one class per variant

What a pattern is not:

  • It is not code to copy. It is a structure. The implementation depends on the language: in Java 8+, many GoF patterns collapse into a lambda.
  • It is not mandatory. If if (type == BOOK) ... else ... covers two cases that are not going to grow, an if is fine.
  • It is not a measure of quality. A project with fifteen patterns is not better than one with three. It is usually worse.
  • It is not timeless. Several GoF patterns exist to make up for shortcomings in the C++ and Java of 1994. In Java 21, some of them are one line.

  1. The GoF catalogue and its historical context

In 1994, Gamma, Helm, Johnson and Vlissides —the Gang of Four— published Design Patterns, cataloguing 23 patterns observed in real C++ and Smalltalk systems. They are organised into three families:

Family Concerned with Patterns (the ones used today in bold)
Creational How objects are created Singleton, Factory Method, Abstract Factory, Builder, Prototype
Structural How they are composed Adapter, Decorator, Proxy, Facade, Composite, Bridge, Flyweight
Behavioural How they collaborate and share responsibilities Strategy, Observer, Template Method, State, Command, Iterator, Chain of Responsibility, Mediator, Memento, Visitor, Interpreter

Two important historical warnings:

  1. Java has many of them built in. Iterator is in the language (for-each). Observer is Spring's event API. Strategy is any functional interface. The book's authors said explicitly that a pattern is a shortcoming of the language: when the language improves, the pattern disappears.
  2. Some have aged badly. The classic Singleton is today widely considered an anti-pattern (we will see why). java.util's Observer/Observable have been deprecated since Java 9.

  1. Principles before patterns

Patterns are concrete solutions; principles are the criteria that tell you whether a solution is good. If you had to choose between memorising the 23 patterns and internalising SOLID, choose SOLID without hesitating: with the principles you can derive the patterns, but not the other way round.

flowchart TD
    P["PRINCIPLES<br/>SOLID, DRY, KISS, YAGNI, Demeter"]
    PA["PATTERNS<br/>Strategy, Decorator, Adapter…"]
    C["CODE<br/>BiblioTech"]
    P -->|"justify"| PA
    PA -->|"structure"| C
    C -->|"reveals problems that demand"| P

  1. SOLID: single responsibility (S)

A class should have only one reason to change.

The popular phrasing "one class, one thing" is misleading, because "one thing" is not defined. The useful formulation is the one about reasons to change: if two different people, with different motives, can ask you to touch the same class, that class has two responsibilities.

Before (the LoanManager BiblioTech once had):

@Service
public class LoanManager {

    public void lend(String isbn, Long employeeId, int days) {
        // 1. Input validation
        if (isbn == null || !isbn.matches("97[89]-\\d{10}")) {
            throw new IllegalArgumentException("Invalid ISBN");
        }
        // 2. Data access
        Material m = em.createQuery("select m from Material m where m.isbn = :i", Material.class)
                       .setParameter("i", isbn).getSingleResult();
        // 3. Business rules
        long active = em.createQuery("select count(l) from Loan l where …").getSingleResult();
        if (active >= 3) throw new LimitExceededException();
        // 4. Persistence
        Loan loan = new Loan(m, employee, LocalDate.now(), LocalDate.now().plusDays(days));
        em.persist(loan);
        // 5. Notification
        smtp.send(employee.getEmail(), "Loan confirmed", "You have taken " + m.getTitle());
        // 6. Output formatting
        System.out.printf("%-40s %s%n", m.getTitle(), loan.getDueDate());
        // 7. Auditing
        Files.writeString(Path.of("audit.log"), LocalDateTime.now() + " LOAN " + isbn + "\n",
                          StandardOpenOption.APPEND);
    }
}

Seven reasons to change: the ISBN format, the data model, the limit rule, the mapping, the mail provider, the console format and the audit format. Each one is requested by a different person.

After:

// DOMAIN: the rule, and only the rule
public class LoanPolicy {
    private final int maxActive;

    public void checkCanBorrow(Employee employee, List<Loan> active) {
        if (active.size() >= maxActive) {
            throw new LoanLimitExceededException(employee.getName(), maxActive);
        }
        if (employee.hasOutstandingFines()) {
            throw new OutstandingFinesException(employee.getName());
        }
    }
}

// APPLICATION: orchestration, and only orchestration
public class LoanManager implements ManageLoans {

    private final MaterialRepository materials;
    private final LoanRepository loans;
    private final EmployeeRepository employees;
    private final LoanPolicy policy;
    private final NoticeSender sender;
    private final Clock clock;

    @Override
    public Loan lend(Isbn isbn, Long employeeId, int days) {
        Material material = materials.findByIsbn(isbn)
                .orElseThrow(() -> new MaterialNotFoundException(isbn));
        Employee employee = employees.findById(employeeId)
                .orElseThrow(() -> new EmployeeNotFoundException(employeeId));

        policy.checkCanBorrow(employee, loans.activeFor(employee));

        Loan loan = Loan.standard(material, employee, LocalDate.now(clock), days);
        loans.save(loan);
        sender.send(Notice.forLoan(loan));
        return loan;
    }
}

ISBN validation lives in the Isbn value object (it is impossible to build an invalid one). The queries live in the repositories. The email, in the adapter. The output format, in the CLI or the DTO. The auditing, in an aspect (11-02) or an @EntityListener.

Beware of the opposite extreme. One class per method is as bad as one class per system: you scatter the logic across twenty files and have to read all of them to understand one use case. The criterion is reasons to change, not number of lines.

  1. SOLID: open/closed (O)

An entity should be open to extension and closed to modification: you add new behaviour without touching existing code.

It is the theoretical justification for the polymorphism you studied in 03-06.

Before — every new material type forces you to touch the same switch, and to remember to touch the other four scattered around the project:

public int loanDays(Material material) {
    switch (material.getType()) {
        case BOOK:     return 15;
        case MAGAZINE: return 7;
        case DVD:      return 3;
        default: throw new IllegalStateException("Unknown type: " + material.getType());
    }
}

After — each type knows its own policy, and adding AudioBook means creating a class:

public abstract class Material {
    // Each subtype answers for itself; the rest of the system does not change.
    public abstract int defaultLoanDays();
    public abstract Money finePerDay();
}

public class Book extends Material {
    @Override public int defaultLoanDays() { return 15; }
    @Override public Money finePerDay() { return Money.euros("0.50"); }
}

public class Magazine extends Material {
    @Override public int defaultLoanDays() { return 7; }
    @Override public Money finePerDay() { return Money.euros("0.25"); }
}

public class Dvd extends Material {
    @Override public int defaultLoanDays() { return 3; }
    @Override public Money finePerDay() { return Money.euros("1.00"); }
}

When NOT to apply it. Do not abstract just in case. The switch version is perfectly reasonable if:

  • The cases are closed and are not going to grow (the days of the week, for instance).
  • The switch lives in one single place.
  • With sealed (10-06), the compiler forces you to cover every case, which removes the main risk.

The problem with the switch is not the switch: it is having the same switch in seven places and forgetting one.

  1. SOLID: Liskov substitution (L)

If S is a subtype of T, you must be able to replace T with S without the program ceasing to work correctly.

It is the principle most often violated without anyone noticing, because the compiler does not help: the violation is semantic.

An example of a violation in BiblioTech. Materials in the reference section are not lent out, they are only consulted on site. The temptation:

public class ReferenceMaterial extends Book {

    @Override
    public Loan lendTo(Employee employee, int days) {
        throw new UnsupportedOperationException("Reference materials are not lent out");
    }
}

It compiles perfectly. And it breaks all the code that works with Material:

// This method was correct. Now it blows up in production when Diego Alonso
// adds a reference material to the loan basket.
public void lendAll(List<Material> materials, Employee employee) {
    for (Material m : materials) {
        m.lendTo(employee, m.defaultLoanDays());          // UnsupportedOperationException
    }
}

The three classic symptoms of a Liskov violation:

Symptom Example
The subtype throws an exception where the supertype did not ReferenceMaterial.lendTo
The subtype strengthens the preconditions The parent accepts 1-30 days; the child only 1-7
The subtype weakens the postconditions The parent guarantees a non-null list; the child returns null
The client code needs instanceof in order to work if (m instanceof ReferenceMaterial) continue;

The correct solution: do not force the inheritance. Separate the capability from the hierarchy, which is exactly interface segregation:

/** Not every material is lendable. The capability is modelled apart from the type. */
public interface Lendable {
    Loan lendTo(Employee employee, int days);
    int defaultLoanDays();
}

public abstract class Material {                 // all of them: title, ISBN, location
    public abstract String getTitle();
    public abstract Isbn getIsbn();
}

public class Book extends Material implements Lendable { … }
public class ReferenceMaterial extends Material { }      // it simply is NOT Lendable

And now the signature tells the truth, and the compiler enforces it:

public void lendAll(List<Lendable> materials, Employee employee) { … }
// It is now IMPOSSIBLE to pass it a ReferenceMaterial.

Practical rule: if, while writing a subclass, you feel the need to throw UnsupportedOperationException or to empty out an inherited method, the relationship is not "is a". Reconsider the hierarchy.

  1. SOLID: interface segregation (I)

No client should be forced to depend on methods it does not use.

In 04-01 you created Lendable and Notifiable as separate interfaces, and now it has a name.

Before — the interface that knows everything:

public interface LibraryManagement {
    Loan lend(Isbn isbn, Long employeeId, int days);
    void returnItem(Long loanId);
    void reserve(Isbn isbn, Long employeeId);
    void sendNotice(Notice notice);
    MonthlyReport generateReport(YearMonth period);
    void importCatalog(Path file);
    void exportCatalog(Path destination);
}

Consequences: any implementation has to implement all seven methods, even if only two are of interest; in tests, a hand-written double needs seven empty methods; and changing the signature of generateReport recompiles everyone who only used lend.

After — small, client-oriented interfaces:

public interface Lendable {
    Loan lendTo(Employee employee, int days);
}

public interface Notifiable {
    String notificationTarget();
}

public interface ManageLoans {                  // the use case's inbound port
    Loan lend(Isbn isbn, Long employeeId, int days);
    void returnItem(Long loanId);
}

public interface QueryCatalog {
    List<Material> search(SearchCriteria criteria);
    Optional<Material> byIsbn(Isbn isbn);
}

A handy rule of thumb: interfaces are designed from the client, not from the implementation. The right question is not "what can this class do?", but "what does the caller need?".

Watch out for the extreme: one interface per method produces a swarm of types. If two methods are always used together, they belong together.

  1. SOLID: dependency inversion (D)

High-level modules should not depend on low-level ones. Both should depend on abstractions. Abstractions should not depend on details; details should depend on abstractions.

It is what has structured BiblioTech since 12-01, and what Spring has automated since 11-02.

Before:

public class LoanManager {
    // The high-level policy depends on a low-level detail.
    private final JpaLoanRepository repository = new JpaLoanRepository();
    private final HttpMetadataGateway metadata = new HttpMetadataGateway();
}

To test a loan calculation you need a database and a network. Switching to MongoDB means rewriting the class.

After:

public class LoanManager {
    private final LoanRepository repository;          // an abstraction defined in the domain
    private final MetadataGateway metadata;

    public LoanManager(LoanRepository repository, MetadataGateway metadata) {
        this.repository = repository;
        this.metadata = metadata;
    }
}

One nuance that hardly anybody explains and that is worth being clear about:

Concept What it is
Dependency inversion (DIP) The principle: depend on abstractions defined by the high-level module
Inversion of control (IoC) The general pattern: something external controls the flow (the framework calls your code)
Dependency injection (DI) The concrete technique: dependencies arrive from outside, through the constructor

Spring gives you IoC and DI. It does not give you the DIP: that depends on you defining the interface in the domain and not in the infrastructure. If LoanRepository lives in the persistence package and extends JpaRepository, you are using DI without DIP.

  1. DRY, KISS, YAGNI and the Law of Demeter

Principle Statement The nuance that matters
DRY (Don't Repeat Yourself) Every piece of knowledge must have a single representation It is about knowledge, not text. Two methods that are identical by coincidence are not duplication: coupling them creates a problem
KISS (Keep It Simple) Prefer the simplest solution that works Simple does not mean "little code": it means "easy to understand"
YAGNI (You Aren't Gonna Need It) Do not implement what you do not need today The cost is not writing it: it is maintaining it, testing it and confusing whoever reads it
Demeter Talk only to your immediate friends Long getter chains reveal that the logic is in the wrong place

The Law of Demeter, picking up from 03-07. A method of A should only call: methods of A itself, of its parameters, of objects it creates itself, and of its direct fields.

// VIOLATION: four levels deep. You know the whole internal structure.
String city = loan.getEmployee().getDepartment().getOffice().getAddress().getCity();

The problem is concrete: this line breaks if any of the four models changes, and it fails with a NullPointerException at any of the four points.

// CORRECT: each object exposes what it is asked, and hides how it knows.
String city = loan.employeeCity();

// in Loan
public String employeeCity() { return employee.city(); }
// in Employee
public String city() { return department.city(); }

An explicit exception: fluent APIs (Builder, Stream, AssertJ) chain on purpose, and do not violate Demeter, because each call returns the same object or one of the same type, rather than navigating somebody else's structure.

  1. Creational patterns: Singleton

Problem: guarantee that only one instance of a class exists and provide a global point of access to it.

The classic solution:

public class BiblioTechConfiguration {

    private static final BiblioTechConfiguration INSTANCE = new BiblioTechConfiguration();

    private BiblioTechConfiguration() { }          // nobody else can construct it

    public static BiblioTechConfiguration getInstance() { return INSTANCE; }
}

And the idiomatic Java variant, with an enum, which is additionally immune to serialisation and reflection:

public enum BiblioTechConfiguration {
    INSTANCE;

    private final int defaultDays = 15;
    public int defaultDays() { return defaultDays; }
}
classDiagram
    class Singleton {
        -static INSTANCE: Singleton
        -Singleton()
        +static getInstance() Singleton
    }
    class Client
    Client ..> Singleton : getInstance()

And now the important part: why the Spring bean does it better.

Aspect Classic Singleton Spring singleton bean
Uniqueness Per JVM ClassLoader Per container
How the client obtains it getInstance() inside the code Injected through the constructor
Visible dependency No: hidden in the method body Yes: it shows up in the constructor signature
Replaceable in tests No, not without dirty tricks Yes: you pass a different object
Configurable per environment No Yes: profiles, properties
Lifecycle Fixed @PostConstruct, @PreDestroy, scopes
Shared mutable state A hidden concurrency hazard The same hazard, but explicit

The underlying problem with the classic Singleton is that it is a global variable in disguise. BiblioTechConfiguration.getInstance() written inside FineCalculator creates a dependency that appears in no signature, that nobody can replace and that makes the class impossible to test with a different configuration.

Rule: in an application with a container, do not write Singletons. Declare a bean. If there is no container, use an enum and keep the state immutable.

  1. Creational patterns: Factory Method and Abstract Factory

Factory Method — problem: create objects without coupling the client to the concrete class, and without a constructor having to decide everything.

In BiblioTech, catalogue import receives records with a type field:

public final class MaterialFactory {

    private MaterialFactory() { }

    public static Material from(ImportRecord record) {
        return switch (record.type()) {
            case BOOK     -> new Book(record.title(), new Isbn(record.isbn()),
                                      record.author(), record.pages());
            case MAGAZINE -> new Magazine(record.title(), new Isbn(record.isbn()),
                                          record.issue(), record.frequency());
            case DVD      -> new Dvd(record.title(), new Isbn(record.isbn()),
                                     record.durationMinutes());
        };
    }
}

A very useful variant in modern Java is the static factory method on the class itself, which contributes something a constructor cannot: a name.

public class Loan {

    private Loan(...) { }       // private constructor: creation goes through the factories

    /** A standard loan, using the material's default number of days. */
    public static Loan standard(Material material, Employee employee, LocalDate today) {
        return new Loan(material, employee, today,
                        today.plusDays(material.defaultLoanDays()),
                        LoanStatus.ACTIVE);
    }

    /** A loan extended by management: up to 90 days. */
    public static Loan special(Material material, Employee employee, LocalDate today, int days) {
        if (days > 90) throw new IllegalArgumentException("90 days maximum");
        return new Loan(material, employee, today, today.plusDays(days), LoanStatus.ACTIVE);
    }
}

Advantages over a constructor: it has a name, it can return a subtype, it can return a cached instance and it can fail before building anything.

Abstract Factory — problem: create families of related objects without coupling to their concrete classes.

In BiblioTech it shows up when exporting reports in several formats, where each format needs a generator, a header formatter and an extension that are consistent with one another:

classDiagram
    class ExportFactory {
        <<interface>>
        +generator() ReportGenerator
        +tableFormatter() TableFormatter
        +extension() String
    }
    class PdfFactory
    class CsvFactory
    class JsonFactory
    ExportFactory <|.. PdfFactory
    ExportFactory <|.. CsvFactory
    ExportFactory <|.. JsonFactory
public interface ExportFactory {
    ReportGenerator generator();
    TableFormatter tableFormatter();
    String extension();

    static ExportFactory forFormat(Format format) {
        return switch (format) {
            case PDF  -> new PdfFactory();
            case CSV  -> new CsvFactory();
            case JSON -> new JsonFactory();
        };
    }
}

When NOT to use them. If there is only one implementation and no more are expected, the factory is a layer of indirection with no advantage whatsoever. new Book(...) is perfectly fine.

  1. Creational patterns: Builder

A debt since 03-04, and now it gets paid.

Problem: an object with many parameters, several of them optional. The telescoping constructor is unreadable and dangerous:

// What does the 15 mean? And the true? And the second null?
Loan l = new Loan(material, employee, LocalDate.now(), 15, true, null, false, null, "Marta Ruiz");

The real danger: if two consecutive parameters have the same type, swapping them compiles and produces a silent error in production.

The solution:

public class Loan {

    private final Material material;
    private final Employee employee;
    private final LocalDate loanDate;
    private final LocalDate dueDate;
    private final boolean renewable;
    private final String notes;
    private final Employee authorisedBy;
    private final LoanStatus status;

    private Loan(Builder b) {
        this.material = b.material;
        this.employee = b.employee;
        this.loanDate = b.loanDate;
        this.dueDate = b.dueDate;
        this.renewable = b.renewable;
        this.notes = b.notes;
        this.authorisedBy = b.authorisedBy;
        this.status = LoanStatus.ACTIVE;
    }

    public static Builder of(Material material, Employee employee) {
        return new Builder(material, employee);      // mandatory ones in the builder's factory
    }

    public static class Builder {
        // Mandatory: final, required in the builder's constructor
        private final Material material;
        private final Employee employee;
        // Optional: with a sensible default
        private LocalDate loanDate = LocalDate.now();
        private LocalDate dueDate;
        private boolean renewable = true;
        private String notes = "";
        private Employee authorisedBy;

        private Builder(Material material, Employee employee) {
            this.material = Objects.requireNonNull(material, "material");
            this.employee = Objects.requireNonNull(employee, "employee");
        }

        public Builder from(LocalDate date)         { this.loanDate = date; return this; }
        public Builder forDays(int days)            { this.dueDate = loanDate.plusDays(days); return this; }
        public Builder until(LocalDate date)        { this.dueDate = date; return this; }
        public Builder notRenewable()               { this.renewable = false; return this; }
        public Builder withNotes(String n)          { this.notes = n; return this; }
        public Builder authorisedBy(Employee e)     { this.authorisedBy = e; return this; }

        public Loan build() {
            // The consistency validation happens ONCE, here:
            // that way the constructed object is always valid.
            if (dueDate == null) {
                dueDate = loanDate.plusDays(material.defaultLoanDays());
            }
            if (dueDate.isBefore(loanDate)) {
                throw new IllegalStateException("The due date cannot precede the loan date");
            }
            if (!material.isLendable() && authorisedBy == null) {
                throw new IllegalStateException("A restricted material requires authorisation");
            }
            return new Loan(this);
        }
    }
}

Usage, which now reads like a sentence:

Loan l = Loan.of(effectiveJava, martaRuiz)
        .from(LocalDate.of(2026, 3, 1))
        .forDays(30)
        .notRenewable()
        .withNotes("For the internal Java course")
        .authorisedBy(nuriaVidal)
        .build();
classDiagram
    class Loan {
        -Loan(Builder)
        +static of(Material, Employee) Builder
    }
    class Builder {
        -material
        -employee
        -dueDate
        +from(LocalDate) Builder
        +forDays(int) Builder
        +notRenewable() Builder
        +build() Loan
    }
    Loan +.. Builder : static nested class
    Builder ..> Loan : builds

Contrast with the modern Java alternatives:

Alternative Advantages Drawbacks When
Constructor Direct, no extra code Unreadable beyond 4 parameters; dangerous with repeated types Few parameters, all mandatory
record One line; immutable; free equals/hashCode/toString No optionals; no step-by-step validation; every field in the canonical constructor DTOs and value objects
Hand-written builder Readable; validation in one place; optionals come naturally ~40 lines per class; duplicates the fields Complex objects, with consistency rules
Lombok's @Builder Zero boilerplate Magic; generates implicit setters on the builder; dangerous on JPA entities (11-07) When the team already uses Lombok sensibly

A very useful combination: record + builder for a value object with many optional fields.

public record SearchCriteria(String title, String author, MaterialType type,
                             Integer yearFrom, Integer yearTo, Boolean onlyAvailable) {

    public static Builder builder() { return new Builder(); }

    public static class Builder {
        private String title; private String author; private MaterialType type;
        private Integer yearFrom; private Integer yearTo;
        private Boolean onlyAvailable = Boolean.FALSE;

        public Builder title(String t) { this.title = t; return this; }
        public Builder author(String a) { this.author = a; return this; }
        public Builder type(MaterialType t) { this.type = t; return this; }
        public Builder between(int from, int to) { this.yearFrom = from; this.yearTo = to; return this; }
        public Builder onlyAvailable() { this.onlyAvailable = true; return this; }

        public SearchCriteria build() {
            return new SearchCriteria(title, author, type, yearFrom, yearTo, onlyAvailable);
        }
    }
}

When NOT to use a Builder: with three mandatory parameters and no optional ones, the builder is forty lines that buy you nothing. And never on JPA entities: Hibernate needs a no-argument constructor and setters for the managed fields.

  1. Creational patterns: Prototype

Problem: create a new object by copying an existing one, when building it from scratch is expensive or its configuration is complex.

In BiblioTech it appears only marginally: duplicating a SearchCriteria saved by Nuria Vidal in order to change one field. In modern Java this is better solved with "wither" methods on a record:

public record SearchCriteria(String title, String author, MaterialType type, …) {

    public SearchCriteria withType(MaterialType newType) {
        return new SearchCriteria(title, author, newType, yearFrom, yearTo, onlyAvailable);
    }
}

Java's Cloneable is the classic implementation of the pattern, and it is all but discouraged: clone() is shallow, it does not call constructors, it interacts badly with final and its contract is poorly specified. Do not use it. Copy with a copy constructor or with methods like the one above.

  1. Structural patterns: Adapter

Problem: an existing class has the functionality you need, but with an interface incompatible with the one your system expects.

That is exactly the situation of MetadataClient (module 9, rewritten in 11-07): it speaks HTTP and JSON, it returns DTOs from an external API, it throws IOException and InterruptedException, and the domain must know nothing about any of that.

classDiagram
    class MetadataGateway {
        <<interface>>
        +findByIsbn(Isbn) Optional~MaterialMetadata~
    }
    class HttpMetadataGateway {
        -MetadataClient client
        +findByIsbn(Isbn) Optional~MaterialMetadata~
    }
    class MetadataClient {
        +queryByIsbn(String) ApiResponseDto
    }
    MetadataGateway <|.. HttpMetadataGateway
    HttpMetadataGateway --> MetadataClient : delegates

The port, in the domain, expressed in the domain's language:

package com.nexussoftware.bibliotech.domain.catalog.port;

public interface MetadataGateway {
    /** Empty if there is no metadata or if the service is unavailable. */
    Optional<MaterialMetadata> findByIsbn(Isbn isbn);
}

The adapter, in infrastructure:

package com.nexussoftware.bibliotech.infrastructure.metadata;

@Component
class HttpMetadataGateway implements MetadataGateway {

    private static final Logger log = LoggerFactory.getLogger(HttpMetadataGateway.class);

    private final MetadataClient client;      // the "adaptee": the class with the incompatible interface

    HttpMetadataGateway(MetadataClient client) { this.client = client; }

    @Override
    public Optional<MaterialMetadata> findByIsbn(Isbn isbn) {
        try {
            ApiResponseDto dto = client.queryByIsbn(isbn.value());          // external format

            // 1. Translates the external model into the domain's
            // 2. Translates the exceptions (error boundary, 06-07)
            // 3. Shields the domain from the existence of HTTP, JSON and this particular API
            return Optional.of(new MaterialMetadata(
                    dto.title(),
                    dto.authors() == null ? List.of() : dto.authors(),
                    dto.publishedDate() == null ? null : Year.parse(dto.publishedDate().substring(0, 4)),
                    dto.pageCount()));

        } catch (ResourceNotFoundException e) {
            return Optional.empty();                     // "no data" is not an error
        } catch (IOException | InterruptedException e) {
            if (e instanceof InterruptedException) Thread.currentThread().interrupt();
            log.warn("Metadata unavailable for {}: {}", isbn, e.getMessage());
            return Optional.empty();                     // graceful degradation
        }
    }
}

Notice the three translations a well-built adapter performs: of types, of exceptions and of semantics (an external 404 becomes Optional.empty(), not an error).

When NOT to use it: if you control the source class and can change its interface, change it. The adapter is for what you cannot touch: third-party libraries, external APIs, legacy code.

  1. Structural patterns: Decorator

A debt since 07-03, and here it gets paid.

Problem: add responsibilities to an object dynamically, without modifying its class or creating a subclass for every combination.

The canonical case: Java's I/O streams. Module 7 used it without naming it:

// Each wrapper ADDS a capability to the previous one, and all of them are InputStream.
InputStream base       = Files.newInputStream(Path.of("catalog.csv"));   // raw bytes
InputStream buffered   = new BufferedInputStream(base);                  // + buffering
InputStream unzipped   = new GZIPInputStream(buffered);                  // + decompression
Reader      reader     = new InputStreamReader(unzipped, UTF_8);         // + character encoding
BufferedReader lines   = new BufferedReader(reader);                     // + readLine()

Without the pattern you would need classes like BufferedGzipUtf8InputStream: one per combination. With it, four classes give you sixteen combinations.

BiblioTech's case: caching the catalogue without touching the repository.

classDiagram
    class MaterialRepository {
        <<interface>>
        +findByIsbn(Isbn) Optional~Material~
        +search(SearchCriteria) List~Material~
    }
    class JpaMaterialRepository
    class CachingCatalog {
        -MaterialRepository delegate
        -Map cache
    }
    class MeteredCatalog {
        -MaterialRepository delegate
    }
    MaterialRepository <|.. JpaMaterialRepository
    MaterialRepository <|.. CachingCatalog
    MaterialRepository <|.. MeteredCatalog
    CachingCatalog --> MaterialRepository : wraps
    MeteredCatalog --> MaterialRepository : wraps
/**
 * Decorator: it IS a MaterialRepository and it HAS a MaterialRepository.
 * That double relationship is the pattern's signature.
 */
public class CachingCatalog implements MaterialRepository {

    private static final Logger log = LoggerFactory.getLogger(CachingCatalog.class);

    private final MaterialRepository delegate;
    private final Map<Isbn, Material> cache = new ConcurrentHashMap<>();   // module 8

    public CachingCatalog(MaterialRepository delegate) {
        this.delegate = delegate;
    }

    @Override
    public Optional<Material> findByIsbn(Isbn isbn) {
        Material cached = cache.get(isbn);
        if (cached != null) {
            log.debug("Cache hit for {}", isbn);
            return Optional.of(cached);
        }
        Optional<Material> result = delegate.findByIsbn(isbn);
        result.ifPresent(m -> cache.put(isbn, m));
        return result;
    }

    @Override
    public List<Material> search(SearchCriteria criteria) {
        return delegate.search(criteria);      // searches are not cached: they vary too much
    }

    @Override
    public Material save(Material material) {
        cache.remove(material.getIsbn());      // invalidation: the hardest part of a cache
        return delegate.save(material);
    }
}

And stackable decorators, just like the streams:

@Bean
MaterialRepository materialRepository(JpaMaterialRepository jpa, MeterRegistry registry) {
    return new MeteredCatalog(               // 3rd: measures
             new CachingCatalog(             // 2nd: caches
               new RetryingCatalog(jpa, 3)));        // 1st: retries
}

Nobody using MaterialRepository knows there are three layers. And the order matters: in this assembly, the metrics measure the time with the cache; if you wanted to measure only the database, the metrics decorator would go on the inside.

Pattern Same interface as what it wraps Intent
Decorator Yes Add behaviour
Adapter No (that is the whole point) Change the interface
Proxy Yes Control access

Decorator and Proxy are structurally identical: they are told apart by intent.

When NOT to use it: if there is only one decoration and there never will be more, an if in the implementation is simpler. And if you need five layers, debugging becomes painful: the call stack fills up with wrappers.

  1. Structural patterns: Proxy

Problem: control access to an object — to delay its creation, check permissions, log calls, retry or manage a transaction.

You already wrote dynamic proxies in 10-03. Now they have a name and a context.

// Dynamic proxy from module 10-03: logs the time taken by each call
@SuppressWarnings("unchecked")
public static <T> T withTiming(T target, Class<T> iface) {
    return (T) Proxy.newProxyInstance(
        iface.getClassLoader(),
        new Class<?>[]{iface},
        (proxy, method, args) -> {
            long start = System.nanoTime();
            try {
                return method.invoke(target, args);
            } finally {
                long ms = (System.nanoTime() - start) / 1_000_000;
                LoggerFactory.getLogger(iface).debug("{} took {} ms", method.getName(), ms);
            }
        });
}

And here a circle from module 11 closes. When you write this:

@Service
public class LoanManager {
    @Transactional
    public Loan lend(Isbn isbn, Long employeeId, int days) { … }
}

Spring does not inject a LoanManager. It injects a proxy of LoanManager that, on every call to lend, opens a transaction, calls the real method, and commits or rolls back. It is exactly the mechanism above.

sequenceDiagram
    participant C as LoanController
    participant P as Transactional proxy
    participant TM as PlatformTransactionManager
    participant S as the real LoanManager

    C->>P: lend(isbn, id, 15)
    P->>TM: getTransaction()
    P->>S: lend(isbn, id, 15)
    S-->>P: Loan
    P->>TM: commit()
    P-->>C: Loan
    Note over P,TM: if S throws a RuntimeException, the proxy rolls back

This explains once and for all the two most famous oddities of @Transactional:

  1. Self-invocation does not work. If methodA() calls this.methodB() and only methodB is @Transactional, there is no transaction: the this. call does not go through the proxy.
  2. private and final methods are not intercepted. The proxy is a generated subclass (CGLIB) or an implementer of the interface (JDK): it cannot override what is neither visible nor overridable.

Types of proxy, all of them present in BiblioTech:

Type What for Where it is in BiblioTech
Virtual Delay the creation of something expensive Hibernate's LAZY associations (11-03)
Protection Check permissions Spring Security's @PreAuthorize (12-07)
Remote Represent an object on another machine A declarative HTTP client
Smart Add cross-cutting logic @Transactional, @Cacheable, TimingAspect

  1. Structural patterns: Facade and Composite

Facade — problem: a subsystem has many classes and the client only needs a few operations. The facade offers a simple interface over a complex set.

BiblioTech's application services are facades: LoanManager.lend(...) hides repositories, policy, clock and notice sender. So was SLF4J in 11-07: a facade over logging implementations.

A warning: a facade easily degenerates into a God Object (we will see it in the anti-patterns) if it accumulates operations without criteria. One facade per area, not one for the whole application.

Composite — problem: treat individual objects and compositions of objects uniformly.

In BiblioTech it shows up in collections of materials: a "thematic collection" (for example, "Software Design Fundamentals") groups materials and other collections, and its availability must be queryable as if it were just another material.

public interface CatalogItem {
    String title();
    int availableCopies();
}

public class Book implements CatalogItem { … }                    // leaf

public class ThematicCollection implements CatalogItem {          // composite
    private final String title;
    private final List<CatalogItem> items;

    @Override
    public int availableCopies() {
        // Available if ALL of its items are: the minimum rules
        return items.stream().mapToInt(CatalogItem::availableCopies).min().orElse(0);
    }
}

  1. Behavioural patterns: Strategy

Problem: several variants of an algorithm, selectable at runtime, without filling the code with conditionals.

You have had it since module 4: RateRule and MaterialFilter were strategies.

classDiagram
    class RateRule {
        <<interface>>
        +calculate(int daysLate, Material m) Money
    }
    class StandardRate
    class NewEmployeeReducedRate
    class CriticalMaterialIncreasedRate
    class FineCalculator {
        -RateRule rule
        +calculate(Loan, LocalDate) Money
    }
    RateRule <|.. StandardRate
    RateRule <|.. NewEmployeeReducedRate
    RateRule <|.. CriticalMaterialIncreasedRate
    FineCalculator --> RateRule : delegates

Before:

public Money calculateFine(Loan l, LocalDate today) {
    int daysLate = (int) ChronoUnit.DAYS.between(l.getDueDate(), today);
    if (daysLate <= 0) return Money.ZERO;

    if (l.getEmployee().tenureInMonths() < 6) {
        return Money.euros("0.25").times(daysLate);
    } else if (l.getMaterial().isCritical()) {
        return Money.euros("2.00").times(daysLate);
    } else if (l.getEmployee().isManagement()) {
        return Money.ZERO;
    } else {
        return Money.euros("0.50").times(daysLate);
    }
}

Every new policy is one more branch in the same method, which then has to be tested all over again.

After:

@FunctionalInterface
public interface RateRule {
    Money calculate(int daysLate, Loan loan);

    default boolean appliesTo(Loan loan) { return true; }
}

public class StandardRate implements RateRule {
    @Override public Money calculate(int days, Loan l) {
        return l.getMaterial().finePerDay().times(days).cappedAt(Money.euros("20.00"));
    }
}

public class NewEmployeeReducedRate implements RateRule {
    @Override public boolean appliesTo(Loan l) { return l.getEmployee().tenureInMonths() < 6; }
    @Override public Money calculate(int days, Loan l) { return Money.euros("0.25").times(days); }
}

public class ManagementExemptRate implements RateRule {
    @Override public boolean appliesTo(Loan l) { return l.getEmployee().isManagement(); }
    @Override public Money calculate(int days, Loan l) { return Money.ZERO; }
}

public class FineCalculator {
    private final List<RateRule> rules;          // ordered: the first that applies wins
    private final RateRule defaultRule = new StandardRate();

    public FineCalculator(List<RateRule> rules) { this.rules = List.copyOf(rules); }

    public Money calculate(Loan l, LocalDate today) {
        int daysLate = (int) ChronoUnit.DAYS.between(l.getDueDate(), today);
        if (daysLate <= 0) return Money.ZERO;

        return rules.stream()
                .filter(r -> r.appliesTo(l))
                .findFirst()
                .orElse(defaultRule)
                .calculate(daysLate, l);
    }
}

And with Spring, the pattern becomes almost invisible. If each rule is a bean, Spring injects the whole list ordered by @Order, and adding a policy means creating a class: zero changes anywhere else in the system.

@Component @Order(10) class ManagementExemptRate implements RateRule { … }
@Component @Order(20) class NewEmployeeReducedRate implements RateRule { … }

@Service
class FineCalculator {
    private final List<RateRule> rules;
    FineCalculator(List<RateRule> rules) { this.rules = rules; }             // Spring injects all
}

In Java 8+, a stateless strategy is simply a lambda or a method reference: Comparator (module 5), Predicate and Function are all strategies.

When NOT to use it: with two stable cases (active/returned), an if is clearer. The pattern pays off when the variants grow or come from configuration.

  1. Behavioural patterns: Template Method

A debt since 04-02.

Problem: several algorithms share the same structure but differ in a few steps.

In BiblioTech: importing the catalogue from CSV, from JSON or from the metadata API. The skeleton is identical —open, validate the header, read records, convert, save, report—; what changes are three steps.

classDiagram
    class CatalogImporter {
        <<abstract>>
        +importFrom(Path) ImportResult
        #open(Path)* Source
        #readRecords(Source)* List~ImportRecord~
        #isValid(ImportRecord) boolean
    }
    class CsvImporter
    class JsonImporter
    class ApiImporter
    CatalogImporter <|-- CsvImporter
    CatalogImporter <|-- JsonImporter
    CatalogImporter <|-- ApiImporter
public abstract class CatalogImporter {

    private static final Logger log = LoggerFactory.getLogger(CatalogImporter.class);

    private final MaterialRepository repository;

    protected CatalogImporter(MaterialRepository repository) {
        this.repository = repository;
    }

    /**
     * TEMPLATE METHOD: it defines the algorithm and is final so that nobody
     * can alter the sequence. Only the steps are replaceable.
     */
    public final ImportResult importFrom(Path source) {
        log.info("Starting import from {}", source);
        long start = System.nanoTime();

        int successful = 0;
        List<ImportError> errors = new ArrayList<>();

        try (Source s = open(source)) {                         // ABSTRACT STEP
            for (ImportRecord record : readRecords(s)) {              // ABSTRACT STEP
                try {
                    if (!isValid(record)) {                    // HOOK with a default implementation
                        errors.add(ImportError.invalid(record));
                        continue;
                    }
                    repository.save(MaterialFactory.from(record));
                    successful++;
                } catch (Exception e) {
                    errors.add(ImportError.of(record, e));
                }
            }
        } catch (IOException e) {
            throw new ImportFailedException(source, e);
        }

        long ms = (System.nanoTime() - start) / 1_000_000;
        onFinish(successful, errors);                          // optional HOOK
        log.info("Import finished: {} successful, {} errors, {} ms",
                 successful, errors.size(), ms);
        return new ImportResult(successful, errors, Duration.ofMillis(ms));
    }

    // --- Steps every subclass MUST provide ---
    protected abstract Source open(Path path) throws IOException;
    protected abstract List<ImportRecord> readRecords(Source source) throws IOException;

    // --- Hooks: default behaviour, replaceable ---
    protected boolean isValid(ImportRecord r) {
        return r.isbn() != null && r.title() != null && !r.title().isBlank();
    }

    protected void onFinish(int successful, List<ImportError> errors) { }
}

A subclass ends up minimal:

public class CsvImporter extends CatalogImporter {

    private final char separator;

    @Override
    protected Source open(Path path) throws IOException {
        return new ReaderSource(Files.newBufferedReader(path, StandardCharsets.UTF_8));
    }

    @Override
    protected List<ImportRecord> readRecords(Source source) throws IOException {
        try (CSVReader csv = new CSVReaderBuilder(source.reader())
                .withSkipLines(1)
                .withCSVParser(new CSVParserBuilder().withSeparator(separator).build())
                .build()) {
            return csv.readAll().stream().map(this::toRecord).toList();
        }
    }
}

Template Method versus Strategy — they solve similar problems with opposite mechanisms:

Aspect Template Method Strategy
Mechanism Inheritance Composition
What is fixed The complete algorithm, in the base class Nothing: it is replaced wholesale
When the choice is made Compile time (which subclass you instantiate) Runtime (which object you inject)
Combinations One hierarchy per variation They combine freely
Risk Deep, brittle hierarchies More objects to coordinate

The modern preference: composition over inheritance. Use Template Method when the skeleton is genuinely fixed and the steps are many.

  1. Behavioural patterns: Observer

Problem: several objects must react to an event, and whatever produces it should not know about them.

In BiblioTech, when a material is returned several mutually independent things must happen: check pending reservations, update statistics, notify the employee, record the audit entry. Without the pattern, LoanManager.returnItem() ends up calling five services and growing with every new requirement.

The classic version:

public interface ReturnListener {
    void onReturn(ReturnEvent event);
}

public class LoanManager {
    private final List<ReturnListener> listeners = new CopyOnWriteArrayList<>();  // module 8

    public void register(ReturnListener listener) { listeners.add(listener); }

    public void returnItem(Long id) {
        Loan l = …;
        l.registerReturn(LocalDate.now(clock));
        loans.save(l);

        ReturnEvent event = new ReturnEvent(l.getId(), l.getIsbn(), …);
        for (ReturnListener listener : listeners) {
            try {
                listener.onReturn(event);
            } catch (Exception e) {
                // A listener that fails must NOT stop the others from running
                log.error("Listener {} failed", listener.getClass().getSimpleName(), e);
            }
        }
    }
}

That try/catch inside the loop is the part almost everybody forgets, and it is what makes the difference between a useful pattern and a cascading failure.

The modern version: Spring application events. The same pattern, without writing the infrastructure:

// The event: an immutable record
public record MaterialReturned(Long loanId, Isbn isbn, Long employeeId,
                               LocalDate date, Money fine) { }

// The publisher: it knows no listener
@Service
public class LoanManager {

    private final ApplicationEventPublisher events;

    @Transactional
    public void returnItem(Long id) {
        Loan l = loans.findById(id).orElseThrow(…);
        Money fine = l.registerReturn(LocalDate.now(clock));
        loans.save(l);

        events.publishEvent(new MaterialReturned(l.getId(), l.getIsbn(),
                                                 l.getEmployeeId(), LocalDate.now(clock), fine));
    }
}

// The listeners: independent, adding one does not touch the publisher
@Component
class ReservationActivator {
    @EventListener
    void onReturn(MaterialReturned event) {
        reservationProcessor.activateNextReservation(event.isbn());
    }
}

@Component
class StatisticsUpdater {
    // Only if the transaction COMMITS: we do not want to count returns that were rolled back
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    void onReturn(MaterialReturned event) {
        statistics.recordReturn(event);
    }
}

@Component
class FineNotifier {
    @Async                                     // on another thread: does not block the response
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    void onReturn(MaterialReturned event) {
        if (event.fine().isPositive()) {
            sender.send(Notice.forFine(event));
        }
    }
}

@TransactionalEventListener(AFTER_COMMIT) deserves attention: it guarantees the reaction only happens if the data was genuinely saved. It is the difference between notifying a real fine and notifying one that was rolled back by a later error.

When NOT to use it. Events make the flow hard to follow: reading returnItem() you cannot tell what will happen next. If there is only one listener and there always will be, call it directly. And java.util.Observer/Observable have been deprecated since Java 9: do not use them.

  1. Behavioural patterns: State

Problem: an object changes behaviour depending on its internal state, and the code fills up with conditionals about that state.

BiblioTech has had LoanStatus since module 4. With the pattern, each state knows which transitions it allows:

stateDiagram-v2
    [*] --> ACTIVE : create
    ACTIVE --> RENEWED : renew
    RENEWED --> RETURNED : return
    ACTIVE --> RETURNED : return
    ACTIVE --> OVERDUE : date passes
    RENEWED --> OVERDUE : date passes
    OVERDUE --> RETURNED : return with fine
    RETURNED --> [*]
    OVERDUE --> LOST : 90 days unreturned
    LOST --> [*]

In Java, an enum with abstract methods is the cleanest implementation of the pattern (04-07):

public enum LoanStatus {

    ACTIVE {
        @Override public boolean allowsRenewal() { return true; }
        @Override public boolean allowsReturn()  { return true; }
        @Override public LoanStatus onRenew()    { return RENEWED; }
    },
    RENEWED {
        @Override public boolean allowsRenewal() { return false; }      // only one renewal
        @Override public boolean allowsReturn()  { return true; }
    },
    OVERDUE {
        @Override public boolean allowsRenewal() { return false; }
        @Override public boolean allowsReturn()  { return true; }
        @Override public boolean generatesFine() { return true; }
    },
    RETURNED {
        @Override public boolean isFinal() { return true; }
    },
    LOST {
        @Override public boolean isFinal() { return true; }
        @Override public boolean generatesFine() { return true; }
    };

    public boolean allowsRenewal() { return false; }
    public boolean allowsReturn()  { return false; }
    public boolean generatesFine() { return false; }
    public boolean isFinal()       { return false; }

    public LoanStatus onRenew() {
        throw new InvalidTransitionException(this, "renew");
    }
}

And the entity just asks questions, with no switch at all:

public void renew(int days) {
    if (!status.allowsRenewal()) {
        throw new InvalidTransitionException(status, "renew");
    }
    this.dueDate = dueDate.plusDays(days);
    this.status = status.onRenew();
}

The decisive advantage: the state machine lives in one single place. Without the pattern, the rule "it can only be renewed once" shows up in the service, in the controller and in the CLI, and sooner or later one of the three is forgotten.

  1. Behavioural patterns: Command

Problem: encapsulate a request as an object, so that it can be parameterised, queued, logged or undone.

In 05-08 you built an undo stack with Deque. That was the Command pattern.

public interface Command {
    void execute();
    void undo();
    String description();
}

public class LendCommand implements Command {

    private final ManageLoans manager;
    private final Isbn isbn;
    private final Long employeeId;
    private Long createdLoanId;         // the state needed in order to undo

    @Override
    public void execute() {
        this.createdLoanId = manager.lend(isbn, employeeId, 15).getId();
    }

    @Override
    public void undo() {
        if (createdLoanId == null) throw new IllegalStateException("Not executed");
        manager.cancel(createdLoanId);
    }

    @Override
    public String description() { return "Lend " + isbn + " to employee " + employeeId; }
}

public class OperationHistory {
    private final Deque<Command> stack = new ArrayDeque<>();     // 05-07/05-08

    public void execute(Command c) {
        c.execute();
        stack.push(c);
    }

    public void undoLast() {
        if (stack.isEmpty()) throw new NothingToUndoException();
        stack.pop().undo();
    }
}

Other uses of the same pattern, all present in the ecosystem: the Runnable and Callable objects you submitted to an ExecutorService in module 8 are commands, and so are tasks queued for deferred processing.

  1. Behavioural patterns: Chain of Responsibility and Iterator

Chain of Responsibility — problem: several handlers can serve a request; it is passed along the chain until one of them processes it.

The canonical example is in Spring itself: Spring Security's filter chain (12-07) and servlet Filters. In BiblioTech, validating an import:

public interface ImportValidator {
    Optional<ImportError> validate(ImportRecord r);
}

@Service
class ValidationChain {
    private final List<ImportValidator> validators;         // Spring injects all, ordered

    public Optional<ImportError> validate(ImportRecord r) {
        return validators.stream()
                .map(v -> v.validate(r))
                .flatMap(Optional::stream)
                .findFirst();               // the first error cuts the chain short
    }
}

Iterator — problem: traverse a collection without exposing its internal structure.

It has been built into the language since Java 5: any Iterable works with for-each (picking up from 05-02). You rarely have to implement it, but when you do —traversing an API's paged results as if they were a list— it is very useful:

public class PagedCatalog implements Iterable<Material> {

    private final MaterialRepository repository;
    private final int pageSize;

    @Override
    public Iterator<Material> iterator() {
        return new Iterator<>() {
            private int page = 0;
            private Iterator<Material> current = Collections.emptyIterator();

            @Override public boolean hasNext() {
                if (current.hasNext()) return true;
                List<Material> next = repository.page(page++, pageSize);
                current = next.iterator();
                return current.hasNext();
            }

            @Override public Material next() {
                if (!hasNext()) throw new NoSuchElementException();
                return current.next();
            }
        };
    }
}
// The client walks 50,000 materials without loading them all into memory
for (Material m : new PagedCatalog(repository, 500)) { … }

  1. Architectural and enterprise patterns

Besides the GoF ones, there are higher-level patterns that BiblioTech already uses, most of them catalogued by Martin Fowler:

Pattern What it solves Where it is in BiblioTech
Repository Abstract data access as if it were an in-memory collection LoanRepository and the Spring Data repositories (11-03)
Unit of Work Group changes and apply them in a single transaction JPA's EntityManager with @Transactional
DTO Carry data between layers or processes The records from 12-01 and 12-04
Application Service Orchestrate use cases with no domain logic LoanManager, ReservationProcessor
Dependency Injection Supply collaborators from outside Constructors + Spring (11-02)
Value Object Model concepts with no identity Isbn, Money
Specification Compose query criteria SearchCriteria, Spring Data's Specification
Domain Model Put the logic in objects with state and behaviour Loan.registerReturn()

On the Unit of Work, it is worth seeing what JPA does underneath, because it explains why you do not call save() after modifying an entity:

@Transactional
public void renew(Long id, int days) {
    Loan l = loans.findById(id).orElseThrow(…);
    l.renew(days);
    // There is NO need for loans.save(l):
    // the persistence context (the unit of work) tracks the entity
    // and issues the UPDATE on flush, before the commit.
}

  1. Anti-patterns

God Object. A class that knows and does everything. Symptoms: over 500 lines, over 15 dependencies, a generic name (GeneralManager, BiblioTechUtil, MainService), and everybody modifying it every sprint. It is the purest violation of single responsibility. It is cured by extracting by reason to change, not by number of lines.

Singleton as a global variable. Already covered: it creates hidden dependencies, prevents testing and conceals shared mutable state. The modern version of the same sin is the utility class with static state:

public final class GlobalConfig {
    public static int loanDays = 15;        // anybody can change it, from any thread
}

Inheritance of convenience. Inheriting in order to reuse code, with no "is a" relationship:

// WRONG: a loan manager IS NOT a list of loans.
public class LoanManager extends ArrayList<Loan> { … }

Consequence: anyone can call clear() and wipe out every loan. The rule is composition over inheritance: if the relationship is not "is a", use a field.

Anaemic Domain Model — and here we have to be honest, because it affects BiblioTech.

An anaemic model is one in which the entities are just data with getters and setters, and all the logic lives in services. Fowler called it an anti-pattern because it wastes object orientation: it separates data and behaviour, which is precisely what OOP puts together.

// ANAEMIC: Loan is a bag of data
public class Loan {
    private LocalDate dueDate;
    private LoanStatus status;
    // 15 getters and 15 setters, zero behaviour
}

@Service
public class LoanManager {
    public void returnItem(Loan l, LocalDate today) {
        // The business rule lives OUTSIDE the object it protects
        if (l.getStatus() == LoanStatus.RETURNED) throw new …;
        l.setReturnDate(today);
        l.setStatus(LoanStatus.RETURNED);
    }
}

The practical problem: nothing stops another service calling setStatus(RETURNED) without setting the date, leaving the object in an invalid state. Invariants cannot be guaranteed if anybody can mutate the fields.

// RICH: the object protects its own rules
public class Loan {
    public Money registerReturn(LocalDate date) {
        if (status.isFinal()) throw new LoanAlreadyClosedException(id);
        if (date.isBefore(loanDate)) throw new InvalidDateException(date);
        this.returnDate = date;
        this.status = LoanStatus.RETURNED;
        return fineUpTo(date);
    }
    // no public setters: it is IMPOSSIBLE to leave the object half-done
}

The honest debate. BiblioTech has logic in its entities (Loan.registerReturn(), Material.defaultLoanDays()), and that is deliberate. But there are legitimate arguments on the other side:

In favour of the rich model In favour of the anaemic model
Invariants are guaranteed in one single place JPA entities have a managed lifecycle: complex logic inside complicates flushing and lazy associations
The logic sits where the data is Logic that needs several aggregates or external services does not fit in an entity
It is tested without infrastructure It is what most teams know; it reads quickly
Fewer bland services Separating data from process fits transactions and functional styles better

A defensible position, and the one BiblioTech takes: reasonably rich entities —the entity's own invariants live inside it—, and application services for whatever involves several aggregates, transactions or infrastructure. Loan knows it cannot be returned twice; LoanManager knows the material has to be found, the employee's limit checked and a notice sent. Neither of those things is in the wrong place.

What is not defensible is an entity with 30 public setters and all the logic in an 800-line service.

  1. Final table: problem → candidate pattern

The problem you are having Candidate pattern
A constructor with 6 parameters, half of them optional Builder
A switch over a type, repeated in several places Polymorphism (open/closed), Strategy
I need several interchangeable calculation policies Strategy
An external library has an interface that does not fit Adapter
I want to add caching or metrics without touching the class Decorator
I want to control access, delay loading or intercept calls Proxy
Several processes share the skeleton and differ in steps Template Method
When something happens, several independent things must react Observer / application events
An object behaves differently depending on its state and there are ifs everywhere State (enum with methods)
I need to undo, queue or log operations Command
Several handlers can serve a request Chain of Responsibility
I want to traverse something complex as if it were a list Iterator
I need a single shared instance A Spring bean (not a classic Singleton)
Create objects without coupling to the concrete class Factory Method
Create coherent families of objects Abstract Factory
Data access is polluting the business logic Repository
A complex subsystem with an awkward interface Facade
Treat an element and a group of elements the same way Composite
Database entities travel out to the API DTO
I cannot test a class without a database or a network Dependency Inversion + Injection

Common Mistakes and Tips

1. Applying a pattern because you have just learned it. The hammer syndrome. If by the end of this lesson your next class has an abstract factory producing decorated strategies, stop. The pattern should arrive as the answer to a concrete pain, not as decoration.

2. Confusing the name with the structure. Calling a class LoanFactory when it creates nothing, or RepositoryImpl when it is a facade. Pattern names are promises: if the name says Decorator, whoever reads it will expect it to implement the same interface it wraps.

3. Starting with the patterns rather than the principles. A project with fifteen badly placed patterns is worse than one with none and SOLID respected. Principles always apply; patterns, when the occasion calls for them.

4. Turning stateless strategies into class hierarchies. In Java 8+, a stateless strategy is a lambda. Comparator.comparing(Material::getTitle) is a complete strategy in one line. Do not create three classes for that.

5. Forgetting the try/catch inside the Observer's loop. A listener that throws stops the following ones from running. It is the pattern's most common failure and the hardest to diagnose afterwards.

6. Inheritance where composition belongs. If in doubt, use composition. Inheritance couples the subclass to the superclass's internal details, and that coupling is invisible until the superclass changes.

7. Uncontrolled stacks of decorators. Five layers of decoration turn a stack trace into a hieroglyph and a simple call into five hops. Two or three layers is fine; five is a sign that the problem is something else.

8. Believing @Transactional works on internal calls. Now you know why it does not: it is a proxy. If this.transactionalMethod() opens no transaction, that is not a Spring bug.

9. An anaemic model out of inertia. Generating entities with every setter "because the IDE does it" and then wondering why the rules are duplicated across three services. Start with no setters and add only the ones you need.

10. Not documenting the pattern you applied. If CachingCatalog is a decorator, say so in the class Javadoc. The next person will understand it in five seconds instead of ten minutes.

A final tip: the acid test of a well-applied pattern is that it removes decision code, rather than adding it. If after applying it there are more ifs, more classes and the same rigidity, take it out.

Exercises

Exercise 1: identify and refactor

This method exists in BiblioTech. Identify all the principles it violates and all the patterns that would solve each problem; then rewrite it.

@Service
public class ExportService {

    @Autowired private EntityManager em;
    @Autowired private JavaMailSender mail;

    public void export(String format, String destination, String emailTo) throws Exception {
        List<Material> materials = em.createQuery("select m from Material m", Material.class)
                                     .getResultList();
        String content;

        if (format.equals("csv")) {
            StringBuilder sb = new StringBuilder("isbn;title;type\n");
            for (Material m : materials) {
                sb.append(m.getIsbn()).append(';')
                  .append(m.getTitle()).append(';');
                if (m instanceof Book)          sb.append("BOOK");
                else if (m instanceof Magazine) sb.append("MAGAZINE");
                else if (m instanceof Dvd)      sb.append("DVD");
                sb.append('\n');
            }
            content = sb.toString();
        } else if (format.equals("json")) {
            content = new ObjectMapper().writeValueAsString(materials);
        } else {
            throw new IllegalArgumentException("Unsupported format: " + format);
        }

        Files.writeString(Path.of(destination), content);

        if (emailTo != null) {
            SimpleMailMessage m = new SimpleMailMessage();
            m.setTo(emailTo);
            m.setSubject("BiblioTech catalogue");
            m.setText("Attached is the catalogue in format " + format);
            mail.send(m);
        }
        System.out.println("Exported " + materials.size() + " materials");
    }
}

Exercise 2: implement a rate-limiting decorator

BiblioTech queries the external metadata API, which limits you to 10 requests per minute. Going over it returns HTTP 429 and blocks the account for an hour.

Implement a RateLimitedGateway decorator that wraps any MetadataGateway and guarantees that no more than N requests per minute are made, waiting if necessary. It must be concurrency-safe (module 8) and honour thread interruption.

Exercise 3: a reservation's state machine

Model the lifecycle of a BiblioTech Reservation with the State pattern using an enum with methods.

States: PENDING (queued, waiting for a copy), AVAILABLE (a copy is held, the employee has 48 h to collect it), COLLECTED (it became a loan), EXPIRED (the 48 h elapsed), CANCELLED (the employee called it off).

Rules: a pending reservation can be cancelled or moved to available; an available one can be collected, cancelled or expired; COLLECTED, EXPIRED and CANCELLED are final; only AVAILABLE has a deadline; only PENDING and AVAILABLE hold a place in the queue.

Write the complete enum, the state diagram and the Reservation class that uses it without a single switch.


Solutions

Solution 1

Violations and applicable patterns:

# Problem Principle violated Pattern
1 The class queries, formats, writes, sends email and prints Single responsibility Extract collaborators
2 if/else over the format Open/closed Strategy or Abstract Factory
3 A chain of instanceof for the type Open/closed, Liskov Polymorphism
4 Direct dependency on EntityManager and JavaMailSender Dependency inversion Repository + Port
5 @Autowired on fields — Constructor injection (11-02)
6 new ObjectMapper() on every call — A singleton bean (11-07: expensive to build)
7 System.out.println Separation of concerns Logging (11-07)
8 throws Exception Error boundary (06-07) Specific exceptions
9 Writing to disk hard-coded Dependency inversion An ExportStore port

Rewrite. First, problem 3 is solved where it belongs, in the domain:

public abstract class Material {
    public abstract MaterialType type();     // goodbye to instanceof
}
public class Book extends Material {
    @Override public MaterialType type() { return MaterialType.BOOK; }
}

The formatting strategy:

public interface CatalogFormatter {
    String format(List<Material> materials);
    Format format();
    String extension();
}

@Component
class CsvFormatter implements CatalogFormatter {
    @Override public Format format()     { return Format.CSV; }
    @Override public String extension()  { return "csv"; }

    @Override
    public String format(List<Material> materials) {
        return materials.stream()
                .map(m -> String.join(";", m.getIsbn().value(), m.getTitle(), m.type().name()))
                .collect(Collectors.joining("\n", "isbn;title;type\n", "\n"));
    }
}

@Component
class JsonFormatter implements CatalogFormatter {
    private final ObjectMapper mapper;               // injected: only one, reused

    JsonFormatter(ObjectMapper mapper) { this.mapper = mapper; }

    @Override public Format format()    { return Format.JSON; }
    @Override public String extension() { return "json"; }

    @Override
    public String format(List<Material> materials) {
        try {
            // DTOs, not entities (12-01)
            List<MaterialDto> dtos = materials.stream().map(MaterialDto::from).toList();
            return mapper.writeValueAsString(dtos);
        } catch (JsonProcessingException e) {
            throw new ExportFailedException("Could not serialise the catalogue", e);
        }
    }
}

The outbound ports:

public interface ExportStore {
    URI store(String name, String content);
}

public interface NoticeSender {
    void send(Notice notice);
}

And the service, which now only orchestrates:

@Service
public class ExportService {

    private static final Logger log = LoggerFactory.getLogger(ExportService.class);

    private final MaterialRepository materials;
    private final Map<Format, CatalogFormatter> formatters;
    private final ExportStore store;
    private final NoticeSender sender;

    /** Spring injects ALL the formatters; we index them by format. */
    public ExportService(MaterialRepository materials,
                         List<CatalogFormatter> formatters,
                         ExportStore store,
                         NoticeSender sender) {
        this.materials = materials;
        this.formatters = formatters.stream()
                .collect(Collectors.toUnmodifiableMap(CatalogFormatter::format, f -> f));
        this.store = store;
        this.sender = sender;
    }

    public ExportResult export(Format format, String name, String emailTo) {
        CatalogFormatter formatter = formatters.get(format);
        if (formatter == null) {
            throw new UnsupportedFormatException(format, formatters.keySet());
        }

        List<Material> catalog = materials.findAll();
        String content = formatter.format(catalog);
        URI location = store.store(name + "." + formatter.extension(), content);

        if (emailTo != null) {
            sender.send(Notice.exportReady(emailTo, format, location));
        }

        log.info("Exported {} materials in {} format to {}", catalog.size(), format, location);
        return new ExportResult(catalog.size(), location, format);
    }
}

Adding XML is now a matter of creating a XmlFormatter class annotated with @Component. Zero changes in ExportService. That is the open/closed principle in practice.

Solution 2

/**
 * Decorator that rate-limits requests to the metadata gateway.
 *
 * It implements a sliding window: it keeps the timestamps of the
 * last N requests; if the oldest one is still inside the window,
 * it waits until it falls out.
 *
 * It IS a MetadataGateway and it HAS a MetadataGateway: a decorator.
 */
public class RateLimitedGateway implements MetadataGateway {

    private static final Logger log = LoggerFactory.getLogger(RateLimitedGateway.class);

    private final MetadataGateway delegate;
    private final int maxRequests;
    private final Duration window;
    private final Clock clock;                     // 10-05: injectable, so it can be tested

    /** Timestamps of the most recent requests. Always accessed under the lock. */
    private final Deque<Instant> timestamps = new ArrayDeque<>();
    private final ReentrantLock lock = new ReentrantLock(true);   // fair: FIFO order

    public RateLimitedGateway(MetadataGateway delegate, int maxRequests,
                              Duration window, Clock clock) {
        this.delegate = Objects.requireNonNull(delegate);
        if (maxRequests < 1) throw new IllegalArgumentException("maxRequests >= 1");
        this.maxRequests = maxRequests;
        this.window = Objects.requireNonNull(window);
        this.clock = Objects.requireNonNull(clock);
    }

    @Override
    public Optional<MaterialMetadata> findByIsbn(Isbn isbn) {
        acquirePermit();                        // may block
        return delegate.findByIsbn(isbn);       // the real call, now outside the lock
    }

    private void acquirePermit() {
        lock.lock();
        try {
            while (true) {
                Instant now = Instant.now(clock);
                purgeOld(now);

                if (timestamps.size() < maxRequests) {
                    timestamps.addLast(now);
                    return;
                }

                // The window is full: we must wait for the oldest one to expire
                Instant oldest = timestamps.peekFirst();
                Duration wait = Duration.between(now, oldest.plus(window));
                if (wait.isNegative() || wait.isZero()) continue;

                log.debug("Rate limit reached; waiting {} ms", wait.toMillis());
                lock.unlock();                       // release it while we sleep
                try {
                    Thread.sleep(wait.toMillis());
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();     // module 8's rule: restore the flag
                    throw new QueryInterruptedException("Rate-limit wait interrupted", e);
                } finally {
                    lock.lock();                     // reacquire before going round again
                }
            }
        } finally {
            lock.unlock();
        }
    }

    private void purgeOld(Instant now) {
        Instant limit = now.minus(window);
        while (!timestamps.isEmpty() && timestamps.peekFirst().isBefore(limit)) {
            timestamps.pollFirst();
        }
    }
}

Assembly with the decorators stacked:

@Bean
MetadataGateway metadataGateway(HttpMetadataGateway http, Clock clock, MeterRegistry registry) {
    return new MeteredGateway(                           // 3rd: measures total time (waiting included)
             new RateLimitedGateway(                     // 2nd: never exceeds 10 per minute
               new CachingGateway(http, Duration.ofHours(24)),    // 1st: the cache avoids requests
               10, Duration.ofMinutes(1), clock));
}

Note the order: the cache goes inside the limiter, so that a cache hit does not consume quota. The other way round, every cached lookup would spend one request of the budget without ever reaching the network. That detail of ordering is why the Decorator pattern demands thought, not just stacking.

A test, taking advantage of the injectable Clock:

@Test
void waitsWhenTheLimitIsExceeded() {
    var delegate = new FakeGateway();
    var clock = Clock.fixed(Instant.parse("2026-03-20T10:00:00Z"), ZoneOffset.UTC);
    var gateway = new RateLimitedGateway(delegate, 2, Duration.ofSeconds(1), clock);

    long start = System.nanoTime();
    gateway.findByIsbn(Isbn.of("978-0000000001"));
    gateway.findByIsbn(Isbn.of("978-0000000002"));
    gateway.findByIsbn(Isbn.of("978-0000000003"));   // the third must wait ~1 s
    long ms = (System.nanoTime() - start) / 1_000_000;

    assertThat(ms).isGreaterThanOrEqualTo(900);
    assertThat(delegate.calls()).isEqualTo(3);
}

Solution 3

stateDiagram-v2
    [*] --> PENDING : reserve
    PENDING --> AVAILABLE : copy arrives
    PENDING --> CANCELLED : cancel
    AVAILABLE --> COLLECTED : collect
    AVAILABLE --> CANCELLED : cancel
    AVAILABLE --> EXPIRED : 48 h elapse
    COLLECTED --> [*]
    EXPIRED --> [*]
    CANCELLED --> [*]
public enum ReservationStatus {

    /** Queued, waiting for a copy to become free. */
    PENDING {
        @Override public boolean canBeCancelled()      { return true; }
        @Override public boolean holdsQueuePlace()     { return true; }
        @Override public ReservationStatus onCopyAvailable() { return AVAILABLE; }
    },

    /** A copy is being held; the employee has 48 hours. */
    AVAILABLE {
        @Override public boolean canBeCancelled()  { return true; }
        @Override public boolean canBeCollected()  { return true; }
        @Override public boolean canExpire()       { return true; }
        @Override public boolean holdsQueuePlace() { return true; }
        @Override public boolean hasDeadline()     { return true; }
        @Override public ReservationStatus onCollect() { return COLLECTED; }
        @Override public ReservationStatus onExpire()  { return EXPIRED; }
    },

    COLLECTED { @Override public boolean isFinal() { return true; } },
    EXPIRED   { @Override public boolean isFinal() { return true; } },
    CANCELLED { @Override public boolean isFinal() { return true; } };

    // --- Queries: by default, nothing is allowed ---
    public boolean canBeCancelled()  { return false; }
    public boolean canBeCollected()  { return false; }
    public boolean canExpire()       { return false; }
    public boolean holdsQueuePlace() { return false; }
    public boolean hasDeadline()     { return false; }
    public boolean isFinal()         { return false; }

    // --- Transitions: by default, invalid ---
    public ReservationStatus onCopyAvailable() { throw new InvalidTransitionException(this, "copy available"); }
    public ReservationStatus onCollect()       { throw new InvalidTransitionException(this, "collect"); }
    public ReservationStatus onExpire()        { throw new InvalidTransitionException(this, "expire"); }

    /** Cancelling is the only transition shared by several states. */
    public ReservationStatus onCancel() {
        if (!canBeCancelled()) throw new InvalidTransitionException(this, "cancel");
        return CANCELLED;
    }
}

And the entity, without a single switch or if on the status:

@Entity
public class Reservation {

    private static final int HOURS_TO_COLLECT = 48;

    @Id @GeneratedValue private Long id;
    @ManyToOne(fetch = FetchType.LAZY) private Material material;
    @ManyToOne(fetch = FetchType.LAZY) private Employee employee;
    @Enumerated(EnumType.STRING) private ReservationStatus status;
    private Instant requestDate;
    private Instant collectionDeadline;       // only meaningful in AVAILABLE
    @Version private long version;            // optimistic locking (11-03)

    protected Reservation() { }               // required by JPA

    public static Reservation create(Material material, Employee employee, Clock clock) {
        Reservation r = new Reservation();
        r.material = material;
        r.employee = employee;
        r.status = ReservationStatus.PENDING;
        r.requestDate = Instant.now(clock);
        return r;
    }

    public void assignCopy(Clock clock) {
        this.status = status.onCopyAvailable();       // validates and transitions in one line
        this.collectionDeadline = Instant.now(clock).plus(HOURS_TO_COLLECT, ChronoUnit.HOURS);
    }

    public void collect() {
        this.status = status.onCollect();
        this.collectionDeadline = null;
    }

    public void cancel() {
        this.status = status.onCancel();
        this.collectionDeadline = null;
    }

    /** Returns true if it did expire. Idempotent: if it does not apply, it does nothing. */
    public boolean expireIfDue(Clock clock) {
        if (!status.canExpire()) return false;
        if (Instant.now(clock).isBefore(collectionDeadline)) return false;
        this.status = status.onExpire();
        this.collectionDeadline = null;
        return true;
    }

    public boolean holdsQueuePlace() { return status.holdsQueuePlace(); }
}

And the periodic job that expires reservations becomes trivial:

@Scheduled(cron = "0 */15 * * * *")     // every 15 minutes
@Transactional
public void expireOverdueReservations() {
    int expired = 0;
    for (Reservation r : repository.withStatus(ReservationStatus.AVAILABLE)) {
        if (r.expireIfDue(clock)) {
            expired++;
            events.publishEvent(new ReservationExpired(r.getId()));   // Observer
        }
    }
    log.info("Reservations expired: {}", expired);
}

What the design has gained: the state machine lives in a single 40-line file, any invalid transition throws a clear exception with the originating state and the attempted action, adding a SUSPENDED state means adding a constant, and it is impossible to leave a reservation in an inconsistent state from anywhere in the system.

Conclusion

Every debt in the course has been settled.

You started with what really matters: the principles. All five of SOLID, each with a before and after from BiblioTech: the single responsibility that broke up that LoanManager with seven reasons to change; the open/closed principle that explains why the polymorphism of 03-06 was more than a syntax trick; Liskov substitution with its most instructive violation —the ReferenceMaterial that threw UnsupportedOperationException— and its real solution, which was not fixing the subclass but redesigning the hierarchy; interface segregation, which you had already been practising since 04-01 with Lendable and Notifiable; and dependency inversion, with the nuance hardly anybody explains: Spring gives you DI and IoC, but the DIP depends on where you put the interface. Plus DRY about knowledge rather than text, KISS, YAGNI and the Law of Demeter with its exception for fluent APIs.

Then the catalogue, always with the same layout: problem, structure, real BiblioTech code and —what almost no material includes— when not to use it. The creational ones, with the Builder promised in 03-04 finally implemented for Loan and honestly contrasted with record and with Lombok's @Builder, and with the classic Singleton put in its place: a global variable in disguise that the Spring bean does better on all seven dimensions that matter. The structural ones, with the Adapter that isolates the external MetadataClient behind a domain port and its three translations —types, exceptions and semantics—; the Decorator promised since 07-03, with the I/O streams as the canonical case and CachingCatalog as our own, including the lesson that the stacking order is a design decision; and the Proxy, which closes the circle from 10-03 and explains once and for all why @Transactional does not work on self-invocations or on private methods.

And the behavioural ones, where the oldest debts lay. The Strategy you had been using since module 4 with the RateRules and the Comparators, now with a name and with the Spring version that makes it almost invisible. The Template Method promised in 04-02, with the comparison that matters: inheritance versus composition, and why the modern preference is the latter. The Observer with its ReturnListeners, the try/catch inside the loop that almost everybody forgets, and Spring's application events with @TransactionalEventListener(AFTER_COMMIT) as its modern, correct version. The State pattern, solved with the abstract-method enum that is the cleanest implementation Java allows. The Command, which you already had in 05-08's undo stack and in every Runnable of module 8. And the Chain of Responsibility and the Iterator, which pick up from 05-02 and which you will see again in 12-07's filter chain.

You also know the enterprise patterns BiblioTech uses without your having written them: Repository, Unit of Work, DTO, Application Service, Value Object, Specification. And the anti-patterns, including the honest debate about the anaemic model, on which BiblioTech takes an explicit side: reasonably rich entities that protect their own invariants, application services for whatever crosses aggregates, transactions or infrastructure.

Above the whole catalogue stands the warning the lesson opened with, which is the one thing you must not forget: a pattern that does not answer a real problem is technical debt with a good name. The goal is not to recognise patterns in your code; it is for your code to be easy to change. Patterns are a means, and sometimes the right means is an if.

BiblioTech now has architecture, modules, boundaries verified by the compiler and an internal design with names of its own. It still lacks something elementary: nobody can use it. There is not one interface through which Marta Ruiz could lend a book without writing Java code.

The next lesson fixes that by the most direct and most underrated route: a console application. Not module 2's Scanner menu, but a professional CLI —with subcommands, typed options, automatic help, exit codes that mean something, composable output for pipes and clean cancellation— that a Nexus Software sysadmin can drop into a cron without complaining.

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