Object-Oriented Design · Study Guide
How objects get built — and the three tried-and-true recipes that keep construction clean: Factory Method, Builder, and Singleton.
Section 1 — Notebook Summary · hand-copy this
Client → asks · Factory → creates · Product
interface · ConcreteProducts.LeetCodeCompiler → CompilationFactory.getInstance(lang) →
Java/Cpp/Python compiler.new JavaCompiler().
switch → factory isn't fully closed to change.Builder, then
.build().private final · outer constructor private, takes Builder.return this.
new Form.Builder("John","Doe").age(25).gender("Male").build();| Path tried | Why it failed |
|---|---|
| Telescoping ctors | combinatorial explosion, not scalable |
| JavaBean (setters) | breaks immutability — fields mutable |
| Builder | ✓ flexible + immutable |
return this → the
chaining trick. That's the whole "fluent" feel.@Builder = same pattern, zero
boilerplate.private · 2. static getInstance() · 3.
if(instance==null) create, else return existing.if(instance==null) is
not thread-safe → 2 threads can make 2 instances. +synchronized ·
double-checked lock · enum. +| Pattern | One-liner | Answers |
|---|---|---|
| Factory | hide which class | WHICH object? |
| Builder | assemble step-wise | HOW to build? |
| Singleton | only one, ever | HOW MANY? |
Section 2 — Detailed Study Reference
You're wiring up a class with nine fields — first name, last name, age, gender, three
booleans, a city, a state. Only the first two are required. So you write a constructor. Then another for
name-plus-age. Then name-plus-age-plus-gender. Ten constructors later you're staring at new Form("John", "Doe", 0, null, false, false, null, null, false) and you
genuinely cannot remember what that third false means.
That mess is what creational patterns exist to prevent. They're the accumulated wisdom for one deceptively hard question: how should objects come into existence? Do it carelessly and your calling code ends up welded to concrete class names, drowning in constructor arguments, or accidentally spawning ten copies of something that was supposed to be one. This lesson walks three fixes — and the single principle underneath all of them.
A design pattern is a tried-and-true, reusable solution to a problem that keeps showing up in software design.
Think of it as a cookbook for developers. You don't copy-paste a pattern the way you copy a library; you follow its recipe and adapt it to your kitchen. It's a template — a shared vocabulary — not a chunk of code. When one engineer says "just use a Builder here," a whole design is communicated in three words.
Patterns sort into three families based on what they organize:
| Family | Concerned with | Examples |
|---|---|---|
| Creational | How objects get created | Factory Method, Builder, Singleton, Abstract Factory |
| Structural | How classes/objects are composed | Adapter, Decorator, Facade |
| Behavioural | How objects communicate | Observer, Strategy, Command |
Everything in this lesson lives in the Creational box. And every one of them is really an application of a single principle, so let's meet that first.
Find the part of your code that changes, and separate it from the part that stays the same.
Analogy — the restaurant kitchen. The dining room never changes: tables, waiters, the act of ordering. What varies is the menu. A well-run restaurant keeps the menu on a separate card, not carved into the tables. Swap the card, keep the room. In code, you push the volatile part (which compiler? which fields? how many instances?) behind a stable interface so the rest of your program never has to be edited when it changes.
Create an object without forcing the caller to name the exact class being created.
Analogy — ordering coffee. You walk up to the counter and say "a latte, please." You don't reach behind the bar, grab the specific machine, and operate it yourself. The barista (the factory) hears your request and produces the right drink. You care about the result — a latte — not about which machine made it. Factory Method puts a barista between your code and the messy business of construction.
Design a compiler for LeetCode that handles Java, C++, and Python. The public API is fixed:
class LeetcodeCompiler { public void compile(String code, String language) { } }
The naïve instinct is to stuff a giant if/else or switch right inside compile(), mixing "figure out
which compiler" with "actually run the compiler." Every new language then means editing this one method —
which does two unrelated jobs at once. The language choice is what varies. Encapsulate it.
// Product interface — the stable contract public interface Compilation { void compile(String code); } // The factory — the ONLY place that knows concrete classes class CompilationFactory { public static Compilation getInstance(String language) { Compilation op = null; switch (language) { case "JAVA": op = new JavaCompiler(); break; case "PYTHON": op = new PythonCompiler(); break; case "CPP": op = new CPPCompiler(); break; } return op; } } // Concrete products class JavaCompiler implements Compilation { public void compile(String code) { /* ... */ } } // CppCompiler, PythonCompiler ... same shape // The client — blissfully ignorant of concrete classes class LeetCodeCompiler { public void compile(String code, String language) { Compilation instance = CompilationFactory.getInstance(language); instance.compile(code); } }
JavaCompiler.switch statement. So the factory itself is not closed to modification — it's a
chokepoint you keep reopening. That's the honest trade: you've localized the change to one place
instead of scattering it, but you haven't eliminated it. This is exactly why heavier variants like Abstract
Factory exist.
switch) is technically the Simple Factory idiom.
True Factory Method defers creation to subclasses via an overridden method. Interviewers love catching
people who blur these. +
Construct a complex object step by step, keeping it readable and immutable, instead of drowning in constructor arguments.
Analogy — the sandwich shop. At Subway you don't shout your entire order in one breath. You walk the line: pick the bread, add cheese, add veggies, skip the onions, choose a sauce. Each choice is a small step, in any order, and only the ones you want. At the end, it's wrapped — sealed, done, unchangeable. That's a Builder: a construction line for objects.
The form has two mandatory fields (first name, last name) and many optional
ones (age, gender, isGraduate, hasExperience, city, state, isEarning…). Once filled, details cannot
be changed — so every field is private final (that's what makes the
object immutable). Now: how do we let callers set only the fields they care about, while keeping
immutability? We try three approaches; watch two of them fail.
Write one constructor per combination, each calling the next:
public EnrollmentForm(String firstName, String lastName) { this(firstName, lastName, 0); } public EnrollmentForm(String firstName, String lastName, int age) { this(firstName, lastName, age, null); } // ... and on, and on ...
This works for a handful of fields, but it can't express "first name, last name, and gender" without yet another constructor — and with 10 optional fields you'd need an unmanageable number of overloads. Not scalable, and unreadable at the call site.
Use a small constructor for mandatory fields, then setters for the rest:
public void setAge(int age) { this.age = age; } public void setGender(String gender) { this.gender = gender; }
Flexible, yes — but a setter, by definition, lets you change a field after creation. That destroys
immutability: someone can call form.setGender(...) tomorrow. Requirement
violated.
public class EnrollmentForm { private final String firstName; private final String lastName; private final int age; private final String gender; // private: only the Builder may construct us private EnrollmentForm(Builder builder) { this.firstName = builder.firstName; this.lastName = builder.lastName; this.age = builder.age; this.gender = builder.gender; } public static class Builder { private final String firstName; // mandatory private final String lastName; private int age; // optional private String gender; public Builder(String firstName, String lastName) { this.firstName = firstName; this.lastName = lastName; } public Builder age(int age) { this.age = age; return this; } public Builder gender(String g) { this.gender = g; return this; } public EnrollmentForm build() { return new EnrollmentForm(this); } } }
And the call site reads like a sentence:
EnrollmentForm form = new EnrollmentForm.Builder("John", "Doe").age(25).gender("Male").build(); // EnrollmentForm{firstName='John', lastName='Doe', age=25, gender='Male'}
this, so calls chain fluently until
.build() seals an immutable object.Run through the requirements and it passes all three: create with just the two names? Yes. Create with names + gender only? Yes. Change gender afterward? No — there's no setter on the finished object, only on the builder. That last "no" is the win.
| Approach | Flexible? | Immutable? | Scales to many fields? |
|---|---|---|---|
| Telescoping ctors | Partly | Yes | No — combinatorial blowup |
| JavaBean setters | Yes | No — mutable | Yes |
| Builder | Yes | Yes | Yes |
Rule of thumb: reach for Builder when an object needs more than 4–5 fields to construct. Its three advantages are maintainability (readable at the call site), robustness (the client only ever receives a fully-constructed object), and immutability.
@Builder (and @Getter) and the entire inner Builder class is produced at compile time. You get
EnrollmentForm.builder().firstName("John").build() for free.
@Getter @Builder public class EnrollmentForm { private final String firstName; private final String lastName; private final int age; // ... rest of the fields, no boilerplate }
Guarantee a class has exactly one instance, and give the whole program a single shared access point to it.
Analogy — the country's president. No matter who asks, at any given moment there is exactly one sitting president, and everyone refers to the same one. You can't spin up a second president for convenience. A Singleton is that: one instance, globally reachable, no duplicates allowed.
A logger records messages to a log file. The catch: the whole application must write to a single log file. If two logger objects each open the file, you get access conflicts and inconsistent output. So we need to forbid creating more than one Logger. How?
We derive the pattern by hitting walls:
new Logger() as many times as they like. Fix: make the
constructor private.getInstance() method that lives inside the class and does the creating.getInstance() without an object,
which we can't make. Fix: make getInstance() static so it belongs to the class, not an instance.getInstance()
repeatedly and get multiple instances. Fix: keep a static reference; only create if
it's null, otherwise return the one that exists.public class Logger { private static Logger instance; // the one and only private Logger() { } // 1. no public construction public static Logger getInstance() { // 2. static access point if (instance == null) { // 3. create once, reuse forever instance = new Logger(); } return instance; } }
getInstance() and lands on the same
single object — no matter how many callers.if (instance == null) check above breaks under concurrency. Two threads can
both evaluate the condition as true before either assigns, and you end up with two Loggers —
exactly the thing you were preventing. This is the single most common Singleton interview follow-up. +
private static final Logger instance = new Logger();), simple and safe;
(b) synchronized getInstance() — correct but slower on every call; (c)
double-checked locking with a volatile field — fast and safe but
fiddly; (d) an enum singleton — Joshua Bloch's recommended approach, thread-safe and
serialization-proof by construction. +
All three are creational, all three are "Encapsulate What Varies" wearing different clothes — but they answer different questions:
| Pattern | Question it answers | What varies (and gets hidden) | Signature move |
|---|---|---|---|
| Factory Method | Which class do I instantiate? | The concrete type chosen at runtime | Ask a factory instead of calling new |
| Builder | How do I assemble a complex object? | Which optional fields get set, and in what order | Fluent chain → .build() |
| Singleton | How many instances may exist? | The instantiation count (forced to one) | Private ctor + static getInstance() |
Tap a question to reveal a 2–4 sentence answer you can read aloud.
new?new JavaCompiler() hard-codes the concrete class
into the caller, so any change means editing the caller. A factory moves that decision into one
place: the client asks for a Compilation by name and gets back the right
implementation, never naming the concrete class. That gives loose coupling and a single point to
extend.switch statement — isn't that a code smell?
this — the builder itself — so
you can immediately call the next setter on the result. That chaining is what produces the readable
.age(25).gender("Male").build() syntax. The mutable builder collects
your choices; build() then produces a frozen, immutable object.private so nobody can
call new; add a static getInstance() method
as the single access point; and inside it, keep a static reference — create the instance only if
it's null, otherwise return the existing one. That guarantees one instance, lazily created,
reachable everywhere.instance == null as true before
either assigns, so each creates its own instance — defeating the purpose. Fixes include eager
initialization (build it at class-load time), a synchronized
getInstance, double-checked locking with a volatile field, or an enum
singleton, which is thread-safe and serialization-safe by design.final?final enforces that at the language
level: they can be assigned exactly once, in the constructor, and never reassigned. Immutability
also makes the object inherently thread-safe.new → loose coupling, easy to extend. Watch the switch: change is localized, not eliminated.this to chain, and a .build() that seals a frozen object. Lombok's @Builder removes the boilerplate.getInstance() + null-check. The naïve version isn't thread-safe — know the
fixes (eager init, synchronized, double-checked lock, enum).