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
whileloop". 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
- What a pattern is and what it is not
- The GoF catalogue and its historical context
- Principles before patterns
- SOLID: single responsibility (S)
- SOLID: open/closed (O)
- SOLID: Liskov substitution (L)
- SOLID: interface segregation (I)
- SOLID: dependency inversion (D)
- DRY, KISS, YAGNI and the Law of Demeter
- Creational patterns: Singleton
- Creational patterns: Factory Method and Abstract Factory
- Creational patterns: Builder
- Creational patterns: Prototype
- Structural patterns: Adapter
- Structural patterns: Decorator
- Structural patterns: Proxy
- Structural patterns: Facade and Composite
- Behavioural patterns: Strategy
- Behavioural patterns: Template Method
- Behavioural patterns: Observer
- Behavioural patterns: State
- Behavioural patterns: Command
- Behavioural patterns: Chain of Responsibility and Iterator
- Architectural and enterprise patterns
- Anti-patterns
- Final table: problem → candidate pattern
- Common Mistakes and Tips
- Exercises
- Conclusion
- 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, anifis 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.
- 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:
- Java has many of them built in.
Iteratoris in the language (for-each).Observeris Spring's event API.Strategyis 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. - Some have aged badly. The classic Singleton is today widely considered an anti-pattern (we will see why).
java.util'sObserver/Observablehave been deprecated since Java 9.
- 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
- 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.
- 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
switchlives 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.
- SOLID: Liskov substitution (L)
If
Sis a subtype ofT, you must be able to replaceTwithSwithout 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 LendableAnd 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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:
- Self-invocation does not work. If
methodA()callsthis.methodB()and onlymethodBis@Transactional, there is no transaction: thethis.call does not go through the proxy. privateandfinalmethods 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 |
- 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);
}
}
- 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.
- 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.
- 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.
- 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.
- 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.
- 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)) { … }
- 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.
}
- 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.
- 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
- Introduction to Java
- Setting Up the Development Environment
- Basic Syntax and Structure
- Variables and Data Types
- Operators
- Console Input and Output
- Your First Complete Program: BiblioTech
Module 2: Control Flow
- Conditional Statements
- Loops
- Switch Statements
- Break and Continue
- Debugging and Execution Traces
- Project: The BiblioTech Interactive Menu
Module 3: Object-Oriented Programming
- Introduction to OOP
- Classes and Objects
- Methods
- Constructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- The Object Class: equals, hashCode and toString
Module 4: Advanced Object-Oriented Programming
- Interfaces
- Abstract Classes
- Inner Classes
- Anonymous Classes
- Lambda Expressions
- Functional Interfaces and Method References
- Enums and Records
Module 5: Data Structures and Collections
- Arrays
- The Collections Framework
- ArrayList
- LinkedList
- HashMap
- HashSet
- Queue and Deque
- Stack
- Sorting and Searching Collections
Module 6: Exception Handling
- Introduction to Exceptions
- The Try-Catch Block
- Throw and Throws
- Custom Exceptions
- The Finally Block
- Try-with-resources and AutoCloseable
- Error Handling Strategies and Logging
Module 7: File Input/Output
- Reading Files
- Writing Files
- File Streams
- BufferedReader and BufferedWriter
- Serialization
- The NIO.2 API: Path and Files
- Interchange Formats: CSV and Properties
Module 8: Multithreading and Concurrency
- Introduction to Multithreading
- Creating Threads
- Thread Lifecycle
- Synchronization
- Concurrency Utilities
- Concurrent Collections and Atomic Variables
- Asynchronous Tasks with CompletableFuture
Module 9: Networking
- Introduction to Networking
- Sockets
- ServerSocket
- DatagramSocket and DatagramPacket
- URL and HttpURLConnection
- The Modern HTTP Client
Module 10: Advanced Topics
- Generics
- Annotations
- Reflection
- Java 8 Features: Streams and Optional
- Dates and Times with java.time
- Java 9 and Beyond
- Memory, Garbage Collection and Performance
Module 11: Java Frameworks and Libraries
- Introduction to Java Frameworks
- Spring Framework
- Hibernate
- JUnit
- Maven
- Advanced Testing with Mockito
- Essential Ecosystem Libraries
