LLD Cohort · Week 3 · Sat 28 Mar 2026 · recording 3h53m
Singleton Wrap-up, Factories & Builder
The creational design patterns class. We finish Singleton (eager, lazy, thread safety, the "holder" trick), then build up the factory family: Simple Factory, Factory Method and Abstract Factory, and finish with Builder.
Each topic: one-line idea → the problem → real-life picture → the design step by step → Python code → doubts & corrections → remember
- Singleton: eager, lazy, thread-safe, holder
- Simple Factory
- Making NotificationFactory a Singleton
- Simple Factory's weakness: Open/Closed violation
- Factory Method
- Abstract Factory
- Builder
- Which creational pattern when?
Singleton: eager, lazy, thread-safe, holder ▶ 1:32:30 – 1:52:40
Singleton = a class that allows exactly one object of itself, and gives everyone a single global way (get_instance()) to reach that one object.
Quick intro (for anyone who missed the earlier class)
Normally every call to a constructor makes a new object. Singleton stops that. It has three parts:
- A private constructor, so outside code cannot write
Logger()and make a second copy. - One stored instance, kept in a static (class-level) field.
- A public
get_instance()method that always hands back that same stored instance.
This class picks up from the previous one. The teacher recapped the variants (eager, lazy, synchronized) and then showed the "best" Java version: the Bill Pugh singleton (holder class).
The problem
Some things must be shared, not copied. Picture three logger objects all writing to the same app.log file. Each one opens the file on its own, so their writes can overwrite each other, arrive in the wrong time order, or even corrupt the file. This is a classic concurrency problem: many writers, one shared resource, no single point of control. With one logger object, you have one place to put a queue or a lock, so access is controlled.
A shared office notebook. If every team member keeps their own copy, nobody knows which one is right. If there is only one notebook on one desk, everyone reads and writes the same book: a single source of truth. A thread pool is the same idea: a fixed team of 5 workers that everyone shares, instead of each class hiring its own workers.
Where Singleton is used (class discussion)
- Shared resources: DB connection pool, thread pool, in-memory cache.
- Configuration: load config once; everyone must read the same values.
- System-wide services: logging framework for a module, file system access.
The teacher's own production use: mostly logging frameworks and file-system access, where several tightly coupled modules depend on one class.
Building it step by step
Step 1: Eager. Create the object as soon as the class loads, whether anyone needs it or not. Simple and thread-safe, because the runtime creates it once, before any user code calls get_instance(). Downside: if the object is expensive (opens connections, loads files), you pay that cost even if nobody uses it.
Step 2: Lazy. Create it on the first get_instance() call. Saves memory, but is not thread-safe: two threads can both see "no instance yet" and both create one.
Step 3: Lazy + lock (double-checked locking). Check, lock, check again. The first check skips the lock on the fast path. The second check handles the thread that was waiting at the lock while another thread created the object.
Step 4: Holder (Bill Pugh). Lazy and thread-safe with no lock in your code. In Java you put the instance inside a private static class Holder. The JVM loads Holder only the first time it is referenced, and class loading is synchronized by the JVM itself. So the runtime does the locking for you.
import threading
# Step 1: EAGER - the object is built when the class is defined (import time)
class EagerSingleton:
_instance: "EagerSingleton" # filled in right after the class
@classmethod
def get_instance(cls) -> "EagerSingleton":
return cls._instance # just read it; nothing to race on
EagerSingleton._instance = EagerSingleton() # built once, up front
# Step 2: LAZY - built on first use, but NOT thread-safe
class LazySingleton:
_instance: "LazySingleton | None" = None
@classmethod
def get_instance(cls) -> "LazySingleton":
if cls._instance is None: # two threads can both pass this check...
cls._instance = cls() # ...and both create an object
return cls._instance
# Step 3: LAZY + DOUBLE-CHECKED LOCKING - built on first use, thread-safe
class DbConnectionPool:
_instance: "DbConnectionPool | None" = None
_lock = threading.Lock()
def __init__(self) -> None:
# Python has no private constructor; this guard is the closest thing
if DbConnectionPool._instance is not None:
raise RuntimeError("Use DbConnectionPool.get_instance()")
print("Opening connection pool (expensive!)")
@classmethod
def get_instance(cls) -> "DbConnectionPool":
if cls._instance is None: # 1st check: fast path, no lock
with cls._lock: # only one thread at a time in here
if cls._instance is None: # 2nd check: maybe another thread just made it
cls._instance = cls()
return cls._instance
# usage
if __name__ == "__main__":
seen = set()
def worker() -> None:
seen.add(id(DbConnectionPool.get_instance()))
threads = [threading.Thread(target=worker) for _ in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
print("distinct objects:", len(seen))
print(EagerSingleton.get_instance() is EagerSingleton.get_instance())
# Opening connection pool (expensive!) <- printed only once
# distinct objects: 1
# TrueStep 4 in Python (the "holder" idea). Python's version of "let the runtime do the locking" is a module. A module's code runs only on its first import, and Python guards imports with its own lock. So a module-level object is created once, on first import, safely.
# app_logger.py - plays the role of Java's "Holder" class
class Logger:
def __init__(self) -> None:
print("Logger created")
def log(self, msg: str) -> None:
print(f"[LOG] {msg}")
logger = Logger() # runs once, on the first "import app_logger"
# anywhere else in the project:
# from app_logger import logger
# logger.log("order placed")
# Every module gets the very same Logger object.Java has private constructors, synchronized, and volatile (needed in Java's double-checked locking so other threads see a fully built object). Python has none of these: use a threading.Lock and a guard in __init__. Also, do not copy the Java holder class literally into Python: a nested class body in Python runs when the outer class is defined, so it is eager, not lazy. Use a module instead.
In class it was said that the "compiler" runs the static final instance = new ... line. It is actually the JVM, at runtime, during class loading/initialization. The point stands: the JVM does it once and thread-safely, so you need no synchronized.
Pros, cons, pitfalls
| Variant | Lazy? | Thread-safe? | Note |
|---|---|---|---|
| Eager | No | Yes | Simplest; wastes resources if never used |
| Lazy (plain) | Yes | No | Two threads can create two objects |
| Lazy + lock on every call | Yes | Yes | Correct but every call pays for the lock |
| Double-checked locking | Yes | Yes | Lock only on first creation |
| Holder (Java) / module (Python) | Yes | Yes | Runtime does the locking; cleanest |
- Scalability: one object can become a bottleneck. Singleton is for one module / one process. In a distributed logging system (many servers) do not reach for Singleton; it limits you.
- Do not make a class a Singleton just because "loggers are singletons". Ask: is there a shared resource or a single source of truth that must be protected? Be flexible, not rigid.
→
Because the object guards a shared resource (a file, a pool, a cache, config). Many instances means many uncontrolled writers: overwrites, wrong ordering, corruption. One instance gives one place to control access and one source of truth.
→
In eager and holder versions, the object is created by the JVM while loading the class, and class loading is already synchronized. After that, users only read the field. In the lazy version, your code creates the object inside get_instance(), and a thousand callers can hit it at the same moment, so two could create it. That write needs a lock.
→
A static nested class does not need an instance of the outer class. That is exactly what we want: we want the instance without first having an object.
- Singleton = private constructor + one stored instance +
get_instance(). - Eager is safe but not lazy; plain lazy is lazy but not safe.
- Double-checked locking: check, lock, check again.
- Holder (Java) / module-level object (Python): lazy and thread-safe for free.
- Use it for shared resources and single sources of truth; watch out for scaling limits.
Simple Factory ▶ 1:52:41 – 2:05:30
A factory is one central place that creates objects for you. Business code asks "give me an SMS notification"; it never calls the concrete constructor itself.
The problem
Imagine business code (say, an Amazon order service) that needs a Payment or SMSNotification object. It writes new SMSNotification(...) with many parameters, and it does this in 10 different places with slightly different settings. Now:
- One small change in the constructor means editing 10 places.
- The business code is exposed to creation details (which parameters, which concrete class) it does not care about. It should care about orders and payments, not about how a Stripe payment object is wired.
- The client knows concrete classes, so changing the class means changing the client. No abstraction = poor LLD.
The goal: separate object-creation logic from business logic and centralize it.
A real factory. You order a product; you do not care how the assembly line works. The factory knows how to build things; you just use what it hands you.
When to use: few product types (2–3), simple creation logic, and speed matters more than extensibility. The teacher noted this is perfectly fine in an LLD interview when you are short on time.
Building it step by step
Before: the client writes SMSNotification() directly, everywhere.
After: a common interface Notification, concrete classes, and one NotificationFactory with an if/else on the type.
from abc import ABC, abstractmethod
class Notification(ABC): # the common contract
@abstractmethod
def send_notification(self) -> None: ...
class SMSNotification(Notification):
def send_notification(self) -> None:
print("Sending SMS notification")
class EmailNotification(Notification):
def send_notification(self) -> None:
print("Sending Email notification")
class WhatsAppNotification(Notification):
def send_notification(self) -> None:
print("Sending WhatsApp notification")
class NotificationFactory:
"""The ONE place that knows how to build each notification."""
def get_notification_instance(self, type_: str) -> Notification:
if type_ == "SMS":
return SMSNotification()
if type_ == "EMAIL":
return EmailNotification()
if type_ == "WHATSAPP":
return WhatsAppNotification()
raise ValueError(f"Unknown notification type: {type_}")
# usage - the client never names a concrete class
factory = NotificationFactory()
whatsapp = factory.get_notification_instance("WHATSAPP")
whatsapp.send_notification()
# Sending WhatsApp notificationclassDiagram
class Notification {
+send_notification()
}
class NotificationFactory {
+get_notification_instance(type) Notification
}
SMSNotification ..|> Notification
EmailNotification ..|> Notification
WhatsAppNotification ..|> Notification
NotificationFactory --> Notification : creates
Client --> NotificationFactory : asks
The class version returned SMSNotification as a silent default for unknown types. That hides typos: in the live demo, asking for WhatsApp printed "Sending SMS" because the strings did not match. Raising an error for unknown types is safer.
What Simple Factory achieved
- The client does not call constructors directly.
- Creation is centralized: change it in one place and it is reflected everywhere.
- Business code is cleaner and does not see creation details.
- Factory = pull object creation out of business code into one place.
- Simple Factory = one class + if/else on a type string.
- Clients depend on the
Notificationinterface, not on concrete classes. - Great for small, stable sets of types and quick interviews.
Making NotificationFactory a Singleton ▶ 2:06:00 – 2:09:22
The factory itself is a shared service, so it makes sense to have just one of it: combine Factory with Singleton.
The problem
If 100 client classes each write NotificationFactory(), you get 100 identical factory objects. The factory has no per-client state, so this is pure waste. Since many modules use it, they should share one factory: one single source of truth for creation. (The teacher admitted it is not strictly needed here; it is good practice for both patterns at once.)
Python code
The teacher used the Bill Pugh holder (NotificationFactoryHolder) plus a private constructor. In Python, the simplest equivalent is one module-level instance; here is the class-based form so it mirrors the Java getInstance() call:
class NotificationFactory:
_instance: "NotificationFactory | None" = None
def __init__(self) -> None:
# stand-in for Java's "private NotificationFactory() {}"
if NotificationFactory._instance is not None:
raise RuntimeError("Use NotificationFactory.get_instance()")
@classmethod
def get_instance(cls) -> "NotificationFactory":
if cls._instance is None:
cls._instance = cls()
return cls._instance
def get_notification_instance(self, type_: str) -> Notification:
if type_ == "SMS":
return SMSNotification()
if type_ == "WHATSAPP":
return WhatsAppNotification()
raise ValueError(f"Unknown notification type: {type_}")
# usage - every client shares the same factory
f1 = NotificationFactory.get_instance()
f2 = NotificationFactory.get_instance()
print(f1 is f2) # True
f1.get_notification_instance("SMS").send_notification() # Sending SMS notification(Reuses Notification, SMSNotification, WhatsAppNotification from the Simple Factory code. If several threads may call get_instance() for the first time together, use the double-checked lock from the Singleton topic.)
→
The private constructor. Without it, anyone can still write NotificationFactory() and bypass get_instance().
- Patterns combine: a factory is often a singleton.
- One shared factory means one central place to change creation logic.
- Do not over-engineer: make it a singleton only if sharing really matters.
Simple Factory's weakness: Open/Closed violation ▶ 2:09:22 – 2:11:05
Every new notification type forces you to edit the factory's if/else, which breaks the Open/Closed Principle.
The problem
The Open/Closed Principle (the "O" in SOLID) says: a class should be open for extension (you can add behavior) but closed for modification (you should not have to edit existing, tested code to do it).
Now the business wants Slack notifications. You must open NotificationFactory and add another if type_ == "SLACK". Then Teams, then Push... The factory keeps growing and every change risks breaking the existing branches. The factory has become the bottleneck.
class NotificationFactory:
def get_notification_instance(self, type_: str) -> Notification:
if type_ == "SMS":
return SMSNotification()
if type_ == "WHATSAPP":
return WhatsAppNotification()
if type_ == "SLACK": # <- had to EDIT existing code
return SlackNotification() # to add one new type
raise ValueError(type_)Simple Factory is still a valid factory and is "good enough" when the list of types is small and rarely changes. If types keep growing, move to Factory Method.
- Simple Factory = centralized, but one if/else that grows forever.
- Adding a type means modifying the factory: an OCP violation.
- The fix: let each type bring its own creator (Factory Method).
Factory Method ▶ 2:11:05 – 2:23:27
Instead of one factory with an if/else, define a factory interface and give each product its own factory class. Adding a new type = adding new classes, never editing old ones.
The problem
Simple Factory put all creation in one class, so that class must change for every new type. We want no central if/else at all.
Instead of one giant factory that builds every brand of phone (and must be rebuilt for each new brand), every brand runs its own small factory. They all follow the same contract: "you call create, you get a phone". A new brand just opens its own factory; nobody else changes.
When to use: many implementations, a system expected to grow, production-grade designs.
Building it step by step
- Keep the same
Notificationinterface and concrete classes. - Delete the big if/else factory.
- Create a
NotificationFactoryinterface with one method:create_notification(). - For each notification, write a small factory:
SMSNotificationFactory,WhatsAppNotificationFactory... - The client holds a
NotificationFactoryand callscreate_notification(); which object it gets depends on which factory it was given. - Add Slack: write
SlackNotification+SlackNotificationFactory. Nothing existing changes.
from abc import ABC, abstractmethod
class Notification(ABC):
@abstractmethod
def send_notification(self) -> None: ...
class SMSNotification(Notification):
def send_notification(self) -> None:
print("Sending SMS notification")
class WhatsAppNotification(Notification):
def send_notification(self) -> None:
print("Sending WhatsApp notification")
class NotificationFactory(ABC):
@abstractmethod
def create_notification(self) -> Notification:
"""The factory method: each subclass decides WHAT to create."""
def notify_user(self) -> None:
# shared business logic; works for any factory
notification = self.create_notification()
notification.send_notification()
class SMSNotificationFactory(NotificationFactory):
def create_notification(self) -> Notification:
return SMSNotification()
class WhatsAppNotificationFactory(NotificationFactory):
def create_notification(self) -> Notification:
return WhatsAppNotification()
# --- Later: add Slack WITHOUT touching anything above ---
class SlackNotification(Notification):
def send_notification(self) -> None:
print("Sending Slack notification")
class SlackNotificationFactory(NotificationFactory):
def create_notification(self) -> Notification:
return SlackNotification()
# usage - client depends only on the NotificationFactory interface
for factory in (SMSNotificationFactory(), WhatsAppNotificationFactory(),
SlackNotificationFactory()):
factory.notify_user()
# Sending SMS notification
# Sending WhatsApp notification
# Sending Slack notificationclassDiagram
class Notification {
+send_notification()
}
class NotificationFactory {
+create_notification() Notification
+notify_user()
}
SMSNotification ..|> Notification
WhatsAppNotification ..|> Notification
SlackNotification ..|> Notification
SMSNotificationFactory --|> NotificationFactory
WhatsAppNotificationFactory --|> NotificationFactory
SlackNotificationFactory --|> NotificationFactory
SMSNotificationFactory --> SMSNotification : creates
WhatsAppNotificationFactory --> WhatsAppNotification : creates
SlackNotificationFactory --> SlackNotification : creates
In the classic (GoF) form, the factory class also holds business logic (like notify_user() above) that calls the abstract create_notification(). Subclasses only override the creation step. That is why it is called the factory method.
Pros / cons
- Pros: no if/else; follows Open/Closed; easy to extend (new type = new class + new factory); client untouched.
- Cons: more classes (two per type). For 2–3 stable types this can be over-engineering.
→
Students answered: adding a channel means extending (writing a new class that implements the interface), not changing any existing class. The new type also owns its own creation logic, so full control of creation sits with the new code.
- Simple Factory centralizes creation with conditionals.
- Factory Method delegates creation to subclasses: one factory per product.
- New type = 2 new classes (product + factory), zero edits.
- Client codes against the factory interface only.
Abstract Factory ▶ 2:23:28 – 3:04:38
A factory that creates a whole family of related objects that must be used together (e.g. all dark-theme widgets, or all Paytm services). One factory per family.
The problem
Factory Method makes one product at a time (email, SMS). Each product knows nothing about the others, so it cannot enforce "these go together". But in the real world, objects often come in groups:
- Payment providers: Paytm gives you Paytm payment, Paytm refund, Paytm invoice. Razorpay gives Razorpay payment, refund, invoice. You must never mix a Paytm payment with a Razorpay refund.
- UI themes: switching to dark mode changes the button, checkbox, toolbar, text color... all together.
Without a factory, the client must manage LightButton, DarkButton, LightCheckbox, DarkCheckbox itself and keep them consistent. That is a headache and an easy place for bugs.
Toggling dark mode on a website. You flip one switch and everything (background, text, buttons) changes together. Somebody wrote code that knows "dark theme = this whole set". That somebody is the abstract factory.
When to use: when you need to create, manage and swap a group of related objects together, and want a guarantee they are compatible. It is an extension of the normal factory: "I know how to make one object" becomes "I know how to make a matching set".
The four building blocks
- Abstract products: interfaces for each kind of product (
Car,Truck). - Concrete products: real versions per family (
TataPetrolCar,TataElectricCar, ...). - Abstract factory: interface with one create method per product (
VehicleFactory.create_car(),create_truck()). - Concrete factories: one per family (
PetrolVehicleFactory,ElectricVehicleFactory).
The class example: Tata Motors
Tata builds cars and trucks. They are different products, but they group naturally by fuel type: the petrol section of the factory builds petrol cars and petrol trucks; the electric section builds electric ones. Ask the electric factory for a car and you can only get an electric car.
from abc import ABC, abstractmethod
# 1) Abstract products
class Car(ABC):
@abstractmethod
def drive(self) -> None: ...
class Truck(ABC):
@abstractmethod
def load(self) -> None: ...
# 2) Concrete products (grouped by fuel type)
class TataPetrolCar(Car):
def drive(self) -> None:
print("Driving Tata petrol car")
class TataElectricCar(Car):
def drive(self) -> None:
print("Driving Tata electric car")
class TataPetrolTruck(Truck):
def load(self) -> None:
print("Loading Tata petrol truck")
class TataElectricTruck(Truck):
def load(self) -> None:
print("Loading Tata electric truck")
# 3) Abstract factory: one create method per product
class VehicleFactory(ABC):
@abstractmethod
def create_car(self) -> Car: ...
@abstractmethod
def create_truck(self) -> Truck: ...
# 4) Concrete factories: one per FAMILY
class PetrolVehicleFactory(VehicleFactory):
def create_car(self) -> Car:
return TataPetrolCar()
def create_truck(self) -> Truck:
return TataPetrolTruck()
class ElectricVehicleFactory(VehicleFactory):
def create_car(self) -> Car:
return TataElectricCar()
def create_truck(self) -> Truck:
return TataElectricTruck()
# usage - the client picks a family ONCE; everything else follows
def run_fleet(factory: VehicleFactory) -> None:
factory.create_car().drive()
factory.create_truck().load()
run_fleet(ElectricVehicleFactory())
run_fleet(PetrolVehicleFactory()) # switch family by changing one line
# Driving Tata electric car
# Loading Tata electric truck
# Driving Tata petrol car
# Loading Tata petrol truckclassDiagram
class VehicleFactory {
+create_car() Car
+create_truck() Truck
}
class Car {
+drive()
}
class Truck {
+load()
}
PetrolVehicleFactory ..|> VehicleFactory
ElectricVehicleFactory ..|> VehicleFactory
TataPetrolCar ..|> Car
TataElectricCar ..|> Car
TataPetrolTruck ..|> Truck
TataElectricTruck ..|> Truck
PetrolVehicleFactory --> TataPetrolCar : creates
PetrolVehicleFactory --> TataPetrolTruck : creates
ElectricVehicleFactory --> TataElectricCar : creates
ElectricVehicleFactory --> TataElectricTruck : creates
Same shape, other examples from class
| Abstract factory | Products in the family | Concrete factories (families) |
|---|---|---|
UIFactory | Button, Checkbox | LightThemeFactory, DarkThemeFactory |
PaymentGatewayFactory | Payment, Refund, Invoice | PaytmFactory, RazorpayFactory |
| Cloud deployment factory | VM, DB, Cache, Load balancer | AWS, GCP, Azure |
HomeCreatorFactory | Floor, Roof, Interior, Kitchen | Home under 50 lakh, under 1 crore, ... |
In the UI demo, the client wrote factory = LightThemeFactory() and got "rendering light button / light checkbox". Changing just that line to DarkThemeFactory() switched every widget to dark, with no other code touched.
Factory Method vs Abstract Factory
| Factory Method | Abstract Factory | |
|---|---|---|
| Creates | One product | A family of related products |
| Factories | One per product (EmailFactory, SMSFactory) | One per family (PetrolFactory, DarkThemeFactory) |
| Guarantees | Nothing about other objects | All products are compatible with each other |
Adding a new family is easy (one new concrete factory). Adding a new product kind (say Bus) is hard: you must add create_bus() to the interface and to every existing factory.
→
You can apply a factory at any level, including engine parts that must work together. Here the point is grouping related objects by a shared trait, like Paytm vs Razorpay all doing pay/refund/invoice, or petrol vs electric. Ajay's own summary was right: things that do the same operations in different "flavours" get grouped under one family factory.
→
A HomeCreatorFactory with create_floor(), create_roof(), create_interior(), create_kitchen(). Families by budget: "home under 50 lakh", "home under 1 crore". Each budget factory returns its own floor/roof/interior implementations; the client just picks a budget.
→
Singleton (only one object), Factory (separate creation from use), Abstract Factory (create groups of related objects), Builder (build complex objects step by step), Prototype (next class).
- Abstract Factory = a factory for a family of related objects.
- Keyword: grouping. Pick the family once; every product follows.
- Parts: abstract products, concrete products, abstract factory, concrete factories.
- New family = easy; new product kind = touch every factory.
Builder ▶ 3:04:40 – 3:52:56
Build a complex object step by step through a separate builder object, setting only the fields you want, in any order, then call build().
The problem: constructor explosion
A Student has name (mandatory) plus optional age, id, address, email. A caller may pass any combination of the optional fields. In Java you would write an overloaded constructor for each combination: Student(name), Student(name, age), Student(name, age, email)... With 4 optional fields that is 24 = 16 constructors (each optional field is either given or not). With 20 or 30 fields it is impossible. This is called constructor explosion (also "telescoping constructors"):
- Hard to read:
Student("Pradeep", 28, 0, null, "x@y.com"), which number is which? - Easy to pass wrong values in the wrong position.
- Not scalable, a maintenance nightmare, and a sign of a design flaw.
CSS, or styling a background photo: you say set width, set height, set color, set texture, in any order, only the ones you care about. Only the URL is mandatory. That chain of "set this, set that" is exactly the Builder style. Java's StringBuilder works the same way: sb.append("a").append("b").
When to use: many fields, many optional, and you want flexible, readable creation. Real uses: HTTP request builders, SQL query builders, form data, StringBuilder.
Building it step by step
- Make the
Studentconstructor private. Clients will not call it directly, since that is what causes the explosion. - Create a static inner class
StudentBuilder. It is the "spokesperson" for Student. Its constructor takes only the mandatory field (name). - Give it one setter per optional field. Each setter stores the value and
return this(in Pythonreturn self). - Because every setter returns the builder, calls can be chained in any order:
.set_age(28).set_address(...).set_email(...). - Finish with
build(): it callsStudent(self), and the Student copies the values from the builder into itself.
Flow: Client → Builder (collects values) → build() → Student (product).
from __future__ import annotations
class Student:
def __init__(self, builder: Student.Builder) -> None:
# meant to be called only from Builder.build()
self.name = builder.name
self.age = builder.age
self.id = builder.id
self.address = builder.address
self.email = builder.email
def __repr__(self) -> str:
return (f"Student(name={self.name!r}, age={self.age}, id={self.id}, "
f"address={self.address!r}, email={self.email!r})")
class Builder: # Java: public static class StudentBuilder
def __init__(self, name: str) -> None: # mandatory field goes here
self.name = name
self.age = 0 # defaults for optional fields
self.id = 0
self.address: str | None = None
self.email: str | None = None
def set_age(self, age: int) -> Student.Builder:
self.age = age
return self # return the builder -> chaining
def set_id(self, id_: int) -> Student.Builder:
self.id = id_
return self
def set_address(self, address: str) -> Student.Builder:
self.address = address
return self
def set_email(self, email: str) -> Student.Builder:
self.email = email
return self
def build(self) -> Student:
# a good place for validation before the object exists
if not self.name:
raise ValueError("name is mandatory")
return Student(self)
# usage - any subset of optional fields, in any order
s1 = (Student.Builder("Pradeep")
.set_age(28)
.set_address("Bangalore")
.set_email("pradeep@example.com")
.build())
s2 = Student.Builder("Neha").set_email("neha@example.com").build()
print(s1)
print(s2)
# Student(name='Pradeep', age=28, id=0, address='Bangalore', email='pradeep@example.com')
# Student(name='Neha', age=0, id=0, address=None, email='neha@example.com')Why chaining works: Student.Builder("Pradeep") is a Builder. .set_age(28) returns that same Builder, so .set_address(...) can be called on it, and so on. Only build() returns a different type: a Student. In the demo, fields not set (like id) kept their default value 0.
Java has no default or keyword arguments, so Builder is the main cure for constructor explosion there. Python already has them: Student(name="Pradeep", email="x@y.com") with a @dataclass and defaults avoids the 16 constructors. Builder is still useful in Python when an object is assembled across several steps, when build() must validate the whole thing, or for a fluent API (query builders). Python also cannot make a constructor truly private and needs no static keyword: a nested class never needs an outer instance.
Pros / cons
- Pros: no constructor explosion; readable (each value is named); any order; optional fields easy; validation in one place (
build()); the final object can be made immutable. - Cons: extra class and duplicated field list; overkill for 2–3 fields.
→
Because "all values" is only one of the 16 valid combinations. The client is free to pass any subset. A direct constructor would need one overload per subset; the builder handles every subset with one setter per field.
→
Inner: it needs to know Student's fields, including private ones; a separate outer class would force Student to expose them and break encapsulation. It also keeps the code logically grouped. Static: we must create a builder before any Student exists, and a static nested class has no dependency on an outer instance. (In Java you then write new Student.StudentBuilder("Pradeep"). Left as homework: revise all uses of static.)
→
Only inside build(), which does new Student(this): it passes the builder itself, and the constructor copies each value. That is the "conclusion" step.
→
You can, the same way you can live without SOLID. But in large codebases it is a real maintainability problem: hard to read, merge conflicts, poor testability, and wrong-argument bugs at runtime. Quality of life gets bad.
- Problem: many optional fields cause constructor explosion (2n combinations).
- Builder: mandatory fields in its constructor, one setter per optional field.
- Every setter returns the builder (
return self), which enables chaining in any order. build()creates the real object (private constructor) and is the place to validate.- Real examples:
StringBuilder, HTTP/SQL query builders.
Homework: finish the Snake and Ladder LLD (Player, Board, Dice, Game classes). Next class: Prototype pattern and the Snake and Ladder walkthrough.
Which creational pattern when?
The four creation patterns from this class, side by side.
| Pattern | Solves | Shape | Use when | Watch out |
|---|---|---|---|---|
| Simple Factory | Creation logic scattered across business code | One factory class with if/else on a type | 2–3 stable types; quick interview design | Violates Open/Closed: every new type edits the factory |
| Factory Method | The growing if/else of Simple Factory | Factory interface + one factory subclass per product | Many types, system expected to grow | Two new classes per type |
| Abstract Factory | Related objects that must be created and used together | One factory per family; one create method per product | Themes, payment providers, cloud vendors, fuel types | Adding a new product kind touches every factory |
| Builder | Constructor explosion from many optional fields | Builder with chained setters (return self) + build() | Complex objects, many optional parameters, need validation | Overkill for few fields (in Python, keyword args may be enough) |
Factories answer "which class do I create?" Builder answers "how do I assemble this one complex object?" Singleton answers "how many?" (exactly one).