9. Object-Oriented Design Principles and Patterns
Knowing the language is not the same as knowing how to arrange code so it can be changed six months later by someone else. This chapter covers the vocabulary of good design: the quality measures that tell you whether a class is well shaped, the SOLID principles that turn those measures into rules, the classic patterns that recur across every codebase, and the UML notation used to discuss all of it on a whiteboard. None of it is Java-specific, but Java's type system is what makes the patterns expressible, and interviews assume you know them.
<a id="91-design-quality-fundamentals"></a>
9.1 Design Quality Fundamentals
This subchapter covers the measures that distinguish maintainable code from code that merely works, and the warning signs that a design is decaying.
Cohesion and Coupling
Every design discussion eventually reduces to two questions. Does this class do one job? And how many other classes does it drag along with it? Those are cohesion and coupling, and they are the foundation everything else in this chapter builds on.
Cohesion measures how closely the parts of a class belong together. High cohesion means every field and method serves one clear purpose. Low cohesion means the class is a drawer where unrelated things were dropped because there was nowhere else to put them.
// LOW cohesion: three unrelated jobs in one class
public class UserManager {
public void saveUser(User u) { /* database code */ }
public void sendWelcomeEmail(User u) { /* SMTP code */ }
public String formatUserAsHtml(User u) { /* rendering code */ }
public boolean validatePassword(String p) { /* security rules */ }
}
// HIGH cohesion: each class has one reason to exist
public class UserRepository { public void save(User u) { } }
public class EmailService { public void sendWelcome(User u) { } }
public class UserRenderer { public String toHtml(User u) { } }
public class PasswordPolicy { public boolean isValid(String p) { } }
A quick test: try to describe the class in one sentence without using "and" or "or". If you cannot, cohesion is low.
Coupling measures how much one class depends on the internals of another. Low coupling means a class can be understood, tested, and changed without dragging half the codebase with it.
// TIGHT coupling: OrderService creates and depends on a concrete class
public class OrderService {
private final MySqlOrderRepository repo = new MySqlOrderRepository();
// Cannot test without a real MySQL instance.
// Cannot switch databases without editing this class.
}
// LOOSE coupling: depends on an interface, receives it from outside
public class OrderService {
private final OrderRepository repo; // an interface
public OrderService(OrderRepository repo) { // injected by the caller
this.repo = repo;
}
}
TIGHT COUPLING LOOSE COUPLING
OrderService OrderService
| |
| new MySqlOrderRepository() | depends on
v v
MySqlOrderRepository OrderRepository (interface)
^ ^
| |
MySql... InMemory... (for tests)
The two measures pull in the same direction, and the standard formulation is high cohesion, low coupling. They are related: splitting a low-cohesion class into focused pieces usually lowers coupling too, because each piece now needs fewer collaborators.
Degrees of coupling, from worst to best:
| Level | Description | Example |
|---|---|---|
| Content | One class modifies another's internals | Reaching into a public field |
| Common | Shared global mutable state | A mutable static singleton |
| Control | A flag argument tells the callee what to do | process(data, true) |
| Stamp | Passing a whole object when one field suffices | calc(order) needing only total |
| Data | Passing exactly the needed values | calc(total, rate) |
| Interface-only | Depending on an abstraction | Constructor-injected interface |
Some coupling is unavoidable and desirable. A class with zero dependencies does nothing useful. The goal is to depend on stable abstractions rather than on volatile implementations.
Practical signals of trouble:
- A method that uses fields of another object more than its own belongs on that object.
- A change to one class forces edits in five others.
- A unit test needs six mocks before it can run.
- A class name contains "Manager", "Processor", "Util", or "Helper", which often signals no single responsibility was ever identified.
Key Takeaways
- Cohesion measures whether a class's parts serve one purpose.
- Coupling measures how much a class depends on other classes' internals.
- The goal is high cohesion and low coupling; improving one usually improves the other.
- Depend on interfaces you own rather than on concrete implementations.
- Describing a class without "and" is a quick cohesion test.
- Names like Manager and Util often mark a class with no real responsibility.
- Zero coupling is not the aim; depending on stable abstractions is.
🧪 Practice
- Find a class in your code with four or more unrelated methods and split it.
- Replace a
new ConcreteThing()inside a class with a constructor parameter typed as an interface.- Rate three of your classes on the coupling table above and justify each.
- Interview: Can coupling ever be too low? (Hint: what happens to a system made entirely of classes that know nothing about each other?)
Separation of Concerns
A concern is one area of responsibility: persistence, validation, presentation, business rules, logging. Separation of concerns says each should live in its own place, so a change to one does not require understanding the others.
The motivation is human, not technical. A class mixing four concerns must be read in full before any part of it can be safely changed, and four different reasons to change it means four different people editing the same file.
Consider a method that mixes everything:
public class OrderProcessor {
public String process(String json) {
// 1. Parsing (input format concern)
Order order = new Gson().fromJson(json, Order.class);
// 2. Validation (business rules concern)
if (order.getItems().isEmpty()) return "<error>empty order</error>";
// 3. Calculation (domain concern)
double total = 0;
for (Item i : order.getItems()) total += i.getPrice() * i.getQty();
if (total > 100) total *= 0.9;
// 4. Persistence (infrastructure concern)
Connection c = DriverManager.getConnection("jdbc:...");
// ... SQL ...
// 5. Presentation (output format concern)
return "<order><total>" + total + "</total></order>";
}
}
Changing the output from XML to JSON means editing a class that also holds tax rules and SQL. Testing the discount rule requires a live database.
Separated, each concern becomes independently testable:
public class OrderParser { public Order parse(String json) { } }
public class OrderValidator { public void validate(Order o) { } }
public class PricingService { public Money total(Order o) { } } // pure logic
public class OrderRepository{ public void save(Order o) { } }
public class OrderRenderer { public String render(Order o) { } }
// The coordinator does no work of its own; it only sequences the steps
public class OrderProcessor {
private final OrderParser parser;
private final OrderValidator validator;
private final PricingService pricing;
private final OrderRepository repository;
private final OrderRenderer renderer;
public String process(String json) {
Order order = parser.parse(json);
validator.validate(order);
order.setTotal(pricing.total(order));
repository.save(order);
return renderer.render(order);
}
}
The usual large-scale expression of this is layering:
+-------------------------------+
| Presentation (controllers) | HTTP, formatting
+-------------------------------+
| depends on
+-------------------------------+
| Application (services) | use cases, orchestration
+-------------------------------+
| depends on
+-------------------------------+
| Domain (entities, rules) | business logic, no frameworks
+-------------------------------+
^ implemented by
+-------------------------------+
| Infrastructure (repositories)| database, network, files
+-------------------------------+
The key rule is that dependencies point inward. The domain layer knows nothing about HTTP or SQL, which is what lets business rules be tested with plain unit tests and reused behind a different interface.
Two cautions. First, separation has a cost: five classes are more files to navigate than one, so split along real concern boundaries rather than mechanically. Second, a coordinator that only delegates is fine and is not a "useless" class; sequencing steps is itself a responsibility.
Key Takeaways
- A concern is one area of responsibility, such as persistence or presentation.
- Mixing concerns means every change requires understanding unrelated code.
- Separated concerns can be tested independently, without databases or frameworks.
- Layering expresses the idea at architecture scale.
- Dependencies should point inward, toward the domain, never outward.
- A coordinating class that only sequences steps is legitimate.
- Split along genuine boundaries; splitting for its own sake adds navigation cost.
🧪 Practice
- Take a method that parses, validates, and saves, and extract three classes.
- Write a unit test for extracted business logic with no database involved.
- Draw the layer diagram for a project you know and mark any inward-pointing violations.
- Interview: Why should the domain layer not import a database library? (Hint: what happens to your business rules when the database is replaced?)
Law of Demeter
The Law of Demeter, also called the principle of least knowledge, is a concrete rule for limiting coupling: talk only to your immediate friends. An object should call methods on itself, on its own fields, on its parameters, and on objects it creates, but not on objects it reached through someone else.
The informal version is "one dot", and the symptom is a chain of calls:
// Violation: reaching three objects deep
String city = order.getCustomer().getAddress().getCity().getName();
That single line couples the caller to Order, Customer, Address, and
City. Renaming a method on any of the four breaks it, and it cannot be
tested without constructing the whole graph.
VIOLATION: the caller knows the whole chain
Caller ----> Order ----> Customer ----> Address ----> City
| ^
+-----------------------------------------------------+
depends on all four types
COMPLIANT: each object asks only its neighbour
Caller ----> Order.getCityName()
|
+--> Customer.getCityName()
|
+--> Address.getCityName()
The fix is to push the behaviour down, so each object answers for its own part:
public class Order {
private final Customer customer;
public String getCityName() { return customer.getCityName(); } // delegate
}
public class Customer {
private final Address address;
public String getCityName() { return address.getCityName(); }
}
// The caller now knows only Order
String city = order.getCityName();
The deeper reformulation is Tell, Don't Ask. Rather than pulling data out of an object to make a decision about it, tell the object to make the decision.
// ASK: the caller extracts state and decides
if (account.getBalance() >= amount) {
account.setBalance(account.getBalance() - amount);
}
// TELL: the account enforces its own invariant
account.withdraw(amount); // throws or returns a result if insufficient
The second version keeps the rule inside the class that owns the data, which is what encapsulation was for in the first place.
Formally, a method m of class C may call methods on:
| Allowed target | Example |
|---|---|
this | this.validate() |
Fields of C | repository.save(x) |
Parameters of m | order.total() |
Objects created inside m | new Formatter().format(x) |
| Static/global objects | Logger.info(...) |
Two important exceptions, or the rule becomes absurd.
Fluent interfaces and builders chain deliberately, and each call returns the same conceptual object:
String s = new StringBuilder().append("a").append("b").toString(); // fine
Comparator<Person> c = Comparator.comparing(Person::getName)
.thenComparing(Person::getAge); // fine
Streams chain by design as well. The distinguishing question is whether each dot navigates to a different object with its own identity, or continues working with the same one. Navigation is the violation; fluent chaining is not.
Data Transfer Objects are the third exception. A structure whose entire
purpose is to carry fields, such as a JSON payload or a record, has no
behaviour to tell, so reading dto.getUser().getName() is acceptable.
The cost of the law is delegation methods, sometimes called "middle man" methods. Applied without judgement it produces classes full of one-line forwards. Apply it where a chain crosses a meaningful boundary, not everywhere.
Key Takeaways
- Call methods only on yourself, your fields, your parameters, and objects you create.
- Long navigation chains couple the caller to every type in the chain.
- Fix them by delegating, so each object answers for its own neighbour.
- Tell, Don't Ask puts decisions inside the object that owns the data.
- Fluent builders and streams are not violations, because they stay on one conceptual object.
- Data transfer objects are exempt, since they hold no behaviour.
- Over-applying the law produces a layer of pointless forwarding methods.
🧪 Practice
- Find a three-dot chain in your code and remove it by adding a delegating method.
- Rewrite an "ask then set" block as a single "tell" method on the object.
- Explain why
list.stream().filter(...).count()does not violate the law.- Interview: What is the cost of following the Law of Demeter strictly? (Hint: count the methods you must add to each class in the chain.)
DRY, KISS, and YAGNI
Three slogans encode most of what experienced developers mean by "keep it clean". They are simple to state and routinely misapplied, so the useful part is knowing their limits.
DRY, Don't Repeat Yourself, is often quoted as "never copy and paste code". The original formulation is more precise and more useful: every piece of knowledge should have a single authoritative representation in the system. The target is duplicated knowledge, not duplicated text.
// Duplicated knowledge: the VAT rate lives in three places
public double invoiceTotal(double net) { return net * 1.21; }
public double quoteTotal(double net) { return net * 1.21; }
public double receiptTotal(double net) { return net * 1.21; }
// Single source of truth: one place changes when the rate does
public final class Tax {
public static final double VAT_RATE = 0.21;
public static double withVat(double net) { return net * (1 + VAT_RATE); }
}
The misapplication is merging code that merely looks alike:
// These two methods look alike but encode DIFFERENT rules
public double employeeDiscount(double p) { return p * 0.9; } // staff policy
public double clearanceDiscount(double p) { return p * 0.9; } // stock policy
Merging them couples two independent policies. When the staff discount changes to 15 percent, you discover the coupling the hard way. The test is whether the two would always change together, for the same reason. If not, the duplication is coincidental and should stay.
KISS, Keep It Simple, favours the most obvious implementation that solves the problem. Cleverness is a cost paid by every future reader:
// Clever: hard to read, hard to modify
return items.stream().collect(Collectors.groupingBy(i -> i.getType().name()
.charAt(0), TreeMap::new, Collectors.mapping(Item::getName,
Collectors.joining(","))));
// Simple: three named steps, each obvious
Map<Character, List<Item>> byInitial = groupByTypeInitial(items);
Map<Character, String> names = joinNames(byInitial);
return new TreeMap<>(names);
Simplicity is not the same as brevity. A one-liner that needs a comment to explain it is less simple than three named lines that do not.
YAGNI, You Aren't Gonna Need It, says do not build for requirements you only imagine. Speculative generality costs immediately and usually never pays off:
// Speculative: one implementation, a factory, and an interface,
// for a system that will only ever send email
public interface NotificationChannel { void send(Message m); }
public class NotificationChannelFactory { ... }
public class EmailChannel implements NotificationChannel { ... }
// YAGNI: start here. Extract the interface when the SECOND channel arrives.
public class EmailSender { public void send(Message m) { ... } }
The three interact, and sometimes conflict:
| Principle | Says | Overdone produces |
|---|---|---|
| DRY | One home per piece of knowledge | Wrong abstractions that couple unrelated code |
| KISS | Prefer the obvious solution | Under-engineering, copy-paste sprawl |
| YAGNI | Build only what is needed now | Rewrites when a known requirement arrives |
DRY pushes toward abstraction; KISS and YAGNI push back. The resolution is the rule of three: duplicate once without concern, and extract an abstraction on the third occurrence, when you can actually see what varies. Abstracting from a single example produces an abstraction shaped by a guess.
A useful counterweight, often attributed to Sandi Metz: duplication is far cheaper than the wrong abstraction. Undoing a bad merge is harder than merging two copies later.
Key Takeaways
- DRY targets duplicated knowledge, not duplicated characters.
- Two similar methods encoding different rules should stay separate.
- KISS prefers the obvious solution; simple is not the same as short.
- YAGNI rejects building for imagined future requirements.
- The principles conflict: DRY abstracts, KISS and YAGNI resist.
- The rule of three delays extraction until the variation is visible.
- The wrong abstraction costs more than the duplication it removed.
🧪 Practice
- Find a magic number appearing in three places and give it one home.
- Find two similar methods and argue whether they encode the same knowledge.
- Identify an interface in your code with exactly one implementation and decide whether it earns its place.
- Interview: When is duplication better than abstraction? (Hint: what happens when two merged rules later need to diverge?)
Programming to an Interface
"Program to an interface, not an implementation" is the oldest rule in the patterns literature, and nearly every pattern in this chapter is an application of it. The idea is to depend on what something does, never on how it does it.
The problem it addresses is that concrete types leak decisions:
public class ReportService {
// Declares the exact class, so the whole method is welded to it
public void generate(ArrayList<Sale> sales, MySqlConnection conn) { ... }
}
Callers holding a LinkedList cannot use this method, and a test cannot run
without MySQL, even though neither detail matters to report generation.
Declaring the abstraction instead removes both problems:
public class ReportService {
public void generate(List<Sale> sales, DataSource source) { ... }
}
CONCRETE DEPENDENCY ABSTRACT DEPENDENCY
ReportService ReportService
| |
v v
MySqlConnection DataSource (interface)
^ ^ ^
| | |
MySql Postgres FakeForTests
The rule has three everyday applications.
Declare variables, parameters, and return types with the most general type that suffices:
List<String> names = new ArrayList<>(); // not ArrayList<String> names
Map<String, Integer> counts = new HashMap<>();
public Collection<Order> findAll() { ... } // free to change the container
public void process(Iterable<Order> orders) { ... }
Inject dependencies rather than constructing them, so the choice is made by the caller and can differ in tests:
public class OrderService {
private final PaymentGateway gateway; // an interface
public OrderService(PaymentGateway gateway) { this.gateway = gateway; }
}
new OrderService(new StripeGateway()); // production
new OrderService(new FakeGateway()); // test, no network
Return abstractions, so a later change of implementation is invisible:
// Returning ArrayList commits you forever; callers may depend on it
public ArrayList<Order> findAll() { ... } // avoid
public List<Order> findAll() { ... } // free to return List.of(...)
The limits are worth stating, because the rule is sometimes applied mechanically.
An interface with exactly one implementation, created only because "interfaces are good practice", is speculative generality and YAGNI applies. Extract the interface when a second implementation appears, or when you need a test double that cannot otherwise exist.
Sometimes the concrete type is the contract. If a method genuinely requires
the O(1) indexed access that ArrayList provides, or must return a modifiable
list, say so. Widening the type there would be dishonest.
And the most general type is not always the right one. Declaring a parameter as
Object because it is the most abstract choice is not programming to an
interface; it is discarding type information.
| Instead of declaring | Prefer | Reason |
|---|---|---|
ArrayList<T> | List<T> | Free to change implementation |
HashMap<K, V> | Map<K, V> | Same |
MySqlRepository | Repository | Testable, swappable |
FileInputStream | InputStream | Works with any source |
ArrayList<T> param | Collection<T> | Accepts more callers |
Key Takeaways
- Depend on what a type does, not on how it does it.
- Declare variables, parameters, and return types with the general type.
- Inject dependencies so callers and tests choose the implementation.
- Returning a concrete collection type commits you to it permanently.
- An interface with one implementation and no test need is speculative.
- State the concrete type when its specific guarantees are genuinely required.
- Using
Objecteverywhere is not abstraction; it is losing type information.
🧪 Practice
- Change three field declarations from concrete collection types to interfaces.
- Refactor a class that calls
newon a dependency to receive it via the constructor.- Write a fake implementation of a repository interface and test a service with it.
- Interview: When is depending on a concrete class the right choice? (Hint: consider a method whose correctness relies on O(1) random access.)
Code Smells and Refactoring Triggers
A code smell is a surface symptom suggesting a deeper design problem. It is not a bug: the code works. It is a signal that the structure will make the next change expensive. Learning the catalogue gives you a vocabulary for saying what is wrong in a review, rather than "this feels bad".
The most common smells, with their usual cure:
| Smell | Symptom | Typical refactoring |
|---|---|---|
| Long Method | Cannot be seen on one screen | Extract Method |
| Large Class | Dozens of fields, many responsibilities | Extract Class |
| Long Parameter List | Five or more parameters | Introduce Parameter Object |
| Duplicated Code | The same knowledge in several places | Extract Method or Superclass |
| Feature Envy | A method uses another object's data more than its own | Move Method |
| Data Clumps | The same fields travel together everywhere | Extract Class |
| Primitive Obsession | String and int where a type belongs | Replace with a value type |
| Switch on Type | switch over a type code, repeated | Replace with polymorphism |
| Shotgun Surgery | One change forces edits in many classes | Move related behaviour together |
| Divergent Change | One class changes for many reasons | Extract Class |
| Message Chains | a.getB().getC().getD() | Hide Delegate |
| Speculative Generality | Abstractions with one user | Inline and delete |
| Temporary Field | A field set only during one operation | Extract Method Object |
Three worth seeing in code.
Primitive obsession uses built-in types where a domain concept belongs, which loses validation and allows nonsense:
// Anything can be passed; nothing is validated; order is easy to confuse
public void transfer(String fromIban, String toIban, double amount,
String currency) { }
transfer(to, from, -50, "euros"); // compiles, all four are wrong
// A value type validates once and makes misuse impossible
public record Iban(String value) {
public Iban {
if (!value.matches("[A-Z]{2}\\d{2}[A-Z0-9]+"))
throw new IllegalArgumentException("invalid IBAN");
}
}
public record Money(BigDecimal amount, Currency currency) { }
public void transfer(Iban from, Iban to, Money amount) { }
Switch on type duplicates the same branching in every new method, and every added type requires finding all of them:
// Every new shape means editing every switch in the codebase
public double area(Shape s) {
switch (s.getType()) {
case CIRCLE: return Math.PI * s.getRadius() * s.getRadius();
case SQUARE: return s.getSide() * s.getSide();
}
throw new IllegalArgumentException();
}
// Polymorphism: a new shape is a new class, and nothing else changes
public interface Shape { double area(); }
public record Circle(double radius) implements Shape {
public double area() { return Math.PI * radius * radius; }
}
Feature envy means the method is on the wrong class:
// This method touches only Address fields: it belongs on Address
public class Invoice {
public String formatAddress(Address a) {
return a.getStreet() + ", " + a.getCity() + " " + a.getPostcode();
}
}
Knowing when not to refactor matters as much. Useful triggers:
- You are about to change code and the smell is in your way.
- The same smell has caused a bug more than once.
- A new requirement does not fit the current structure.
Poor triggers are "it is not how I would have written it" and "this file is old". Refactoring without tests is rewriting, and refactoring code nobody touches spends effort for no return.
The discipline is: make the change easy, then make the easy change. Refactor first so the feature becomes simple, rather than forcing a feature into a bad structure and leaving it worse.
Key Takeaways
- A smell is a symptom of a design problem, not a bug.
- The catalogue gives shared vocabulary for code review.
- Primitive obsession loses validation that a small value type would enforce.
- Repeated switching on a type code should become polymorphism.
- Feature envy means the method sits on the wrong class.
- Refactor when a smell blocks a change you are making, not on sight.
- Refactoring requires tests; without them it is an unverified rewrite.
🧪 Practice
- Identify three smells in a file you did not write and name the refactoring for each.
- Replace a
Stringparameter that has validation rules with a record value type.- Convert a
switchover an enum type code into polymorphic classes.- Interview: Why is Shotgun Surgery the opposite of Divergent Change? (Hint: in each case, count the classes touched by one change and the reasons one class changes.)
<a id="92-solid-principles"></a>
9.2 SOLID Principles
This subchapter covers the five principles that turn the vague goals of the previous section into concrete rules for shaping classes and dependencies.
Single Responsibility Principle
The first principle is the most quoted and the most misunderstood. It is usually stated as "a class should do one thing", which invites arguments about what counts as one thing. Robert Martin's sharper formulation is:
A class should have one, and only one, reason to change.
The shift from "one thing" to "one reason to change" makes it testable. A reason to change corresponds to a stakeholder or an area of the business that can issue a request. If the accounting department and the IT operations team can each independently force an edit to the same class, that class has two responsibilities.
// Three stakeholders can force a change to this one class
public class Employee {
public Money calculatePay() { } // accounting changes the pay rules
public void save() { } // DBAs change the schema
public String reportHours() { } // HR changes the report format
}
The danger is not aesthetic. Two teams editing one class for unrelated reasons produces merge conflicts, and a change made for accounting can break the HR report because they share private helper methods.
Separated by stakeholder:
public class Employee { } // just the data
public class PayCalculator { Money calculate(Employee e) { } } // accounting
public class EmployeeRepository { void save(Employee e) { } } // operations
public class HourReporter { String report(Employee e) { } } // HR
BEFORE AFTER
Accounting ---+ Accounting ---> PayCalculator
Operations ---+--> Employee Operations ---> EmployeeRepository
HR ---+ HR ---> HourReporter
three reasons to change one reason each
The usual objection is that this produces many small classes. It does, and that is the trade. Small classes with clear names are easier to locate than large ones with vague ones, and the total amount of code is roughly the same.
Applying it requires judgement about granularity. Taken to an extreme, every method becomes its own class, which is its own design failure. Two useful checks:
- Can you name the class without "and", "or", or a vague noun like Manager?
- Would a change requested by one team ever require this class to be edited by another?
Common Java applications of the principle:
| Mixed responsibility | Split into |
|---|---|
| Entity that saves itself | Entity plus Repository |
| Service that also formats output | Service plus Renderer or DTO mapper |
| Class that validates and persists | Validator plus Repository |
| Controller with business logic | Controller plus Service |
The principle also applies to methods. A method that both computes a value and prints it has two reasons to change:
// Two jobs: cannot test the calculation without capturing stdout
public void printTotal(Order o) {
double total = o.items().stream().mapToDouble(Item::price).sum();
System.out.println("Total: " + total);
}
// One job each: the calculation is now testable
public double total(Order o) { ... }
public void printTotal(Order o) { System.out.println("Total: " + total(o)); }
A final note on what SRP is not. It does not say a class may have only one
method, and it does not forbid a class from having several methods that serve
the same responsibility. A PayCalculator with six methods, all about pay, is
perfectly compliant.
Key Takeaways
- A class should have exactly one reason to change.
- A reason to change maps to a stakeholder who can request it.
- Mixing stakeholders in one class causes merge conflicts and accidental breakage.
- Entities that persist, validate, and render themselves violate the principle.
- It applies to methods too: computing and printing are two jobs.
- Compliance produces more, smaller classes, which is the intended trade.
- One responsibility does not mean one method.
🧪 Practice
- List every reason a class in your codebase might change and count the stakeholders.
- Split an entity that contains its own SQL into an entity and a repository.
- Separate a method that calculates and prints into two methods.
- Interview: Why is "one reason to change" better than "does one thing"? (Hint: which of the two can two people disagree about indefinitely?)
Open-Closed Principle
Bertrand Meyer's principle states that software entities should be open for extension but closed for modification. New behaviour should be added by writing new code, not by editing code that already works.
The reason is risk. Editing a tested, deployed class can break every existing caller, and each edit requires re-testing everything. Adding a new class risks only the new path.
The violation is a conditional that grows with every requirement:
public class DiscountCalculator {
public double discount(Order order, String customerType) {
if (customerType.equals("REGULAR")) return 0;
if (customerType.equals("PREMIUM")) return order.total() * 0.1;
if (customerType.equals("VIP")) return order.total() * 0.2;
// Every new customer type means editing and re-testing THIS method
return 0;
}
}
The fix is to make the varying part an abstraction, so new behaviour arrives as a new implementation:
public interface DiscountPolicy {
double discount(Order order);
}
public class RegularDiscount implements DiscountPolicy {
public double discount(Order o) { return 0; }
}
public class PremiumDiscount implements DiscountPolicy {
public double discount(Order o) { return o.total() * 0.1; }
}
public class VipDiscount implements DiscountPolicy {
public double discount(Order o) { return o.total() * 0.2; }
}
// This class is now CLOSED: it never changes again
public class DiscountCalculator {
public double discount(Order order, DiscountPolicy policy) {
return policy.discount(order);
}
}
// A new tier is a NEW FILE, with no edits anywhere else
public class StudentDiscount implements DiscountPolicy {
public double discount(Order o) { return o.total() * 0.15; }
}
OPEN FOR EXTENSION, CLOSED FOR MODIFICATION
DiscountCalculator ------> DiscountPolicy <-- abstraction is STABLE
(never edited) ^
|
+---------+-----------+-----------+
| | | |
Regular Premium VIP Student <-- add freely
(new files only)
Java offers several mechanisms for the extension point:
| Mechanism | Use when |
|---|---|
| Interface + implementations | Behaviour varies by type |
| Abstract class with hooks | Shared skeleton, varying steps |
| Strategy passed as a lambda | The variation is a single function |
| Enum with per-constant behaviour | The set of cases is genuinely fixed |
The lambda form is often the lightest option and needs no new files at all:
public class DiscountCalculator {
public double discount(Order o, Function<Order, Double> policy) {
return policy.apply(o);
}
}
calculator.discount(order, o -> o.total() * 0.15);
Two important qualifications.
First, you cannot be closed against every change. The principle is about anticipating the axis along which a system varies, and abstracting that axis only. Guessing wrong produces the speculative generality that YAGNI warns about. In practice the sequence is: write the concrete version, and when the second variation arrives, extract the abstraction then. The second occurrence tells you where the seam belongs.
Second, an enum with per-constant behaviour is a legitimate implementation when the set of cases is fixed by the domain and adding to it genuinely warrants a review of all the code:
public enum Planet {
MERCURY(3.303e+23, 2.4397e6),
VENUS(4.869e+24, 6.0518e6);
private final double mass, radius;
Planet(double mass, double radius) { this.mass = mass; this.radius = radius; }
public double surfaceGravity() { return 6.67300E-11 * mass / (radius * radius); }
}
Sealed interfaces, from Java 17, make the opposite choice explicit: they deliberately close the type hierarchy so the compiler can check exhaustiveness. That is the right tool when you want every new case to force a review.
Key Takeaways
- Add behaviour by writing new code rather than editing working code.
- A conditional that grows with each requirement is the classic violation.
- Extract the varying behaviour behind an interface or a function.
- The stable abstraction is what the rest of the system depends on.
- Lambdas provide the same extension point without new files.
- You cannot be closed against every change; abstract the axis that actually varies.
- Sealed types intentionally invert the principle when exhaustiveness matters.
🧪 Practice
- Replace an
if/else ifchain over a type string with polymorphic classes.- Add a fourth variation and verify no existing file needed editing.
- Rewrite the same design using a lambda instead of an interface hierarchy.
- Interview: Can code be closed against all possible changes? (Hint: what must you know in advance for the abstraction to be in the right place?)
Liskov Substitution Principle
Barbara Liskov's principle governs inheritance: objects of a subtype must be usable anywhere the supertype is expected, without the caller noticing. If a caller must check the actual type before using an object, inheritance has been misused.
Java's compiler enforces the syntax of substitutability. It cannot enforce the semantics, which is where violations hide.
The textbook example is a rectangle and a square. Mathematically a square is a rectangle, so inheritance looks right:
public class Rectangle {
protected int width, height;
public void setWidth(int w) { this.width = w; }
public void setHeight(int h) { this.height = h; }
public int area() { return width * height; }
}
public class Square extends Rectangle {
@Override public void setWidth(int w) { this.width = w; this.height = w; }
@Override public void setHeight(int h) { this.width = h; this.height = h; }
}
The override is necessary to keep a square square, and it breaks every caller
written against Rectangle:
public void resize(Rectangle r) {
r.setWidth(5);
r.setHeight(4);
assert r.area() == 20; // holds for Rectangle, FAILS for Square (16)
}
The lesson is that an "is-a" relationship in the real world does not imply an
"is-substitutable-for" relationship in code. What matters is behaviour under
the supertype's contract, and Rectangle's contract implicitly allows width
and height to vary independently.
Formally, a subtype may not:
| Rule | Violation looks like |
|---|---|
| Strengthen preconditions | Rejecting inputs the parent accepted |
| Weaken postconditions | Returning less than the parent guaranteed |
| Throw new checked exceptions | Adding a failure the caller cannot expect |
| Break invariants | Square's linked width and height |
| Change expected behaviour | add that silently does nothing |
A precondition violation:
public class Account {
public void withdraw(double amount) { ... } // any positive amount
}
public class SavingsAccount extends Account {
@Override public void withdraw(double amount) {
if (amount > 500) throw new IllegalArgumentException("limit 500");
// STRENGTHENED precondition: code written for Account now fails
super.withdraw(amount);
}
}
The standard library has a well-known violation. List.add is specified to
append, but the fixed-size list from Arrays.asList throws:
List<String> list = Arrays.asList("a", "b");
list.add("c"); // UnsupportedOperationException: not substitutable
The usual symptom in your own code is a caller forced to test the type:
// If this appears, substitutability has already failed
if (shape instanceof Square) {
// special handling
}
Three ways to fix a violation.
Prefer composition. If the subtype cannot honour the contract, it is not a subtype:
public class Square {
private final Rectangle rect; // HAS-A, not IS-A
public Square(int side) { rect = new Rectangle(side, side); }
public int area() { return rect.area(); }
// no setWidth/setHeight to break
}
Make the supertype immutable. Without setters, the rectangle-square problem disappears entirely, because there is no way to observe the inconsistency.
public record Rectangle(int width, int height) {
public int area() { return width * height; }
}
public record Square(int side) {
public int area() { return side * side; }
}
Restructure the hierarchy so the common supertype holds only what both
genuinely share, such as a Shape interface with area().
Key Takeaways
- A subtype must be usable wherever the supertype is, with no caller changes.
- Java checks the syntax of substitutability but not the semantics.
- A real-world "is-a" does not guarantee substitutability in code.
- Subtypes must not strengthen preconditions or weaken postconditions.
- A caller that checks
instanceofis evidence of a violation.Arrays.asListreturning a list whoseaddthrows is a standard-library example.- Fix violations with composition, immutability, or a restructured hierarchy.
🧪 Practice
- Write the rectangle-square example and make an assertion fail.
- Rewrite it with composition so no assertion can fail.
- Find an override in your code that throws where the parent did not.
- Interview: Is a
SquareaRectangle? (Hint: answer separately for mutable and immutable versions, and say what changes.)
Interface Segregation Principle
The fourth principle says no client should be forced to depend on methods it does not use. Many small, focused interfaces beat one large one.
The cost of a fat interface is that every implementer must supply every method, even the meaningless ones, and every client recompiles when any method changes.
// A fat interface: assumes every worker is human
public interface Worker {
void work();
void eat();
void sleep();
}
public class Robot implements Worker {
public void work() { /* fine */ }
public void eat() { throw new UnsupportedOperationException(); } // smell
public void sleep() { throw new UnsupportedOperationException(); }
}
Those thrown exceptions are also a Liskov violation, which is typical: the principles reinforce one another.
Segregated into roles:
public interface Workable { void work(); }
public interface Eater { void eat(); }
public interface Sleeper { void sleep(); }
public class Human implements Workable, Eater, Sleeper { ... }
public class Robot implements Workable { ... } // implements only what fits
FAT INTERFACE SEGREGATED
Worker Workable Eater Sleeper
+work() ^ ^ ^
+eat() | | |
+sleep() Human ------+-------+
^ ^ |
| | Robot (Workable only)
Human Robot (must stub two)
The client's view matters as much as the implementer's. A class needing only
work() should depend on Workable, so it is unaffected when Eater changes:
// Depends on three methods, uses one
public class Scheduler {
public void schedule(Worker w) { w.work(); }
}
// Depends on exactly what it uses
public class Scheduler {
public void schedule(Workable w) { w.work(); }
}
Recognising violations:
- An implementation throws
UnsupportedOperationException. - An implementation leaves methods empty.
- A client uses two methods out of fifteen.
- Adding a method forces edits to classes that do not care about it.
Java's standard library shows both sides. The single-method interfaces in
java.util.function are maximally segregated. Collection is deliberately
broad, which is why immutable implementations must throw from add, and it is
the compromise that motivated default methods.
default methods do reduce the burden, since an implementer can inherit a
reasonable body rather than stubbing one. They are not a licence to grow
interfaces without limit: a client still depends on the full type.
public interface Repository<T> {
Optional<T> findById(String id);
// Not every store supports these; a default avoids forcing stubs
default List<T> findAll() { throw new UnsupportedOperationException(); }
}
The counterweight is fragmentation. Splitting every interface to one method produces dozens of types and obscures the concept they belong to. Segregate along client roles: if two methods are always used by the same callers, they belong together.
Key Takeaways
- Clients should not depend on methods they never call.
- A fat interface forces implementers to stub or throw from irrelevant methods.
- Thrown
UnsupportedOperationExceptionsignals both an ISP and a Liskov violation.- Split interfaces along the roles that clients actually use.
- Depend on the narrowest interface that covers your needs.
defaultmethods ease the burden but do not justify unbounded interfaces.- Over-segregation fragments a coherent concept into unrelated types.
🧪 Practice
- Find an interface with a method that some implementation stubs out, and split it.
- Narrow a method parameter from a broad interface to the narrowest one that compiles.
- Argue whether
java.util.Listviolates ISP, givenList.of.- Interview: How do
defaultmethods relate to this principle? (Hint: do they reduce what the implementer must write, what the client depends on, or both?)
Dependency Inversion Principle
The fifth principle has two clauses:
High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details; details should depend on abstractions.
The word inversion refers to reversing the direction of the conventional dependency. Normally the important code calls the plumbing, so it depends on it. Inversion makes the plumbing depend on an abstraction that the important code owns.
Before inversion, the business logic is chained to infrastructure:
// High-level policy depends directly on a low-level detail
public class OrderService {
private final MySqlOrderRepository repo = new MySqlOrderRepository();
private final SmtpEmailSender mailer = new SmtpEmailSender();
public void place(Order o) {
repo.insert(o);
mailer.send(o.customerEmail(), "Order placed");
}
}
Switching to Postgres, or testing without a mail server, means editing
OrderService, which holds the business rules that have nothing to do with
either.
After inversion, both sides depend on interfaces, and critically, the interfaces are defined by the high-level module in its own package:
// Owned by the domain, expressed in domain language
public interface OrderRepository { void save(Order o); }
public interface Notifier { void notifyPlaced(Order o); }
public class OrderService {
private final OrderRepository repo;
private final Notifier notifier;
public OrderService(OrderRepository repo, Notifier notifier) {
this.repo = repo;
this.notifier = notifier;
}
public void place(Order o) { repo.save(o); notifier.notifyPlaced(o); }
}
// Infrastructure depends INWARD on the domain's interface
public class MySqlOrderRepository implements OrderRepository { ... }
public class SmtpNotifier implements Notifier { ... }
BEFORE: dependency flows outward AFTER: it flows inward
OrderService OrderService
| |
v v
MySqlOrderRepository OrderRepository <-- domain owns this
^
| implements
MySqlOrderRepository
The arrow from infrastructure now points up toward the policy. That is the inversion, and it is what lets the domain be compiled and tested with no database on the classpath at all.
Who owns the interface is the detail people miss. An interface placed in the persistence package, and named after the database, has not inverted anything. The interface belongs to the consumer and is named in the consumer's language.
Dependency injection is the mechanism that supplies the implementations.
Constructor injection is the default, since it makes dependencies explicit and
allows final fields:
// Production wiring
var service = new OrderService(new MySqlOrderRepository(), new SmtpNotifier());
// Test wiring: no database, no mail server
var test = new OrderService(new InMemoryOrderRepository(), new FakeNotifier());
Note that dependency injection and dependency inversion are different things. Injection is a technique for supplying collaborators; inversion is a rule about which way dependencies point. You can inject a concrete class, which is injection without inversion.
| Term | Means |
|---|---|
| Dependency Inversion | Depend on abstractions owned by the consumer |
| Dependency Injection | Receive collaborators from outside |
| Inversion of Control | The framework calls you, not the reverse |
| IoC Container | A tool that constructs and wires the graph |
Chapter 13 covers how Spring automates the wiring. The principle stands without any framework: a constructor parameter is enough.
The limit is the same as always. Not every dependency needs inverting.
Depending on String, List, or LocalDate is fine, because they are stable.
Invert where the dependency is volatile: things that touch the network, the
filesystem, the clock, randomness, or third-party services.
Key Takeaways
- High-level policy and low-level details should both depend on abstractions.
- The abstraction must be owned and named by the high-level module.
- After inversion, infrastructure depends inward on the domain.
- This is what lets business logic be tested without a database.
- Dependency injection supplies collaborators; inversion decides the direction.
- Constructor injection is the default, since it permits
finalfields.- Invert volatile dependencies only; stable library types need no abstraction.
🧪 Practice
- Extract an interface for a repository and move it into the service's package.
- Write an in-memory implementation and test a service with no database.
- Identify which of your dependencies are volatile and which are stable.
- Interview: What is the difference between dependency injection and dependency inversion? (Hint: one is a technique, the other a direction. Can you have one without the other?)
Applying SOLID to Java Class Design
The five principles are usually taught separately and applied together. Working one example from a violation to a clean design shows how they interact, and where applying them all would be overkill.
Start with a class violating all five:
public class ReportGenerator {
public void generate(String type, List<Sale> sales) {
double total = 0;
for (Sale s : sales) total += s.amount(); // logic
String output;
if (type.equals("PDF")) output = toPdf(total); // OCP violation
else if (type.equals("HTML")) output = toHtml(total);
else output = String.valueOf(total);
// SRP violation: also persists and emails
new FileWriter("/reports/out.txt").write(output); // DIP violation
new SmtpClient().send("boss@corp.com", output);
}
}
Apply the principles in order.
SRP splits the responsibilities by stakeholder:
public class SalesTotaller { public Money total(List<Sale> sales) { ... } }
public interface ReportFormatter { String format(Money total); }
public interface ReportPublisher { void publish(String report); }
OCP and ISP make formats extensible without editing anything, each interface holding exactly what its clients use:
public class PdfFormatter implements ReportFormatter { ... }
public class HtmlFormatter implements ReportFormatter { ... }
// A new format is a new class, nothing else changes
DIP has the high-level generator own the interfaces and receive implementations:
public class ReportGenerator {
private final SalesTotaller totaller;
private final ReportFormatter formatter;
private final ReportPublisher publisher;
public ReportGenerator(SalesTotaller t, ReportFormatter f, ReportPublisher p) {
this.totaller = t; this.formatter = f; this.publisher = p;
}
public void generate(List<Sale> sales) {
publisher.publish(formatter.format(totaller.total(sales)));
}
}
LSP is satisfied by making every ReportFormatter genuinely
interchangeable: none throws where another returns, and none demands extra
setup.
The result is testable with no filesystem and no mail server:
var generator = new ReportGenerator(new SalesTotaller(),
new HtmlFormatter(),
report -> captured = report); // a lambda
Note the publisher supplied as a lambda. A single-method interface can be satisfied by one, which keeps the test short and shows why ISP and functional interfaces fit together.
How the principles relate:
| Principle | Governs | Answers |
|---|---|---|
| SRP | Class size | What belongs in this class? |
| OCP | Extension | How do I add behaviour safely? |
| LSP | Inheritance | Is this really a subtype? |
| ISP | Interface size | What should clients see? |
| DIP | Dependency direction | Which way do the arrows point? |
A closing warning, because SOLID is frequently over-applied. Every abstraction has a cost in indirection: more files, more jumps to follow a call, and harder first-time comprehension. A three-line script does not need five interfaces.
Sensible defaults:
- Start concrete. Extract an abstraction when the second case appears.
- Invert dependencies on the network, the filesystem, the clock, and randomness. Leave the rest.
- Split a class when two teams edit it for unrelated reasons.
- Prefer composition to inheritance, which makes LSP mostly moot.
- Let tests drive it: code that is hard to test usually violates DIP or SRP.
Key Takeaways
- The principles are applied together and reinforce one another.
- SRP decides class boundaries; ISP decides interface boundaries.
- OCP adds behaviour by extension; LSP keeps substitutes honest.
- DIP points dependencies at abstractions owned by the high-level module.
- A single-method interface can be satisfied by a lambda, which shortens tests.
- Every abstraction costs indirection, so apply them where change actually happens.
- Difficulty writing a unit test is the most reliable signal of a violation.
🧪 Practice
- Take a class that reads a file, computes, and prints, and apply all five principles.
- Write a test for the result using lambdas as the injected collaborators.
- Find an abstraction in your code that has one implementation and no test double, and justify removing it.
- Interview: Which SOLID principle does a class that is hard to unit test most often violate? (Hint: what makes a collaborator impossible to replace in a test?)
<a id="93-creational-patterns"></a>
9.3 Creational Patterns
This subchapter covers the patterns that control how objects are created, so that construction decisions do not spread through the code that uses them.
Singleton and Its Java Implementations
Some things should exist exactly once: a connection pool, a configuration registry, a cache. Singleton guarantees one instance and provides a global point of access to it.
The naive implementation has a concurrency bug:
public class Config {
private static Config instance;
private Config() { } // private: blocks `new`
public static Config getInstance() {
if (instance == null) { // two threads can BOTH see null
instance = new Config(); // and both construct an instance
}
return instance;
}
}
The standard fixes, in increasing order of quality.
Eager initialisation creates the instance when the class loads. The JVM guarantees class initialisation is thread-safe, so this is correct and simple. The cost is that the instance is built even if never used:
public class Config {
private static final Config INSTANCE = new Config(); // thread-safe by the JVM
private Config() { }
public static Config getInstance() { return INSTANCE; }
}
Double-checked locking defers creation but requires volatile to be
correct. Without it, another thread can observe a partially constructed object
through instruction reordering:
public class Config {
private static volatile Config instance; // volatile is MANDATORY here
private Config() { }
public static Config getInstance() {
if (instance == null) { // fast path: no lock
synchronized (Config.class) {
if (instance == null) { // re-check while holding the lock
instance = new Config();
}
}
}
return instance;
}
}
The holder idiom gets laziness with no synchronisation at all. The nested class is not loaded until first referenced, and class loading is already thread-safe:
public class Config {
private Config() { }
private static class Holder { // loaded on first access only
private static final Config INSTANCE = new Config();
}
public static Config getInstance() { return Holder.INSTANCE; }
}
The enum singleton is the form Joshua Bloch recommends. It is concise, thread-safe, and the only version that resists both serialisation attacks and reflection:
public enum Config {
INSTANCE;
private final Properties props = load();
public String get(String key) { return props.getProperty(key); }
}
Config.INSTANCE.get("db.url");
Eager simple, always created
DCL lazy, needs volatile, easy to get wrong
Holder lazy, no locking, idiomatic
Enum lazy-ish, safest, resists reflection and serialisation
| Approach | Thread-safe | Lazy | Reflection-proof | Verdict |
|---|---|---|---|---|
| Naive | No | Yes | No | Broken |
| Eager | Yes | No | No | Fine if cheap |
| Synchronised method | Yes | Yes | No | Slow on every call |
| Double-checked | Yes with volatile | Yes | No | Works, error-prone |
| Holder | Yes | Yes | No | Recommended |
| Enum | Yes | Yes | Yes | Best |
Singleton is the most criticised pattern in the catalogue, and the criticism is largely fair:
- It is global mutable state, so any code can change it and any bug can come from anywhere.
- It hides dependencies. A class calling
Config.getInstance()has a dependency invisible in its constructor. - It is hard to test, because the instance persists between tests and cannot be replaced with a fake.
- It defeats DIP, since callers depend on a concrete class by name.
The usual better answer is a single instance managed by a container or created
once in main and injected:
// Not a singleton class, just one instance, injected where needed
var config = new Config(properties);
var service = new OrderService(config);
Spring beans are singleton-scoped by default and give the "one instance" property without the global access point, which is why the pattern appears far less often in modern Java.
Key Takeaways
- Singleton guarantees one instance with a global access point.
- The naive lazy version is not thread-safe.
- Double-checked locking requires
volatileor it is broken.- The holder idiom gives lazy, lock-free initialisation.
- The enum singleton is the safest and resists reflection and serialisation.
- Singletons are global state, hide dependencies, and complicate testing.
- Prefer one injected instance over a class that enforces its own singularity.
🧪 Practice
- Implement the holder idiom and prove the instance is created on first access.
- Break the naive version by calling
getInstancefrom many threads.- Convert a singleton in your code into an injected dependency.
- Interview: Why is the enum singleton immune to reflection attacks? (Hint: what does
Constructor.newInstancedo when the type is an enum?)
Factory Method
Direct use of new couples a class to the exact type it creates. Factory
Method defines an operation for creating an object and lets subclasses decide
which class to instantiate.
The problem, stated concretely: a class that both orchestrates a workflow and picks the concrete type has two reasons to change, and a new type means editing it.
public class LogisticsPlanner {
public void planDelivery() {
Truck truck = new Truck(); // welded to Truck
truck.deliver();
}
}
The pattern moves creation into an overridable method:
public abstract class Logistics {
// The factory method: subclasses decide the concrete type
protected abstract Transport createTransport();
// The algorithm, written against the abstraction, never changes
public void planDelivery() {
Transport t = createTransport();
t.deliver();
}
}
public class RoadLogistics extends Logistics {
@Override protected Transport createTransport() { return new Truck(); }
}
public class SeaLogistics extends Logistics {
@Override protected Transport createTransport() { return new Ship(); }
}
Logistics Transport
+planDelivery() ---uses--> +deliver()
#createTransport() ^
^ ^ |
| | +----+----+
RoadLog. SeaLog. | |
creates creates Truck Ship
| | ^ ^
+----------|--------------+ |
+------------------------+
The key structural point: planDelivery depends only on Transport, so adding
air freight means adding two classes and editing none. That is the Open-Closed
Principle expressed as a creation mechanism.
A lighter variant, sometimes called a parameterised factory or simple factory, puts the decision in one static method rather than a hierarchy. It is not the Gang of Four pattern, but it is far more common in practice:
public class TransportFactory {
public static Transport create(String mode) {
return switch (mode) {
case "road" -> new Truck();
case "sea" -> new Ship();
default -> throw new IllegalArgumentException(mode);
};
}
}
This centralises the switch in one place rather than scattering it, though
the switch itself still needs editing for each new type. A registry avoids even
that:
public class TransportFactory {
private static final Map<String, Supplier<Transport>> REGISTRY =
new ConcurrentHashMap<>();
public static void register(String key, Supplier<Transport> creator) {
REGISTRY.put(key, creator); // new types register themselves
}
public static Transport create(String key) {
Supplier<Transport> s = REGISTRY.get(key);
if (s == null) throw new IllegalArgumentException(key);
return s.get();
}
}
TransportFactory.register("air", Plane::new); // no edit to the factory
Use Factory Method when:
- The class cannot know in advance which type it must create.
- Creation involves logic beyond a constructor call.
- You want subclasses to extend which products are produced.
- The creation decision should live in one place rather than at every call site.
In the standard library, Calendar.getInstance() and
NumberFormat.getInstance(Locale) are factory methods: the returned concrete
type depends on the locale and is not named by the caller.
The cost is a parallel hierarchy. Every product often gains a matching creator,
which is a lot of classes for a small gain. With lambdas, a Supplier<T>
frequently does the same job in one line, and that is the modern default for
simple cases.
Key Takeaways
- Factory Method delegates instantiation to a method that subclasses override.
- The calling algorithm depends only on the product abstraction.
- Adding a product means adding classes, not editing existing ones.
- The simple static factory centralises a
switchbut still needs editing.- A registry of suppliers removes even that edit.
Calendar.getInstanceis a standard-library example.- The cost is a parallel creator hierarchy; a
Supplieroften suffices.
🧪 Practice
- Write a
Logisticshierarchy with road and sea subclasses.- Add an air variant and verify no existing class needed changing.
- Replace the hierarchy with a
Map<String, Supplier<Transport>>registry.- Interview: How does Factory Method differ from a static factory method? (Hint: which one involves inheritance, and which one is just a named constructor?)
Abstract Factory
Factory Method creates one product. Abstract Factory creates families of related products and guarantees the members of a family are used together.
The motivating problem is consistency. Imagine a user interface supporting two themes. Mixing a dark button with a light checkbox produces a broken screen, and nothing in the type system prevents it:
Button button = new DarkButton();
Checkbox box = new LightCheckbox(); // compiles, but visually wrong
Abstract Factory makes the mismatch impossible by having one object produce the whole family:
// The abstract factory: one method per product in the family
public interface UiFactory {
Button createButton();
Checkbox createCheckbox();
}
public class DarkUiFactory implements UiFactory {
public Button createButton() { return new DarkButton(); }
public Checkbox createCheckbox() { return new DarkCheckbox(); }
}
public class LightUiFactory implements UiFactory {
public Button createButton() { return new LightButton(); }
public Checkbox createCheckbox() { return new LightCheckbox(); }
}
// Client code knows neither theme nor concrete widget types
public class Screen {
private final Button button;
private final Checkbox checkbox;
public Screen(UiFactory factory) {
this.button = factory.createButton(); // guaranteed to match
this.checkbox = factory.createCheckbox();
}
}
Screen screen = new Screen(userPrefersDark ? new DarkUiFactory()
: new LightUiFactory());
UiFactory Button Checkbox
+createButton() ^ ^
+createCheckbox() | |
^ ^ +-----+-----+ +-----+-----+
| | | | | |
DarkUiF. LightUiF. Dark Light Dark Light
| \ | \ Button Button Checkbox Checkbox
| \ | +-----------------------------------> (creates)
+----\----+----------------------> (creates)
The defining property is that the factory picks one column of the product matrix, so every object created comes from the same family.
The classic Java example is JDBC. A Connection is an abstract factory
producing statements and metadata that all belong to the same database driver:
Connection conn = DriverManager.getConnection(url); // picks the family
Statement stmt = conn.createStatement(); // a matching product
PreparedStatement ps = conn.prepareStatement(sql); // another
DocumentBuilderFactory and TransformerFactory in the XML APIs follow the
same shape.
The distinction from Factory Method is a standard interview question:
| Aspect | Factory Method | Abstract Factory |
|---|---|---|
| Products created | One | A family of related products |
| Mechanism | Inheritance, an overridden method | Composition, an object with several methods |
| Varies by | Subclass | The factory instance passed in |
| Typical size | One method | One method per product |
The costs are real and explain why it appears less often than the simpler patterns.
Adding a new product type to the family, a Slider for example, requires
editing the factory interface and every implementation. The pattern is open for
new families and closed against new product types, which is the opposite of
what people often assume.
It also multiplies classes quickly: with three families and four products, you have twelve product classes plus four factory types.
Use it when a system must be configured with one of several families, when products in a family are genuinely required to work together, and when the family set changes more often than the product set. Otherwise a simple factory or dependency injection is usually enough, since a DI container supplies matched implementations by configuration without a bespoke factory hierarchy.
Key Takeaways
- Abstract Factory creates families of related products through one interface.
- It guarantees products used together come from the same family.
- The client depends only on the abstract factory and abstract products.
- JDBC's
Connectionproducing statements is a standard-library example.- Factory Method uses inheritance for one product; Abstract Factory uses composition for many.
- Adding a family is easy; adding a product type forces edits everywhere.
- Dependency injection often achieves the same result with less machinery.
🧪 Practice
- Build a
UiFactorywith two themes and a client that cannot mix them.- Add a third theme and confirm no client code changed.
- Add a
Sliderproduct and count the files you had to edit.- Interview: Why is Abstract Factory not open for extension in every direction? (Hint: compare adding a new family with adding a new product type.)
Builder for Complex Objects
Constructors handle a few parameters well. Beyond that they fail in two ways: nobody can read the call site, and optional parameters require an explosion of overloads.
// What do these arguments mean? Which are optional? Are any swapped?
Pizza p = new Pizza(12, true, false, true, false, true, "thin");
// The telescoping alternative: one constructor per combination
public Pizza(int size) { ... }
public Pizza(int size, boolean cheese) { ... }
public Pizza(int size, boolean cheese, boolean pepperoni) { ... }
// ...and so on, unreadably
A JavaBeans-style approach with setters solves readability but creates two new problems: the object is temporarily inconsistent between calls, and it cannot be immutable.
Builder separates construction from representation. A mutable builder collects the parameters, then produces an immutable object in one step:
public class Pizza {
private final int size; // all final: immutable result
private final boolean cheese;
private final boolean pepperoni;
private final String crust;
private Pizza(Builder b) { // private: only the builder constructs
this.size = b.size;
this.cheese = b.cheese;
this.pepperoni = b.pepperoni;
this.crust = b.crust;
}
public static Builder builder(int size) { return new Builder(size); }
public static class Builder {
private final int size; // required: taken in the constructor
private boolean cheese = false; // optional: sensible defaults
private boolean pepperoni = false;
private String crust = "regular";
public Builder(int size) { this.size = size; }
// Each setter returns `this`, which is what enables chaining
public Builder cheese() { this.cheese = true; return this; }
public Builder pepperoni() { this.pepperoni = true; return this; }
public Builder crust(String crust) { this.crust = crust; return this; }
public Pizza build() {
if (size < 6) throw new IllegalArgumentException("too small");
return new Pizza(this); // validate, then construct once
}
}
}
The call site now documents itself:
Pizza pizza = Pizza.builder(12)
.cheese()
.pepperoni()
.crust("thin")
.build(); // the object exists only when fully specified
TELESCOPING BUILDER
new Pizza(12,t,f,t,f,t,"thin") builder(12).cheese().crust("thin").build()
unreadable, order-sensitive named, order-independent, validated once
Builder's advantages over setters are worth stating precisely:
| Property | Setters | Builder |
|---|---|---|
| Readable call site | Yes | Yes |
| Immutable result | No | Yes |
| Object never half-built | No | Yes |
| Validation in one place | No | Yes |
| Required parameters enforced | No | Yes, via the builder's constructor |
Java's standard library uses the pattern widely:
var client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.connectTimeout(Duration.ofSeconds(10))
.build();
Stream.Builder<String> sb = Stream.builder();
var calendar = new Calendar.Builder().setDate(2026, 0, 1).build();
Two Java-specific notes.
For a small number of fields, a record is usually better. It is immutable, validated in the compact constructor, and needs no builder at all. Reach for a builder when there are many optional fields or the construction is genuinely multi-step.
public record Pizza(int size, boolean cheese, String crust) {
public Pizza { // compact constructor
if (size < 6) throw new IllegalArgumentException("too small");
}
}
Libraries such as Lombok generate builders from an annotation, removing the boilerplate that is the pattern's main cost:
@Builder
public class Pizza { private final int size; private final String crust; }
The downsides: roughly twice the code for a hand-written builder, an extra
object per construction, and the fact that missing a required field is caught
at runtime in build() rather than at compile time, unless you take required
fields in the builder's constructor as above.
Key Takeaways
- Builder replaces telescoping constructors and setter-based construction.
- A mutable builder produces an immutable, fully validated object.
- Each builder method returns
this, enabling a readable chain.- Required parameters belong in the builder's constructor.
- Validation happens once, inside
build().HttpClient.newBuilder()is a standard-library example.- Prefer a record for a few fields; use a builder for many optional ones.
🧪 Practice
- Convert a class with a six-argument constructor into a builder.
- Enforce a required field by taking it in the builder's constructor.
- Add validation to
build()and write a test that expects the exception.- Interview: Why does a builder produce a more robust object than a set of setters? (Hint: at what moments can the object be observed in an invalid state under each approach?)
Prototype and Object Copying
Sometimes creating an object from scratch is expensive: it required a database query, a network call, or heavy parsing. Prototype creates new objects by copying an existing configured instance rather than constructing one.
The pattern is simple in intent: keep a fully prepared exemplar, and clone it whenever you need another.
public interface Prototype<T> { T copy(); }
public class Document implements Prototype<Document> {
private final String template; // expensive to load
private final List<String> sections;
private String title;
@Override
public Document copy() {
Document d = new Document(this.template);
d.sections = new ArrayList<>(this.sections); // copy, do not share
d.title = this.title;
return d;
}
}
Document base = loadExpensiveTemplate(); // done once
Document invoice = base.copy(); // cheap
Java has a built-in mechanism, Cloneable and Object.clone(), which is
widely regarded as a design mistake. It is worth knowing because it appears in
older code:
public class Point implements Cloneable { // a marker interface, no methods
private int x, y;
@Override
public Point clone() {
try {
return (Point) super.clone(); // native field-by-field copy
} catch (CloneNotSupportedException e) {
throw new AssertionError(e); // unreachable: we are Cloneable
}
}
}
Its problems are substantial: Cloneable declares no clone method, so the
interface does not advertise the capability; clone is protected on
Object; it throws a checked exception that cannot happen; it bypasses
constructors, so final fields and invariants are not enforced; and the
default copy is shallow.
The shallow versus deep distinction is the part that causes real bugs. A shallow copy duplicates the object but shares its referenced objects:
SHALLOW COPY DEEP COPY
original ---> [ list ] <--- copy original ---> [ list A ]
copy ---> [ list B ]
mutating through either fully independent
reference affects both
Order original = new Order(new ArrayList<>(List.of("item1")));
Order shallow = original.shallowCopy();
shallow.items().add("item2");
System.out.println(original.items()); // [item1, item2]: shared list!
Ways to copy properly in modern Java:
// 1. A copy constructor: explicit, readable, no Cloneable
public Order(Order other) {
this.id = other.id;
this.items = new ArrayList<>(other.items); // deep where it matters
}
// 2. A static factory, which reads better at the call site
public static Order copyOf(Order other) { return new Order(other); }
// 3. A record's wither-style method for one changed field
public record Config(String host, int port) {
public Config withPort(int newPort) { return new Config(host, newPort); }
}
// 4. Serialisation round-trip: deep, but slow and fragile
Immutability removes the problem entirely. If an object cannot change, sharing it is safe and copying is unnecessary:
public record Point(int x, int y) { } // no copy needed, ever
Point p2 = p1; // sharing is safe
Use Prototype when object creation is genuinely expensive, when you need many objects differing slightly from a configured baseline, or when the concrete class is decided at runtime and you hold an instance rather than a type.
| Approach | Deep by default | Recommended |
|---|---|---|
Object.clone() | No | No |
| Copy constructor | You decide | Yes |
Static copyOf | You decide | Yes |
Record with... | Not needed | Yes |
| Serialisation | Yes | Rarely |
Key Takeaways
- Prototype creates objects by copying a configured instance.
- It pays off when construction is expensive or the type is chosen at runtime.
Cloneableandclone()are a flawed design and should be avoided.clonebypasses constructors, so invariants andfinalfields are not enforced.- A shallow copy shares referenced objects; mutations are visible through both.
- Prefer a copy constructor or a static
copyOffactory.- Immutable objects need no copying at all.
🧪 Practice
- Write a class with a
Listfield and show a shallow copy sharing it.- Fix it with a copy constructor that duplicates the list.
- Add a wither method to a record that changes one component.
- Interview: Why is
Cloneableconsidered a design mistake? (Hint: look at which methods the interface actually declares.)
Static Factory Methods
A static factory method is a static method that returns an instance of its class. It is not one of the Gang of Four patterns, but Joshua Bloch lists it as Item 1 of Effective Java, and it is the creational technique you will use most often.
The first advantage is that methods have names and constructors do not. A class needing two constructors with the same parameter types cannot have them; static factories can:
// Impossible as constructors: same erasure, same parameter list
public static Complex fromCartesian(double re, double im) { ... }
public static Complex fromPolar(double r, double theta) { ... }
Names also make call sites self-documenting:
BigInteger probablePrime = BigInteger.probablePrime(512, random);
// versus a constructor whose boolean flag means "prime"
The second advantage is that a factory is not required to create a new object. It may return a cached instance, which a constructor can never do:
Integer i = Integer.valueOf(127); // returns a cached instance
Boolean b = Boolean.valueOf(true); // always returns Boolean.TRUE
The third is that it may return any subtype, so the concrete class can stay private and change without breaking callers:
// The caller sees List; the implementation is an unexported class
List<String> l = List.of("a", "b");
// Collections returns different private implementations by size
Set<String> s = Collections.singleton("x");
The fourth is that the returned class can vary by argument:
// EnumSet returns RegularEnumSet or JumboEnumSet depending on the enum size
EnumSet<Day> days = EnumSet.of(Day.MON, Day.TUE);
And the fifth is cleaner generics, though var and the diamond operator have
reduced this benefit:
Map<String, List<Order>> m = HashMap.newHashMap(16); // Java 19+
Widely used naming conventions, which are worth following because readers expect them:
| Name | Meaning |
|---|---|
of | Aggregates arguments: List.of("a", "b") |
from | Converts one type to another: Date.from(instant) |
valueOf | A more verbose from: Integer.valueOf("42") |
getInstance | Returns an instance, possibly cached |
newInstance | Guarantees a distinct new instance |
copyOf | A copy of the argument: List.copyOf(list) |
create | A new instance, no caching implied |
getXxx / newXxx | Factory on a different class, such as Files.newInputStream |
A worked example combining the benefits:
public final class Temperature {
private final double celsius;
private Temperature(double celsius) { this.celsius = celsius; } // no `new`
public static Temperature ofCelsius(double c) { return new Temperature(c); }
public static Temperature ofFahrenheit(double f) {
return new Temperature((f - 32) * 5 / 9); // named, so no ambiguity
}
public static Temperature absoluteZero() { return ZERO; } // cached constant
private static final Temperature ZERO = new Temperature(-273.15);
}
The two limitations Bloch names:
- A class with only private constructors cannot be subclassed. That is often a benefit, since it forces composition, but it is a genuine restriction.
- Static factories are harder to find in documentation than constructors, which have their own section. Consistent naming is what mitigates this.
Static factories combine naturally with the other patterns here: a builder is usually reached through one, and a singleton's accessor is one.
Key Takeaways
- A static factory method is a named alternative to a constructor.
- Names distinguish factories that would otherwise have identical signatures.
- A factory may return a cached instance rather than always constructing.
- It may return any subtype, keeping implementation classes private.
- The returned class can vary by argument, as
EnumSetdoes.- Follow the conventional names:
of,from,valueOf,copyOf,getInstance.- A class with only private constructors cannot be subclassed.
🧪 Practice
- Add
ofCelsiusandofFahrenheitfactories to a temperature class.- Cache and return a shared instance for a common value, and prove it with
==.- Rename three factory methods in your code to match the conventions.
- Interview: Name two things a static factory can do that a constructor cannot. (Hint: think about the returned object's identity and its exact type.)
<a id="94-structural-patterns"></a>
9.4 Structural Patterns
This subchapter covers the patterns that compose classes and objects into larger structures while keeping the pieces independently replaceable.
Adapter
Two pieces of code that should work together often have incompatible interfaces: a third-party library, a legacy class, or a service whose API you cannot change. Adapter wraps one interface in another so the client can use it unchanged.
The analogy is the travel plug. The appliance and the socket both work; they simply do not fit. The adapter changes the shape without changing either side.
Suppose your application is written against a PaymentProcessor interface, and
a third-party library offers something similar but different:
// What your code expects
public interface PaymentProcessor {
void pay(double amount, String currency);
}
// What the library provides: you cannot edit this
public class LegacyGateway {
public void makePayment(int cents, int currencyCode) { ... }
}
The adapter implements your interface and translates:
public class LegacyGatewayAdapter implements PaymentProcessor {
private final LegacyGateway adaptee; // the wrapped object
public LegacyGatewayAdapter(LegacyGateway adaptee) { this.adaptee = adaptee; }
@Override
public void pay(double amount, String currency) {
int cents = (int) Math.round(amount * 100); // translate units
int code = Currency.getInstance(currency).getNumericCode(); // and types
adaptee.makePayment(cents, code);
}
}
// The client never learns that LegacyGateway exists
PaymentProcessor processor = new LegacyGatewayAdapter(new LegacyGateway());
processor.pay(19.99, "EUR");
Client ---> PaymentProcessor (target)
^
| implements
LegacyGatewayAdapter
|
| wraps (composition)
v
LegacyGateway (adaptee)
There are two forms. The object adapter above uses composition and is almost always the right choice. The class adapter uses inheritance and is limited in Java, since you can extend only one class:
// Class adapter: only possible when the target is an interface
public class ClassAdapter extends LegacyGateway implements PaymentProcessor {
@Override public void pay(double amount, String currency) {
makePayment((int) (amount * 100), 978); // inherited method
}
}
| Form | Mechanism | Can adapt subclasses | Flexibility |
|---|---|---|---|
| Object adapter | Composition | Yes, any instance | High |
| Class adapter | Inheritance | No, one fixed class | Low |
The Java library is full of adapters:
// Arrays.asList adapts an array to the List interface
List<String> list = Arrays.asList(array);
// InputStreamReader adapts a byte stream to a character stream
Reader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8);
// Collections.list adapts a legacy Enumeration to a List
List<String> l = Collections.list(enumeration);
InputStreamReader is the clearest example: bytes and characters are genuinely
different abstractions, and the adapter performs the decoding that bridges
them.
Adapter is also the natural partner of the Dependency Inversion Principle. Your domain declares the interface it wants; the adapter sits in the infrastructure layer and makes a third-party type satisfy it. That keeps vendor types out of your business logic entirely, so replacing the vendor means writing one new adapter.
Two cautions. An adapter that grows logic beyond translation has become a service and should be renamed. And a chain of adapters, each adapting the previous, is a sign the abstractions are wrong rather than that more adapters are needed.
Key Takeaways
- Adapter converts one interface into another that clients already expect.
- It lets incompatible code work together without modifying either side.
- The object adapter uses composition and is preferred in Java.
- The class adapter uses inheritance and is limited to one adaptee class.
Arrays.asListandInputStreamReaderare standard-library adapters.- Adapter keeps third-party types out of the domain layer.
- An adapter containing business logic has outgrown the pattern.
🧪 Practice
- Write an adapter that makes a third-party logging class satisfy your own logging interface.
- Adapt a class whose method returns an array into one returning a
List.- Explain why
InputStreamReaderis an adapter rather than a decorator.- Interview: Why is the object adapter usually preferred over the class adapter? (Hint: how many classes can a Java class extend?)
Decorator
Adding behaviour to a class by subclassing it does not scale. With three optional features you need seven subclasses to cover every combination, and the choice is fixed at compile time.
SUBCLASS EXPLOSION
Coffee
CoffeeWithMilk
CoffeeWithSugar
CoffeeWithMilkAndSugar
CoffeeWithWhip
CoffeeWithMilkAndWhip ... 2^n classes
Decorator attaches responsibilities to an object dynamically by wrapping it. Each decorator implements the same interface as the thing it wraps, so wrappers nest to any depth and the client cannot tell the difference.
public interface Coffee {
double cost();
String description();
}
public class SimpleCoffee implements Coffee {
public double cost() { return 2.00; }
public String description() { return "coffee"; }
}
// The base decorator holds a reference to the wrapped component
public abstract class CoffeeDecorator implements Coffee {
protected final Coffee wrapped;
protected CoffeeDecorator(Coffee wrapped) { this.wrapped = wrapped; }
}
public class Milk extends CoffeeDecorator {
public Milk(Coffee c) { super(c); }
public double cost() { return wrapped.cost() + 0.50; } // add to the inner
public String description() { return wrapped.description() + " + milk"; }
}
public class Sugar extends CoffeeDecorator {
public Sugar(Coffee c) { super(c); }
public double cost() { return wrapped.cost() + 0.20; }
public String description() { return wrapped.description() + " + sugar"; }
}
Combinations are now built at runtime, in any order and any number:
Coffee order = new Sugar(new Milk(new SimpleCoffee()));
System.out.println(order.description()); // "coffee + milk + sugar"
System.out.println(order.cost()); // 2.70
NESTED WRAPPERS
+-------------------------------+
| Sugar |
| +-------------------------+ |
| | Milk | | cost() delegates inward,
| | +-------------------+ | | then adds its own amount
| | | SimpleCoffee 2.00 | | |
| | +-------------------+ | |
| | + 0.50 | |
| +-------------------------+ |
| + 0.20 |
+-------------------------------+
Java's I/O library is the canonical example, and recognising it makes the whole package easier to read:
InputStream in = new GZIPInputStream( // adds decompression
new BufferedInputStream( // adds buffering
new FileInputStream("data.gz"))); // the component
Each wrapper adds one capability, and any order that makes sense is legal.
Collections.unmodifiableList is a decorator too: it wraps a list and adds the
behaviour of rejecting modification.
Decorator is easily confused with its neighbours:
| Pattern | Interface of the wrapper | Purpose |
|---|---|---|
| Decorator | Same as the component | Add behaviour |
| Adapter | Different | Make an incompatible type fit |
| Proxy | Same as the component | Control access |
| Facade | New and simpler | Hide a complex subsystem |
The distinguishing test for Decorator is that the wrapper has the same interface and adds something, whereas a Proxy has the same interface and controls access without adding features.
The costs are genuine. A deeply wrapped object is hard to debug, since a stack
trace shows many near-identical frames. Identity is lost, so equals and ==
on the outer object do not match the inner one. And many small classes can
obscure the overall behaviour.
In modern Java, a function chain often replaces a simple decorator:
Function<String, String> pipeline = ((Function<String, String>) String::trim)
.andThen(String::toLowerCase);
Use the class-based pattern when the decorated type has several methods or must remain a domain type.
Key Takeaways
- Decorator adds behaviour by wrapping an object at runtime.
- The wrapper implements the same interface, so wrappers nest freely.
- It replaces an exponential number of subclasses with composable pieces.
- Java's I/O streams are the canonical example.
Collections.unmodifiableListdecorates a list with a restriction.- Decorator adds behaviour; Proxy controls access; Adapter changes the interface.
- Deep nesting complicates debugging and breaks object identity.
🧪 Practice
- Build a coffee pricing example with three decorators and combine them.
- Wrap a
FileInputStreamin a buffered and a compressed stream and explain the order.- Write a decorator that logs every call before delegating.
- Interview: How do you tell a Decorator from a Proxy when both wrap and implement the same interface? (Hint: ask what the wrapper adds versus what it withholds.)
Composite
Some structures are inherently recursive: a folder contains files and folders, a menu contains items and submenus, a UI panel contains widgets and panels. Writing client code that constantly checks "is this a leaf or a container?" produces conditionals everywhere.
// Without Composite: the client must know the difference
public long totalSize(Object node) {
if (node instanceof File f) return f.size();
if (node instanceof Folder d) {
long sum = 0;
for (Object child : d.children()) sum += totalSize(child); // recursion
return sum;
}
throw new IllegalArgumentException();
}
Composite composes objects into tree structures and lets clients treat individual objects and compositions uniformly. Both leaf and container implement one interface.
public interface FileSystemNode {
String name();
long size(); // both leaves and folders answer this
}
// Leaf: no children
public record FileNode(String name, long size) implements FileSystemNode { }
// Composite: holds children of the same interface type
public class FolderNode implements FileSystemNode {
private final String name;
private final List<FileSystemNode> children = new ArrayList<>();
public FolderNode(String name) { this.name = name; }
public String name() { return name; }
public FolderNode add(FileSystemNode child) {
children.add(child); // accepts files AND folders
return this;
}
@Override
public long size() {
return children.stream().mapToLong(FileSystemNode::size).sum();
// recursion is implicit: each child answers for itself
}
}
The client now has no conditionals at all:
FileSystemNode root = new FolderNode("root")
.add(new FileNode("a.txt", 100))
.add(new FolderNode("sub")
.add(new FileNode("b.txt", 250)));
System.out.println(root.size()); // 350: works the same for a file or folder
FileSystemNode
+name() +size()
^ ^
| |
FileNode FolderNode
(leaf) children: List<FileSystemNode>
|
+--------+--------+
| |
FileNode FolderNode
|
FileNode
The uniformity is the whole point: size() on a leaf returns a number, and on
a composite it aggregates its children, but the caller does not care.
The design question the pattern raises is where to put child-management
methods such as add and remove.
Transparent puts them on the shared interface, so every node has them. This maximises uniformity but forces leaves to implement operations that make no sense, which violates the Interface Segregation Principle.
Safe puts them only on the composite, as shown above. Leaves stay clean,
but a client holding a FileSystemNode must downcast to add a child.
| Variant | add lives on | Uniformity | Type safety |
|---|---|---|---|
| Transparent | The interface | High | Low |
| Safe | The composite | Lower | High |
Most modern Java code chooses the safe variant, because throwing
UnsupportedOperationException from a leaf is exactly the smell discussed
under ISP.
Standard-library and framework examples include the AWT and Swing component
hierarchy, where Container is both a Component and a holder of components,
and the composite structure of XML or JSON documents.
Sealed interfaces pair well with Composite when the set of node types is fixed, because pattern matching then becomes exhaustive:
public sealed interface Node permits FileNode, FolderNode { }
long size = switch (node) {
case FileNode f -> f.size();
case FolderNode d -> d.children().stream().mapToLong(this::size).sum();
}; // no default needed: the compiler knows the cases are complete
Two cautions: the shared interface tends to drift toward the lowest common denominator, and deep trees risk stack overflow with naive recursion.
Key Takeaways
- Composite represents part-whole hierarchies as trees.
- Leaves and containers implement the same interface, so clients need no type checks.
- A composite's operation delegates to its children, making recursion implicit.
- The transparent variant puts
addon the interface and weakens leaves.- The safe variant keeps
addon the composite and is usually preferred.- Swing's component hierarchy and document trees are real examples.
- Watch for an interface diluted to the lowest common denominator.
🧪 Practice
- Model a file system with files and folders and compute a total size.
- Add a
print(int depth)operation that renders the tree with indentation.- Implement both the safe and transparent variants and compare their drawbacks.
- Interview: What does Composite let the client avoid writing? (Hint: what kind of statement disappears from the traversal code?)
Facade
A subsystem with many classes is powerful but exhausting to use. Clients must know which objects to create, in what order, and how they interrelate. Every client repeats that knowledge, and every change to the subsystem breaks them all.
Facade provides a single simplified interface to a complex subsystem.
// The subsystem: four classes, a specific order, easy to get wrong
public class VideoFile { ... }
public class CodecFactory { public Codec extract(VideoFile f) { ... } }
public class BitrateReader {
public byte[] read(VideoFile f, Codec c) { ... }
public byte[] convert(byte[] buffer, Codec c) { ... }
}
public class AudioMixer { public byte[] fix(byte[] result) { ... } }
Without a facade, every caller carries the full procedure:
VideoFile file = new VideoFile(name);
Codec source = CodecFactory.extract(file);
Codec dest = format.equals("mp4") ? new Mp4Codec() : new OggCodec();
byte[] buffer = BitrateReader.read(file, source);
byte[] result = BitrateReader.convert(buffer, dest);
result = new AudioMixer().fix(result);
The facade reduces that to one call:
public class VideoConverter {
public File convert(String fileName, String format) {
VideoFile file = new VideoFile(fileName);
Codec source = CodecFactory.extract(file);
Codec dest = format.equals("mp4") ? new Mp4Codec() : new OggCodec();
byte[] buffer = BitrateReader.read(file, source);
byte[] result = BitrateReader.convert(buffer, dest);
return save(new AudioMixer().fix(result));
}
}
File mp4 = new VideoConverter().convert("holiday.ogg", "mp4");
WITHOUT FACADE WITH FACADE
Client --+--> VideoFile Client ---> VideoConverter
+--> CodecFactory |
+--> BitrateReader +--> VideoFile
+--> AudioMixer +--> CodecFactory
+--> BitrateReader
knows 4 classes and the order knows 1 class +--> AudioMixer
The defining characteristic is that a facade introduces a new, simpler interface rather than reusing an existing one. That is what separates it from Adapter, which converts to an interface the client already has.
An important property: a facade does not hide the subsystem. Clients with unusual needs can still use the classes directly. A facade that forbids that, by making everything package-private, becomes a bottleneck when someone needs the twenty percent of functionality it does not expose.
Real examples:
// javax.faces and java.net.URL both act as facades over layered subsystems
URL url = new URL("https://example.com");
InputStream in = url.openStream(); // hides sockets, protocols, and streams
// SLF4J is a facade over multiple logging implementations
Logger log = LoggerFactory.getLogger(MyClass.class);
// A service class is usually a facade over repositories and domain objects
public class CheckoutService {
public Receipt checkout(Cart cart) {
validator.validate(cart);
Payment p = gateway.charge(cart.total());
inventory.reserve(cart.items());
return receipts.create(cart, p);
}
}
That last one is worth noticing: most well-written application service classes are facades, whether or not anyone calls them that.
| Pattern | Interface | Intent |
|---|---|---|
| Facade | New and simpler | Hide subsystem complexity |
| Adapter | An existing one | Convert an incompatible type |
| Mediator | New | Coordinate peers that would otherwise talk directly |
The risk is the god facade. As features accumulate, a facade can grow into a class that does everything and violates SRP. If it exceeds a few cohesive operations, split it by use case.
Key Takeaways
- Facade offers one simplified entry point to a complex subsystem.
- It removes knowledge of subsystem classes and their call order from clients.
- It defines a new interface, unlike Adapter which conforms to an existing one.
- The subsystem should remain accessible for advanced clients.
java.net.URLand SLF4J'sLoggerFactoryare real examples.- Most application service classes are facades over repositories and domain logic.
- Facades tend to grow; split them when they stop being cohesive.
🧪 Practice
- Write a facade over three classes that must be called in a fixed order.
- Show that a client can still bypass the facade when it needs to.
- Identify a facade in a framework you use and list what it hides.
- Interview: How does a Facade differ from an Adapter? (Hint: does the client interface already exist, or is it newly invented?)
Proxy
A Proxy is a stand-in that implements the same interface as a real object and controls access to it. The client cannot tell the difference, which is exactly what lets the proxy add laziness, access checks, caching, logging, or remote communication.
The shape is identical to Decorator. The difference is intent: a decorator adds behaviour, a proxy manages access to something that may be expensive, remote, or restricted.
The most common variant is the virtual proxy, which defers creating an expensive object until it is genuinely needed:
public interface Image { void display(); }
public class RealImage implements Image {
private final String filename;
public RealImage(String filename) {
this.filename = filename;
loadFromDisk(); // expensive, happens in the constructor
}
private void loadFromDisk() { System.out.println("loading " + filename); }
public void display() { System.out.println("displaying " + filename); }
}
public class ImageProxy implements Image {
private final String filename;
private RealImage real; // created only on first use
public ImageProxy(String filename) { this.filename = filename; }
@Override
public void display() {
if (real == null) {
real = new RealImage(filename); // lazy initialisation
}
real.display();
}
}
Image image = new ImageProxy("photo.jpg"); // nothing loaded yet
image.display(); // loads, then displays
image.display(); // reuses the loaded image
Client ---> Image (interface)
^ ^
| |
ImageProxy RealImage
| ^
+------------+ holds and controls access
The recognised variants:
| Variant | Controls |
|---|---|
| Virtual proxy | Creation cost, by deferring it |
| Protection proxy | Access, by checking permissions |
| Remote proxy | Location, by hiding network communication |
| Caching proxy | Repetition, by storing previous results |
| Smart reference | Lifecycle, by counting references or locking |
A protection proxy:
public class SecureDocumentProxy implements Document {
private final Document real;
private final User user;
public String read() {
if (!user.hasRole("READER")) throw new AccessDeniedException();
return real.read(); // delegate only if permitted
}
}
Java has first-class support through dynamic proxies, which generate a proxy class at runtime for any set of interfaces:
Document proxy = (Document) Proxy.newProxyInstance(
Document.class.getClassLoader(),
new Class<?>[]{ Document.class },
(p, method, args) -> { // an InvocationHandler
System.out.println("calling " + method.getName());
return method.invoke(realDocument, args); // delegate
});
This is the mechanism behind a great deal of framework behaviour. Spring's
@Transactional works by wrapping your bean in a proxy that opens a
transaction, calls your method, and commits or rolls back. Its well-known
limitation follows directly: a call from one method of a class to another
method of the same class bypasses the proxy, so the annotation has no effect.
@Service
public class OrderService {
@Transactional public void outer() { inner(); } // bypasses the proxy
@Transactional public void inner() { ... } // NOT transactional here
}
Hibernate uses the same technique for lazy loading, which is why accessing an
uninitialised association outside a session throws LazyInitializationException.
The costs: an extra indirection on every call, more complex stack traces, and
the subtlety that a proxy's identity differs from the real object's, so ==
and sometimes equals behave unexpectedly.
Key Takeaways
- Proxy implements the same interface as the real object and controls access to it.
- Decorator adds behaviour; Proxy manages access, cost, or location.
- Virtual proxies defer expensive creation until first use.
- Protection proxies enforce permissions before delegating.
java.lang.reflect.Proxygenerates proxies at runtime from interfaces.- Spring's
@Transactionaland Hibernate's lazy loading are proxy-based.- Self-invocation bypasses a proxy, which is why internal calls skip the annotation.
🧪 Practice
- Write a virtual proxy that defers loading and prove the load happens once.
- Write a protection proxy that rejects calls from an unauthorised user.
- Use
Proxy.newProxyInstanceto log every method call on an interface.- Interview: Why does calling one
@Transactionalmethod from another in the same class not start a transaction? (Hint: which object is the caller actually invoking the method on?)
Bridge
Sometimes a hierarchy varies along two independent dimensions at once. Modelling both with inheritance multiplies classes, because every combination needs its own subclass.
INHERITANCE EXPLOSION: shape x rendering
Shape
Circle CircleVector CircleRaster
Square -> SquareVector SquareRaster
Triangle TriangleVector TriangleRaster
m shapes x n renderers = m*n classes
Bridge separates an abstraction from its implementation so the two can vary independently. Rather than one inheriting from the other, the abstraction holds a reference to the implementation.
// The implementation side: how drawing happens
public interface Renderer {
void renderCircle(double x, double y, double r);
void renderSquare(double x, double y, double side);
}
public class VectorRenderer implements Renderer {
public void renderCircle(double x, double y, double r) {
System.out.println("vector circle r=" + r);
}
public void renderSquare(double x, double y, double s) { ... }
}
public class RasterRenderer implements Renderer {
public void renderCircle(double x, double y, double r) {
System.out.println("raster pixels for circle r=" + r);
}
public void renderSquare(double x, double y, double s) { ... }
}
// The abstraction side: what is being drawn
public abstract class Shape {
protected final Renderer renderer; // the BRIDGE to the other axis
protected Shape(Renderer renderer) { this.renderer = renderer; }
public abstract void draw();
}
public class Circle extends Shape {
private final double x, y, radius;
public Circle(Renderer renderer, double x, double y, double radius) {
super(renderer);
this.x = x; this.y = y; this.radius = radius;
}
@Override public void draw() { renderer.renderCircle(x, y, radius); }
}
Either axis now grows independently:
Shape c1 = new Circle(new VectorRenderer(), 0, 0, 5);
Shape c2 = new Circle(new RasterRenderer(), 0, 0, 5); // same shape, new renderer
c1.draw();
c2.draw();
BRIDGE: m + n classes, not m * n
Shape (abstraction) <>------------> Renderer (implementor)
^ ^
| |
+-+----+-------+ +------+------+
| | | | |
Circle Square Triangle VectorRenderer RasterRenderer
The pattern is frequently confused with Adapter and Strategy. The distinctions:
| Pattern | When applied | Purpose |
|---|---|---|
| Bridge | Designed upfront | Let two dimensions vary independently |
| Adapter | Applied after | Make incompatible types work together |
| Strategy | Either | Swap one algorithm at runtime |
Structurally Bridge and Strategy look nearly identical, since both hold a reference to an interface. The difference is scope and intent: Strategy varies a single algorithm, while Bridge separates an entire hierarchy from an entire implementation hierarchy, and is usually planned from the start.
The classic real example is JDBC. The java.sql interfaces are the
abstraction; each vendor's driver is an implementation. Your code varies
queries and connections freely, while the driver varies independently:
// Same abstraction, different implementations, no client changes
Connection c = DriverManager.getConnection("jdbc:mysql://...");
Connection p = DriverManager.getConnection("jdbc:postgresql://...");
The same shape appears in SLF4J, where the logging API is the abstraction and Logback or Log4j the implementation, and in AWT's peer architecture, where components bridge to platform-specific widgets.
The costs: an extra level of indirection, and the need to identify the two dimensions correctly upfront. Applying Bridge where only one dimension actually varies adds complexity for nothing, so wait until the second axis is real.
Key Takeaways
- Bridge separates an abstraction from its implementation so both can vary.
- It replaces an m by n class explosion with m plus n classes.
- The abstraction holds a reference to the implementor instead of inheriting.
- Bridge is usually designed upfront; Adapter is applied to existing code.
- Bridge and Strategy look alike; Bridge separates whole hierarchies.
- JDBC and SLF4J are large-scale examples of the pattern.
- Do not apply it until a genuine second dimension of variation exists.
🧪 Practice
- Implement shapes and renderers as a bridge and combine them freely.
- Add a third renderer and confirm no shape class changed.
- Count the classes needed with and without the pattern for four shapes and three renderers.
- Interview: How does Bridge differ from Strategy when the code looks the same? (Hint: compare what varies on each side, one algorithm or a whole hierarchy.)
<a id="95-behavioral-patterns"></a>
9.5 Behavioral Patterns
This subchapter covers the patterns that govern how objects communicate and how responsibility for an algorithm is distributed between them.
Strategy
A method that selects between several algorithms with a conditional has to be edited every time a new algorithm appears, and the algorithms cannot be tested or reused independently.
public class Sorter {
public void sort(int[] data, String algorithm) {
if (algorithm.equals("quick")) { /* 30 lines */ }
else if (algorithm.equals("merge")) { /* 30 lines */ }
else if (algorithm.equals("bubble")){ /* 20 lines */ }
// one class, three algorithms, one enormous method
}
}
Strategy defines a family of interchangeable algorithms, encapsulates each one, and makes them swappable at runtime. The context holds a strategy and delegates to it.
public interface SortStrategy {
void sort(int[] data);
}
public class QuickSort implements SortStrategy {
public void sort(int[] data) { /* ... */ }
}
public class MergeSort implements SortStrategy {
public void sort(int[] data) { /* ... */ }
}
// The context: knows nothing about any specific algorithm
public class Sorter {
private SortStrategy strategy;
public Sorter(SortStrategy strategy) { this.strategy = strategy; }
public void setStrategy(SortStrategy s) { this.strategy = s; } // swap at runtime
public void sort(int[] data) { strategy.sort(data); }
}
Sorter sorter = new Sorter(new QuickSort());
sorter.sort(data);
sorter.setStrategy(new MergeSort()); // changed without touching Sorter
Sorter (context) <>------> SortStrategy
^
+------------+------------+
| | |
QuickSort MergeSort BubbleSort
Strategy is the purest expression of the Open-Closed Principle: the context is closed for modification, and the family of algorithms is open for extension.
In modern Java a single-method strategy is usually a lambda, and no interface hierarchy is needed at all:
public class Sorter {
public void sort(int[] data, IntBinaryOperator comparator) { ... }
}
// Strategy as a lambda
List<String> names = new ArrayList<>(List.of("bob", "alice"));
names.sort(Comparator.comparing(String::length)); // a strategy
names.sort((a, b) -> b.compareTo(a)); // a different one
Comparator is the standard library's canonical Strategy: Collections.sort
delegates the comparison decision entirely to the object you pass. Other
examples include ThreadPoolExecutor's RejectedExecutionHandler and the
Predicate passed to removeIf.
A practical example where the class form still earns its place, because each strategy needs its own state and configuration:
public interface ShippingStrategy {
Money cost(Order order);
}
public class StandardShipping implements ShippingStrategy {
public Money cost(Order o) { return Money.of(5.00); }
}
public class ExpressShipping implements ShippingStrategy {
private final double surcharge; // per-strategy state
public ExpressShipping(double surcharge) { this.surcharge = surcharge; }
public Money cost(Order o) { return Money.of(15.00 + surcharge); }
}
public class Checkout {
public Money total(Order order, ShippingStrategy shipping) {
return order.subtotal().plus(shipping.cost(order));
}
}
Strategy is often confused with State and with Template Method:
| Pattern | Who chooses | What varies |
|---|---|---|
| Strategy | The client | A whole algorithm |
| State | The object itself | Behaviour as state changes |
| Template Method | The subclass | Steps within a fixed skeleton |
The distinction from State is the useful one: with Strategy the client picks and the strategies do not know about each other; with State the object transitions between states itself.
The costs are that clients must know the strategies exist in order to choose one, and that trivial strategies add classes for little benefit. Use a lambda when the strategy is one function with no state, and a class when it carries configuration, needs a name, or must be tested on its own.
Key Takeaways
- Strategy encapsulates interchangeable algorithms behind one interface.
- The context delegates and never changes when a new algorithm is added.
- It is the clearest application of the Open-Closed Principle.
- A single-method strategy is usually better expressed as a lambda.
Comparatoris the standard library's canonical Strategy.- Use a class when the strategy holds state or configuration.
- The client must know enough about the strategies to choose one.
🧪 Practice
- Build a payment processor with card, bank transfer, and wallet strategies.
- Swap the strategy at runtime and confirm the context was not modified.
- Rewrite a single-method strategy as a lambda and compare readability.
- Interview: How is Strategy different from State? (Hint: who decides which implementation is active, and do the implementations know about each other?)
Observer
When one object changes, several others may need to react. Hard-coding the notifications couples the source to every consumer and requires editing it whenever a consumer is added.
public class Stock {
private final EmailAlerter email; // knows every consumer by name
private final Dashboard dashboard;
private final AuditLog log;
public void setPrice(double p) {
this.price = p;
email.send(p); // a new consumer means editing here
dashboard.update(p);
log.record(p);
}
}
Observer defines a one-to-many dependency so that when one object changes state, all its dependents are notified automatically. The subject knows only that observers exist, not who they are.
public interface PriceObserver {
void onPriceChange(String symbol, double price);
}
public class Stock {
private final List<PriceObserver> observers = new CopyOnWriteArrayList<>();
private double price;
public void subscribe(PriceObserver o) { observers.add(o); }
public void unsubscribe(PriceObserver o) { observers.remove(o); }
public void setPrice(double price) {
this.price = price;
notifyObservers(); // push the change outward
}
private void notifyObservers() {
for (PriceObserver o : observers) {
o.onPriceChange(symbol, price); // the subject knows only the interface
}
}
}
// Observers register themselves; Stock never mentions them
stock.subscribe((sym, p) -> System.out.println(sym + " now " + p));
stock.subscribe(new AuditLog()::record);
Stock (subject)
observers: List<PriceObserver>
|
notifies | (subject knows only the interface)
+--------------+-----+------+--------------+
v v v v
EmailAlerter Dashboard AuditLog (added later, no edit)
Note the CopyOnWriteArrayList. Observers frequently unsubscribe from inside a
notification, which would throw ConcurrentModificationException with an
ArrayList. This is a real bug, not a theoretical one.
Two delivery models:
| Model | The subject sends | Trade-off |
|---|---|---|
| Push | The changed data | Simple; may send more than needed |
| Pull | Only "something changed" | Observers query; more round trips |
Java's original java.util.Observable and Observer were deprecated in Java
9 and should not be used. Observable is a class, so it consumes the single
inheritance slot, it is not thread-safe, and the notification order is
unspecified. Modern options:
// 1. A hand-rolled listener list, as above: clear and dependency-free
// 2. PropertyChangeSupport from java.beans
private final PropertyChangeSupport support = new PropertyChangeSupport(this);
support.addPropertyChangeListener(evt -> System.out.println(evt.getNewValue()));
support.firePropertyChange("price", oldPrice, newPrice);
// 3. Flow API (Java 9), reactive streams with backpressure
SubmissionPublisher<Double> publisher = new SubmissionPublisher<>();
publisher.subscribe(new Flow.Subscriber<>() { ... });
// 4. Framework events, such as Spring's ApplicationEventPublisher
The pattern underpins a great deal of familiar code: Swing's
addActionListener, JavaFX properties and bindings, the DOM's
addEventListener, and every message bus.
Three hazards worth knowing.
Memory leaks. A subject holds strong references to observers, so an observer that never unsubscribes cannot be collected. Long-lived subjects with short-lived observers are the usual leak source.
Notification storms. An observer that updates state which triggers another notification can cascade or loop. Guard against re-entrancy.
Unpredictable order and failure. Observers are notified in an unspecified order, and an exception from one can prevent the rest from running unless you catch per observer.
for (PriceObserver o : observers) {
try { o.onPriceChange(symbol, price); }
catch (RuntimeException e) { log.warn("observer failed", e); } // isolate
}
Key Takeaways
- Observer notifies many dependents automatically when a subject changes.
- The subject depends only on an interface, so observers can be added freely.
- Use a
CopyOnWriteArrayListso observers may unsubscribe during notification.- Push sends the data; pull sends only a signal and lets observers query.
java.util.Observableis deprecated; use listeners,Flow, or framework events.- Observers that never unsubscribe cause memory leaks.
- Isolate observer exceptions so one failure does not block the rest.
🧪 Practice
- Build a stock price subject with two lambda observers.
- Unsubscribe an observer from inside its own callback and handle the result.
- Add exception isolation and prove a failing observer does not block others.
- Interview: How can Observer cause a memory leak? (Hint: who holds a reference to whom, and what happens if nobody calls unsubscribe?)
Template Method
Several algorithms often share the same overall shape while differing in a few steps. Copying the skeleton into each variant duplicates the sequence, and a change to the order must then be made everywhere.
Template Method defines the skeleton of an algorithm in a base class and lets subclasses override specific steps without changing the structure.
public abstract class DataImporter {
// The TEMPLATE METHOD: final, so the sequence cannot be altered
public final void importData(Path file) {
String raw = readFile(file); // shared step
List<Record> records = parse(raw); // varies: abstract
validate(records); // shared, but overridable hook
save(records); // varies: abstract
onComplete(records.size()); // optional hook, empty by default
}
// Shared implementation
private String readFile(Path file) { ... }
// Steps subclasses MUST supply
protected abstract List<Record> parse(String raw);
protected abstract void save(List<Record> records);
// Hooks: subclasses MAY override
protected void validate(List<Record> records) { /* default: nothing */ }
protected void onComplete(int count) { }
}
public class CsvImporter extends DataImporter {
@Override protected List<Record> parse(String raw) { /* split on commas */ }
@Override protected void save(List<Record> r) { /* insert into SQL */ }
}
public class JsonImporter extends DataImporter {
@Override protected List<Record> parse(String raw) { /* Jackson */ }
@Override protected void save(List<Record> r) { /* write to Mongo */ }
@Override protected void validate(List<Record> r) { /* extra checks */ }
}
DataImporter.importData() <-- fixed sequence, declared final
|
+-- readFile() shared
+-- parse() abstract ---> CsvImporter / JsonImporter supply
+-- validate() hook ---> optional override
+-- save() abstract ---> subclasses supply
+-- onComplete() hook ---> optional override
Three details make the pattern work correctly.
The template method should be final, so subclasses cannot change the
sequence. That is the invariant the pattern exists to protect.
Steps should be protected, not public. They are extension points for
subclasses, not part of the class's public interface.
Hooks with empty default bodies let subclasses opt in without forcing every one to implement them, which keeps the abstract surface small.
This is an application of the Hollywood Principle: "don't call us, we'll call you". The framework owns the flow and calls into your code, rather than your code calling the framework. That inversion is what distinguishes a framework from a library.
Standard-library and framework examples:
// AbstractList implements the algorithms; you supply get() and size()
public class Range extends AbstractList<Integer> {
private final int n;
public Range(int n) { this.n = n; }
@Override public Integer get(int i) { return i; } // the varying step
@Override public int size() { return n; }
// indexOf, contains, iterator, subList all work from these two
}
AbstractList, AbstractMap, InputStream, JUnit's setUp and tearDown,
and Spring's JdbcTemplate all use the pattern.
The comparison with Strategy is the usual interview question:
| Aspect | Template Method | Strategy |
|---|---|---|
| Mechanism | Inheritance | Composition |
| Binding time | Compile time | Runtime |
| Varies | Steps inside a skeleton | The whole algorithm |
| Coupling | Subclass to superclass | Context to interface |
| Changeable later | No, fixed at compile time | Yes, swappable |
Template Method's weakness is inheritance itself: a subclass depends on the protected structure of its parent, so changing the base class can break every subclass, and you get only one inheritance slot.
The composition-based alternative passes the varying steps as functions, which is often preferable in modern Java:
public final class DataImporter {
public void importData(Path file,
Function<String, List<Record>> parser,
Consumer<List<Record>> saver) {
saver.accept(parser.apply(readFile(file)));
}
}
Key Takeaways
- Template Method fixes an algorithm's skeleton and defers steps to subclasses.
- Declare the template method
finalso the sequence cannot be changed.- Abstract steps are mandatory; hooks with empty bodies are optional.
- Extension points should be
protected, not public.- It embodies the Hollywood Principle, where the framework calls your code.
AbstractListand JUnit lifecycle methods are real examples.- Template Method binds at compile time via inheritance; Strategy binds at runtime via composition.
🧪 Practice
- Write an abstract report generator with a fixed sequence and two subclasses.
- Add an optional hook and override it in only one subclass.
- Rewrite the same design using functions passed as parameters.
- Interview: Why should the template method be declared
final? (Hint: what guarantee does the base class lose if a subclass overrides it?)
Command
Calling a method directly means the call happens immediately, once, and cannot be recorded, queued, or undone. Command turns a request into an object, so it can be passed around, stored, scheduled, logged, and reversed.
The analogy is a restaurant order slip. The waiter writes the request down; the slip can be queued, re-read, handed to a different cook, or cancelled. The customer and the cook never interact directly.
public interface Command {
void execute();
void undo(); // what makes undo/redo possible
}
// The receiver: the object that does the real work
public class Document {
private final StringBuilder text = new StringBuilder();
public void append(String s) { text.append(s); }
public void deleteLast(int n) { text.delete(text.length() - n, text.length()); }
public String content() { return text.toString(); }
}
// A concrete command captures the receiver AND the parameters
public class AppendCommand implements Command {
private final Document doc;
private final String textToAdd;
public AppendCommand(Document doc, String textToAdd) {
this.doc = doc;
this.textToAdd = textToAdd;
}
@Override public void execute() { doc.append(textToAdd); }
@Override public void undo() { doc.deleteLast(textToAdd.length()); }
}
The invoker stores commands, which is what enables history:
public class Editor {
private final Deque<Command> history = new ArrayDeque<>();
public void run(Command c) {
c.execute();
history.push(c); // remember it for undo
}
public void undo() {
if (!history.isEmpty()) history.pop().undo();
}
}
Editor editor = new Editor();
Document doc = new Document();
editor.run(new AppendCommand(doc, "Hello"));
editor.run(new AppendCommand(doc, " world"));
editor.undo(); // "Hello"
Client ---creates---> Command (interface)
^
Invoker ---stores--------+|
(history) |
AppendCommand ---calls---> Document (receiver)
The four roles are worth naming, because interviews ask for them: the client creates commands, the invoker triggers them, the command encapsulates the request, and the receiver performs the work.
What Command buys you:
| Capability | How the pattern provides it |
|---|---|
| Undo/redo | Each command knows how to reverse itself |
| Queuing | Commands are objects, so they can wait in a list |
| Logging | Serialise commands to replay after a crash |
| Scheduling | Execute later, or on another thread |
| Macros | A composite command holding a list of commands |
| Decoupling | The invoker never learns what the command does |
A macro command is Composite applied to Command:
public class MacroCommand implements Command {
private final List<Command> commands;
public void execute() { commands.forEach(Command::execute); }
public void undo() {
// Reverse order matters: undo the last action first
for (int i = commands.size() - 1; i >= 0; i--) commands.get(i).undo();
}
}
In Java the interface is functional in spirit, and Runnable is effectively a
command without undo. That is exactly how executors use it:
ExecutorService pool = Executors.newFixedThreadPool(4);
pool.submit(() -> processOrder(id)); // a command, queued and run later
Other examples include Swing's Action, which bundles a command with its label
and icon, and the request objects in messaging systems.
The costs: a class per operation, which is why undo-less commands are usually
lambdas, and the fact that correct undo is genuinely hard. A command must
capture enough state to reverse itself, and for operations that lose
information, such as overwriting text, it must store the old value.
Key Takeaways
- Command turns a request into an object with its own lifetime.
- The four roles are client, invoker, command, and receiver.
- Storing executed commands in a stack gives undo and redo.
- Commands can be queued, logged, replayed, and scheduled.
- A macro command composes several commands, undoing them in reverse order.
Runnablesubmitted to an executor is a command without undo.- Implementing
undocorrectly requires capturing the state being replaced.
🧪 Practice
- Build a text editor with append commands and a working undo stack.
- Add a macro command that groups three operations into one undo step.
- Implement a command that overwrites a value and correctly restores it.
- Interview: What must a command store to support undo? (Hint: consider an operation that replaces a value rather than adding one.)
State
An object whose behaviour depends on its internal state usually accumulates conditionals in every method, and the legal transitions are scattered and implicit.
public class Order {
private String state = "NEW";
public void pay() {
if (state.equals("NEW")) state = "PAID";
else if (state.equals("PAID")) throw new IllegalStateException();
else if (state.equals("SHIPPED")) throw new IllegalStateException();
}
public void ship() {
if (state.equals("PAID")) state = "SHIPPED";
else throw new IllegalStateException();
}
// every method repeats the same branching
}
State allows an object to alter its behaviour when its internal state changes, by moving each state's behaviour into its own class. The object appears to change class.
public interface OrderState {
OrderState pay(Order order); // returns the NEXT state
OrderState ship(Order order);
OrderState cancel(Order order);
}
public class NewState implements OrderState {
public OrderState pay(Order o) { o.chargeCard(); return new PaidState(); }
public OrderState ship(Order o) { throw new IllegalStateException("unpaid"); }
public OrderState cancel(Order o) { return new CancelledState(); }
}
public class PaidState implements OrderState {
public OrderState pay(Order o) { throw new IllegalStateException("paid"); }
public OrderState ship(Order o) { o.dispatch(); return new ShippedState(); }
public OrderState cancel(Order o) { o.refund(); return new CancelledState(); }
}
public class ShippedState implements OrderState {
public OrderState pay(Order o) { throw new IllegalStateException(); }
public OrderState ship(Order o) { throw new IllegalStateException(); }
public OrderState cancel(Order o) { throw new IllegalStateException("in transit"); }
}
// The context delegates everything and holds only the current state
public class Order {
private OrderState state = new NewState();
public void pay() { state = state.pay(this); } // no conditionals left
public void ship() { state = state.ship(this); }
public void cancel() { state = state.cancel(this); }
}
STATE MACHINE made explicit
pay() ship()
NEW --------> PAID --------> SHIPPED
| |
| cancel() | cancel() (refund)
v v
CANCELLED <-----+
The gain is that each state class holds the complete rules for one state, and the legal transitions are visible in the return types rather than buried in conditionals. Adding a state means adding a class.
Java's enums make a compact alternative when states carry no extra data, and this is the form most Java codebases use:
public enum OrderState {
NEW {
@Override public OrderState pay() { return PAID; }
},
PAID {
@Override public OrderState ship() { return SHIPPED; }
},
SHIPPED;
// Defaults reject everything; each constant permits only what it allows
public OrderState pay() { throw new IllegalStateException(name()); }
public OrderState ship() { throw new IllegalStateException(name()); }
}
The enum version gives exhaustive switch support, serialisation, and a natural place to define the full set of states, at the cost of not allowing per-state fields of different types.
State is structurally identical to Strategy, and the difference is worth stating precisely:
| Aspect | Strategy | State |
|---|---|---|
| Who selects it | The client | The object, internally |
| Implementations know each other | No | Yes, they name successors |
| Changes over time | Only if the client swaps it | Continuously, by transition |
| Models | An algorithm choice | A lifecycle |
Where to put the transition logic is the pattern's main design decision. Returning the next state from the state object, as above, keeps each state's rules together. The alternative has the context decide, which centralises the machine but reintroduces conditionals.
The cost is a class per state, so for two or three states a simple enum with a switch is usually clearer. The pattern pays off when there are many states, many operations, or complex transition rules.
Key Takeaways
- State moves behaviour that depends on a mode into one class per mode.
- The context delegates and holds only the current state object.
- Conditionals disappear because each state implements only its own rules.
- Transitions become explicit, typically by returning the next state.
- Java enums with per-constant bodies are a compact alternative.
- State and Strategy look identical; State transitions itself, Strategy is chosen by the client.
- For two or three simple states, an enum and a switch are clearer.
🧪 Practice
- Model a vending machine with idle, has-coin, and dispensing states.
- Implement the same machine as an enum with per-constant transitions.
- Make an illegal transition throw and write a test proving it.
- Interview: Why do State implementations know about each other when Strategy implementations do not? (Hint: who is responsible for deciding what comes next?)
Iterator and Visitor
The last two patterns both concern traversal, and they answer opposite questions. Iterator asks how to walk a structure without exposing it. Visitor asks how to add operations to a structure without editing it.
Iterator provides sequential access to the elements of an aggregate without revealing how it is stored. It was covered mechanically in chapter 7; here it matters as a design decision.
Exposing the internal collection leaks the representation and lets callers mutate it:
public class Playlist {
private final List<Song> songs = new ArrayList<>();
public List<Song> getSongs() { return songs; } // leaks; callers can clear it
}
Implementing Iterable gives traversal without exposure:
public class Playlist implements Iterable<Song> {
private final List<Song> songs = new ArrayList<>();
@Override
public Iterator<Song> iterator() {
return Collections.unmodifiableList(songs).iterator(); // read-only walk
}
}
for (Song s : playlist) { ... } // works because of Iterable
A custom iterator can also traverse something that is not a collection at all, which is where the pattern earns its place:
public class Fibonacci implements Iterable<Long> {
private final int count;
public Fibonacci(int count) { this.count = count; }
@Override
public Iterator<Long> iterator() {
return new Iterator<>() {
private long a = 0, b = 1;
private int emitted = 0;
@Override public boolean hasNext() { return emitted < count; }
@Override public Long next() {
if (!hasNext()) throw new NoSuchElementException();
long result = a;
long next = a + b; a = b; b = next; // advance the state
emitted++;
return result;
}
};
}
}
for (long f : new Fibonacci(8)) System.out.print(f + " "); // 0 1 1 2 3 5 8 13
Visitor separates an algorithm from the object structure it operates on. The motivation is the "expression problem": with ordinary polymorphism, adding a new type is easy and adding a new operation means editing every class. Visitor reverses that trade.
public interface Visitor {
void visit(Circle c);
void visit(Square s);
}
public interface Shape {
void accept(Visitor v); // double dispatch: the shape calls back
}
public record Circle(double radius) implements Shape {
public void accept(Visitor v) { v.visit(this); } // picks visit(Circle)
}
public record Square(double side) implements Shape {
public void accept(Visitor v) { v.visit(this); }
}
// A new OPERATION is a new class; no shape is edited
public class AreaVisitor implements Visitor {
private double total;
public void visit(Circle c) { total += Math.PI * c.radius() * c.radius(); }
public void visit(Square s) { total += s.side() * s.side(); }
public double total() { return total; }
}
AreaVisitor area = new AreaVisitor();
shapes.forEach(s -> s.accept(area));
The accept call is double dispatch: the first dispatch selects the shape's
accept, and the second selects the visitor's overload. Java resolves overloads
at compile time, so this round trip is what makes the right method run.
shape.accept(visitor)
|
| dispatch 1: which Shape? -> Circle.accept
v
visitor.visit(this)
|
| dispatch 2: which overload? -> visit(Circle)
v
the correct operation
The trade-off, stated plainly:
| Change | Ordinary polymorphism | Visitor |
|---|---|---|
| Add a new type | Easy, one new class | Hard, edit every visitor |
| Add a new operation | Hard, edit every class | Easy, one new visitor |
So Visitor suits a stable set of types with a growing set of operations, such as compiler passes over a fixed abstract syntax tree.
Modern Java offers a lighter alternative. Sealed interfaces with pattern
matching give the same "add an operation without editing the types" property,
with compiler-checked exhaustiveness and no accept boilerplate:
public sealed interface Shape permits Circle, Square { }
double area = switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Square s -> s.side() * s.side();
}; // exhaustive: adding a permitted type breaks this switch at compile time
For new code this is usually the better choice. Classic Visitor remains relevant in older codebases and in libraries that must work across Java versions.
Key Takeaways
- Iterator gives sequential access without exposing the underlying structure.
- Implementing
Iterableenables the enhancedforloop over your own types.- A custom iterator can traverse computed sequences, not just collections.
- Visitor separates operations from the structure they act on.
- It uses double dispatch through
acceptto select the right overload.- Visitor makes new operations easy and new types hard, reversing the usual trade.
- Sealed interfaces with pattern matching achieve the same goal with compiler-checked exhaustiveness.
🧪 Practice
- Make a custom class implement
Iterableand use it in a for-each loop.- Write an iterator over a computed sequence such as primes.
- Implement area and perimeter visitors over a shape hierarchy.
- Rewrite the same operations with a sealed interface and a switch.
- Interview: Why does Visitor need double dispatch? (Hint: when does Java decide which overload of
visitto call, and what does it know at that moment?)
<a id="96-uml-and-modeling"></a>
9.6 UML and Modeling
This subchapter covers the diagram notation used to discuss designs, and the process of turning a written requirement into a class model.
Class Diagram Notation
Design discussions happen on whiteboards, and UML is the shared notation for them. You rarely need the full specification; a small subset covers almost every real conversation, and interviews expect you to read and draw it.
A class is a box with three compartments: name, attributes, operations.
+---------------------------+
| Account | <- class name (italic if abstract)
+---------------------------+
| - balance: BigDecimal | <- attributes: visibility name: type
| - owner: String |
| + MAX_LIMIT: int {static} |
+---------------------------+
| + deposit(amount: Money) | <- operations
| + withdraw(amount: Money) |
| - validate(): boolean |
+---------------------------+
Visibility markers map directly onto Java's access modifiers:
| Symbol | UML visibility | Java equivalent |
|---|---|---|
+ | public | public |
- | private | private |
# | protected | protected |
~ | package | package-private |
Notation conventions worth knowing:
- Types follow the name after a colon:
balance: BigDecimal. This reads backwards from Java and trips people up. {static}or underlining marks static members.- Italics mark abstract classes and abstract methods.
- A stereotype in guillemets labels the kind of element:
<<interface>>,<<abstract>>,<<enumeration>>,<<record>>.
+---------------------------+
| <<interface>> |
| Repository |
+---------------------------+
| + save(entity: T): void |
| + findById(id: ID): T |
+---------------------------+
The Java that corresponds to the account box above:
public class Account {
public static final int MAX_LIMIT = 10_000; // + and {static}
private BigDecimal balance; // -
private String owner; // -
public void deposit(Money amount) { } // +
public void withdraw(Money amount) { } // +
private boolean validate() { return true; } // -
}
Diagrams should be partial on purpose. Showing every field and getter produces a picture nobody reads. Include only what the conversation needs: for a discussion about dependencies, class names and relationships are often enough, with both other compartments left empty.
Minimal but useful for an architecture discussion
+----------------+ +-------------------+ +--------------+
| OrderService |----->| OrderRepository |<|----| JpaOrderRepo |
+----------------+ | <<interface>> | +--------------+
+-------------------+
Other elements that appear regularly:
- A note is a dog-eared box attached by a dashed line, used for constraints or explanations.
- A package is a tabbed folder shape grouping related classes.
- A constraint in braces states a rule:
{balance >= 0}.
Tools such as PlantUML let you keep diagrams in version control as text, which is worth preferring over images that drift from the code:
@startuml
interface Repository {
+save(entity: T)
}
class JpaRepository
Repository <|.. JpaRepository
@enduml
Key Takeaways
- A class box has three compartments: name, attributes, and operations.
- Visibility uses
+,-,#, and~for public, private, protected, and package.- Types follow the member name after a colon, the reverse of Java.
- Italics mark abstract classes and methods;
{static}or underlining marks static.- Stereotypes in guillemets label interfaces, enums, and records.
- Leave out detail that the discussion does not need.
- Text-based tools keep diagrams versioned alongside the code.
🧪 Practice
- Draw a class box for a
Bookclass with three fields and two methods.- Translate a given UML box into compiling Java, including visibility.
- Draw an interface box with a stereotype and one operation.
- Interview: What does an italic class name mean in UML? (Hint: what cannot be instantiated?)
Modeling Associations and Multiplicity
Boxes alone say nothing. The lines between them carry most of a diagram's information: who knows about whom, how many, and who owns whose lifetime.
An association is a plain line meaning one class holds a reference to another. An arrowhead makes it one-directional.
+---------+ +---------+
| Student |------------->| Course | Student knows Course
+---------+ +---------+ (a unidirectional association)
public class Student {
private List<Course> courses; // the reference the line represents
}
Multiplicity labels each end with how many instances participate:
| Notation | Meaning |
|---|---|
1 | Exactly one |
0..1 | Zero or one |
* | Zero or more |
1..* | One or more |
0..* | Same as * |
2..5 | A specific range |
+---------+ 1 0..* +---------+
| Order |---------------| OrderLine|
+---------+ +---------+
Read: one Order has zero or more OrderLines,
each OrderLine belongs to exactly one Order.
public class Order {
private final List<OrderLine> lines = new ArrayList<>(); // 0..*
}
public class OrderLine {
private final Order order; // 1
}
Two special associations express ownership, and the distinction matters because it tells you about object lifetimes.
Aggregation, a hollow diamond, is a "has-a" where the part can outlive the whole. A department has employees, but deleting the department does not delete the people.
Composition, a filled diamond, is a "part-of" where the part cannot exist without the whole. Delete the order and its lines go with it.
AGGREGATION (hollow diamond): parts survive independently
+------------+ 1 0..* +----------+
| Department |<>-----------| Employee |
+------------+ +----------+
COMPOSITION (filled diamond): parts die with the whole
+---------+ 1 1..* +-----------+
| Order |<#>------------| OrderLine |
+---------+ +-----------+
(a filled diamond is drawn solid; shown here as <#>)
// Aggregation: the employees exist before and after
public class Department {
private final List<Employee> members;
public Department(List<Employee> members) { this.members = members; }
}
// Composition: the order creates and owns its lines
public class Order {
private final List<OrderLine> lines = new ArrayList<>();
public void addLine(Product p, int qty) {
lines.add(new OrderLine(this, p, qty)); // created here, owned here
}
}
A dependency is a dashed arrow meaning "uses temporarily", typically a method parameter, a local variable, or a return type rather than a field:
+---------------+ +-----------+
| OrderService |- - - - ->| Validator | uses, but does not hold
+---------------+ +-----------+
public class OrderService {
public void place(Order o, Validator v) { v.validate(o); } // a parameter
}
The full set of line types, which is the vocabulary most interviews check:
| Relationship | Line | Meaning | In Java |
|---|---|---|---|
| Association | Solid line | Holds a reference | A field |
| Aggregation | Hollow diamond | Has-a, parts independent | A field, injected |
| Composition | Filled diamond | Part-of, shared lifetime | A field, created inside |
| Dependency | Dashed arrow | Uses temporarily | Parameter or local |
| Inheritance | Hollow triangle, solid line | Extends | extends |
| Realization | Hollow triangle, dashed line | Implements | implements |
The diamond is drawn on the side of the whole, not the part, which is the detail most often reversed on a whiteboard.
A last note: the aggregation-versus-composition distinction is frequently debated and rarely changes code. If a diagram's audience does not need it, a plain association with multiplicity communicates nearly as much.
Key Takeaways
- A solid line is an association, meaning one class holds a reference.
- Multiplicity at each end states how many instances participate.
- Aggregation, a hollow diamond, means the part can outlive the whole.
- Composition, a filled diamond, means the part dies with the whole.
- The diamond is drawn on the side of the containing whole.
- A dashed arrow is a dependency: a parameter or local, not a field.
- Multiplicity usually conveys more than the aggregation distinction.
🧪 Practice
- Draw
Library,Book, andMemberwith correct multiplicities.- Decide whether
HouseandRoomis aggregation or composition, and justify it.- Translate a composition relationship into Java that enforces the lifetime.
- Interview: What is the practical difference between aggregation and composition in code? (Hint: who calls
new, and what happens on deletion?)
Representing Inheritance and Realization
Two relationships express type hierarchies, and UML distinguishes them by line style. Both use a hollow triangle pointing at the more general type.
Generalization, drawn with a solid line, is class inheritance:
+----------------+
| *Shape* | italic name = abstract
+----------------+
| # name: String |
+----------------+
| + area(): double {abstract} |
+----------------+
/_\ hollow triangle points at the PARENT
|
+------+------+
| |
+--------+ +--------+
| Circle | | Square |
+--------+ +--------+
public abstract class Shape {
protected String name;
public abstract double area();
}
public class Circle extends Shape {
@Override public double area() { return Math.PI * r * r; }
}
Realization, drawn with a dashed line and the same hollow triangle, is interface implementation:
+---------------------+
| <<interface>> |
| Drawable |
+---------------------+
| + draw(): void |
+---------------------+
/_\
: dashed line = realization
:
+---------+
| Circle |
+---------+
public interface Drawable { void draw(); }
public class Circle implements Drawable {
@Override public void draw() { }
}
The mnemonic: solid line for extends, dashed line for implements. Both
triangles point toward the abstraction.
A class doing both, which is the common case:
+---------+ +----------------+
| *Shape* | | <<interface>> |
+---------+ | Drawable |
/_\ +----------------+
| /_\
| :
+------------+--------------+
|
+---------+
| Circle |
+---------+
public class Circle extends Shape implements Drawable, Comparable<Circle> { }
Java's single inheritance and multiple interface implementation show directly in the diagram: at most one solid triangle leaves a class, but any number of dashed ones may.
Interface extension uses a dashed or solid line depending on the convention, but since one interface extending another is genuinely generalization, a solid line is correct:
<<interface>> Collection
/_\
| (solid: interface generalization)
<<interface>> List
public interface List<E> extends Collection<E> { }
Two further notations complete the picture.
A default method is shown as a normal operation on the interface, sometimes marked with a note, since it is not abstract:
+------------------------------+
| <<interface>> |
| Comparator |
+------------------------------+
| + compare(a, b): int |
| + reversed(): Comparator {default} |
+------------------------------+
A sealed hierarchy is annotated with a constraint listing the permitted subtypes:
+--------------------+
| <<sealed>> Shape |
| {permits Circle, |
| Square} |
+--------------------+
public sealed interface Shape permits Circle, Square { }
A design caution that belongs with the notation: a diagram showing deep inheritance chains, four or five levels, is usually showing a design problem. Favour shallow hierarchies and composition, which appear as diamonds and plain associations rather than long triangle chains.
| Relationship | Line | Triangle | Java |
|---|---|---|---|
| Class generalization | Solid | Hollow | extends a class |
| Interface realization | Dashed | Hollow | implements |
| Interface generalization | Solid | Hollow | extends an interface |
Key Takeaways
- Generalization uses a solid line with a hollow triangle for
extends.- Realization uses a dashed line with a hollow triangle for
implements.- Both triangles point toward the more abstract type.
- A class shows at most one solid triangle but many dashed ones.
- One interface extending another is generalization, so the line is solid.
- Sealed hierarchies are annotated with their permitted subtypes.
- Deep triangle chains in a diagram usually signal overused inheritance.
🧪 Practice
- Draw a hierarchy where
CircleextendsShapeand implementsDrawable.- Diagram part of the Java collections hierarchy with correct line styles.
- Translate a diagram containing both relationships into a class declaration.
- Interview: Why can a UML class have only one solid inheritance triangle in Java? (Hint: what does Java permit only once per class?)
Sequence Diagrams for Object Interaction
A class diagram shows structure: which types exist and how they relate. It says nothing about order. A sequence diagram shows how objects collaborate over time to satisfy one scenario, which is what you draw when explaining a flow.
The layout is objects across the top, time flowing downward:
:Client :OrderService :PaymentGateway :OrderRepository
| | | |
| place(order) | | |
|---------------->| | |
| | charge(total) | |
| |----------------->| |
| | | |
| |<- - - - - - - - -| receipt |
| | | |
| | save(order) |
| |------------------------------------>|
| | |
|<- - - - - - - - | confirmation |
| | | |
The elements:
| Element | Notation | Meaning |
|---|---|---|
| Participant | Box at the top, named :Type | An object, not a class |
| Lifeline | Dashed vertical line | The object's existence |
| Activation bar | Narrow rectangle on the lifeline | The object is executing |
| Synchronous message | Solid line, filled arrowhead | A normal method call |
| Return | Dashed line, open arrowhead | The value coming back |
| Asynchronous message | Solid line, open arrowhead | Fire and forget |
| Self-message | An arrow looping back | A call on itself |
| Object creation | Arrow to a box drawn lower down | new |
| Destruction | An X at the end of a lifeline | The object is discarded |
The colon prefix matters: :OrderService means "an unnamed instance of
OrderService", while order:Order names the instance. Sequence diagrams show
objects, not classes.
The Java that the diagram above describes:
public class OrderService {
public Confirmation place(Order order) {
Receipt receipt = gateway.charge(order.total()); // second message
repository.save(order); // third message
return new Confirmation(order.id(), receipt); // the return
}
}
Control structures are drawn as combined fragments, labelled boxes drawn around a region:
alt = alternatives (if/else)
opt = optional (if with no else)
loop = repetition
par = parallel execution
:Service :Repository
| |
+--+-------------------+------------------+
| alt [found] | |
| |---- update() ---->| |
|--+-------------------+------------------|
| [else] | |
| |---- insert() ---->| |
+--+-------------------+------------------+
| |
if (repository.exists(id)) {
repository.update(order); // the [found] branch
} else {
repository.insert(order); // the [else] branch
}
Sequence diagrams are most useful for a small number of specific jobs:
- Explaining a non-obvious flow to someone new to the code.
- Designing an interaction before writing it, to find missing calls.
- Documenting a protocol between services, including the failure paths.
- Showing where a pattern's indirection actually goes, such as a proxy or an observer notification.
They are poor at showing branching-heavy logic. Once a diagram needs four nested fragments, prose or code is clearer. Keep one diagram to one scenario, including the error scenario as its own diagram rather than cramming both in.
A practical note: like class diagrams, sequence diagrams drift from the code quickly. Draw them for the design conversation and for genuinely stable protocols, and prefer text-based tools so they can live in the repository.
Key Takeaways
- A sequence diagram shows the order of interactions in one scenario.
- Participants are objects, written
:Type, with time flowing downward.- A solid arrow is a call; a dashed arrow is the return.
- Activation bars show when an object is executing.
- Combined fragments labelled alt, opt, loop, and par express control flow.
- One diagram should cover one scenario, with errors drawn separately.
- They suit protocols and unfamiliar flows, not branch-heavy logic.
🧪 Practice
- Draw the sequence for a user login that checks a cache, then a database.
- Add an
altfragment for the invalid-password path.- Diagram an observer notification reaching three subscribers.
- Interview: When is a sequence diagram more useful than a class diagram? (Hint: which one can express the order in which things happen?)
From Requirements to Class Model
The final skill is turning prose into a design. It is the core of most object-oriented design interviews, and the process is mechanical enough to practise.
Take a requirement:
A library lends books to members. Each member can borrow up to five books at a time for fourteen days. Late returns incur a fine of 0.50 per day. Members can reserve a book that is currently out.
Step one: find the nouns. They are candidate classes.
library, book, member, loan, fine, reservation, day
Filter them. Discard nouns that are attributes rather than types, duplicates,
or things outside the system's responsibility. day is a unit, not a class.
library may be the system itself rather than an object.
Remaining candidates: Book, Member, Loan, Fine, Reservation.
Step two: find the verbs. They are candidate operations.
lends, borrow, return, incur, reserve
Step three: assign responsibilities. Put each operation on the class that owns the data it needs. This is where the Law of Demeter and cohesion pay off.
| Operation | Belongs on | Because |
|---|---|---|
canBorrow() | Member | It knows how many loans it holds |
isOverdue() | Loan | It knows the due date |
calculateFine() | Loan | It knows how many days are late |
reserve(member) | Book | It knows its availability |
Step four: distinguish entities from value objects. An entity has identity and a lifecycle; a value object is defined entirely by its contents and should be immutable.
// Value objects: immutable, compared by value
public record Isbn(String value) {
public Isbn { if (!value.matches("\\d{13}")) throw new IllegalArgumentException(); }
}
public record Money(BigDecimal amount, Currency currency) { }
// Entities: identity matters, state changes over time
public class Member {
private final MemberId id; // identity
private final List<Loan> activeLoans = new ArrayList<>();
public boolean canBorrow() { return activeLoans.size() < MAX_LOANS; }
}
public class Loan {
private final Book book;
private final Member member;
private final LocalDate dueDate;
private LocalDate returnedOn; // state changes
public boolean isOverdue(LocalDate today) {
return returnedOn == null && today.isAfter(dueDate);
}
public Money calculateFine(LocalDate today) {
long daysLate = ChronoUnit.DAYS.between(dueDate, today);
return daysLate <= 0 ? Money.ZERO
: Money.of(BigDecimal.valueOf(daysLate * 0.50));
}
}
Step five: decide the relationships and multiplicities.
+--------+ 1 0..5 +--------+ 1 1 +--------+
| Member |<>-----------| Loan |------------| Book |
+--------+ +--------+ +--------+
^ 1
|
0..* |
+-------------+
| Reservation |
+-------------+
Encode the constraint, rather than leaving it in the diagram:
public class Member {
private static final int MAX_LOANS = 5;
public Loan borrow(Book book, LocalDate today) {
if (!canBorrow()) throw new BorrowLimitExceededException(id);
Loan loan = new Loan(book, this, today.plusDays(14));
activeLoans.add(loan);
return loan;
}
}
Step six: apply the principles. Check the model against the earlier sections. Does each class have one reason to change? Are volatile dependencies, such as persistence, inverted behind an interface the domain owns? Is a rule that will vary, like the fine calculation, behind a strategy?
public interface FinePolicy { Money fineFor(long daysLate); }
// Now the rate can change without editing Loan
Common mistakes in interviews, worth avoiding deliberately:
- Making everything a class, including
DayandFine, when aLocalDateand aMoneyvalue object suffice. - Anemic classes: data holders with getters and setters, and all behaviour in a
LibraryService. Put behaviour with the data it uses. - Starting with the database schema. Model the domain first; persistence is a detail that should be inverted behind an interface.
- Designing for requirements nobody stated, which YAGNI rejects.
- Using
Stringanddoublefor identifiers and money, which loses validation and, for money, precision.
Key Takeaways
- Nouns suggest classes, verbs suggest operations, but both lists need filtering.
- Put each operation on the class that owns the data it needs.
- Entities have identity and lifecycle; value objects are immutable and compared by content.
- Encode constraints such as a borrow limit in code, not only in the diagram.
- Invert persistence behind an interface the domain owns.
- Put a rule likely to change behind a strategy interface.
- Avoid anemic classes, and never model money as a
double.
🧪 Practice
- Model a parking garage with levels, spots, vehicles, and tickets.
- Identify which of your classes are entities and which are value objects.
- Draw the class diagram with multiplicities, then write the skeleton code.
- Add a pricing rule behind a strategy interface and a second implementation.
- Interview: Given "a customer places orders containing products", name the classes and the multiplicities. (Hint: consider whether the price on an order line should come from the product or be copied at purchase time.)
<a id="10-advanced-runtime-and-language-features"></a>