By WebNest Studio Engineering Team
Advanced Java Tutorial
Design Patterns Overview: Singleton, Factory, Builder, Observer
A design pattern is a proven, reusable solution to a recurring problem in object-oriented design — not a piece of code you copy, but a shape of a solution you adapt to your own classes. Patterns give teams a shared vocabulary: saying "just make it a Singleton" or "wrap that in a Builder" communicates an entire structural decision in a few words.
This lesson covers four of the most commonly used patterns in real Java codebases: Singleton (exactly one instance), Factory (centralized object creation), Builder (step-by-step construction of complex objects), and Observer (notifying dependents automatically when state changes). Each solves a different problem, and recognizing which one fits is more valuable than memorizing the code by rote.
Why it matters
Design patterns are named solutions to problems that keep recurring, and they are the shared vocabulary of developers: "use a builder here" is understood at once. The Java libraries and Spring are full of them, and pattern questions appear in most interviews beyond junior level.
Singleton — Exactly One Instance
The Singleton pattern ensures a class has only one instance for the entire application and provides a single global access point to it. It is implemented with a private constructor (so outside code cannot call new), a private static field holding the one instance, and a public static method that returns it. Singletons are commonly used for things like configuration objects, connection pools, or loggers where having two competing instances would cause bugs.
- A naive Singleton (lazy initialization without synchronization) is not thread-safe — two threads can both see the field as null and each construct a separate instance.
- Common fixes: synchronize the accessor method, use double-checked locking with a
volatilefield, or simply initialize the field eagerly at class-loading time (the JVM guarantees class initialization is thread-safe). - Overuse of Singleton is a real code smell — it introduces hidden global state and makes unit testing harder, since tests cannot easily substitute a mock instance.
Factory — Centralizing Object Creation
The Factory pattern moves the logic of "which concrete class to instantiate" out of client code and into one dedicated method or class. Instead of scattering new CreditCardPayment() or new PaypalPayment() calls everywhere, calling code asks a factory for a "Payment" and receives the correct concrete type based on some input. This decouples client code from concrete classes — clients only depend on an interface or abstract type, so adding a new payment method later means changing the factory, not every call site.
Builder — Constructing Complex Objects Step by Step
The Builder pattern solves the problem of constructors with too many parameters (sometimes called the "telescoping constructor" problem), especially when many of those parameters are optional. Instead of one constructor with ten parameters where most are unused, a Builder exposes fluent, chainable methods like .name(...) and .age(...), each returning the builder itself, ending with a .build() call that produces an immutable, fully-constructed object.
Observer — Publish-Subscribe Notifications
The Observer pattern defines a one-to-many dependency: when one object (the "subject" or "publisher") changes state, all of its registered dependents (the "observers" or "listeners") are notified automatically. This is the foundation behind GUI event listeners, messaging systems, and reactive libraries. The subject keeps a list of observers and calls a notification method on each of them whenever a relevant change occurs, without needing to know anything about what the observers actually do with that notification.
Examples
Thread-safe Singleton using eager initialization, plus a simple Factory
class AppConfig {
// Eager initialization: the JVM loads the class (and this field) only once,
// and class loading itself is thread-safe, so no extra synchronization is needed.
private static final AppConfig INSTANCE = new AppConfig();
private String environment = "production";
private AppConfig() { } // private constructor blocks "new AppConfig()" elsewhere
public static AppConfig getInstance() {
return INSTANCE;
}
public String getEnvironment() {
return environment;
}
}
interface Notifier {
void send(String message);
}
class EmailNotifier implements Notifier {
public void send(String message) { System.out.println("Email: " + message); }
}
class SmsNotifier implements Notifier {
public void send(String message) { System.out.println("SMS: " + message); }
}
class NotifierFactory {
static Notifier create(String type) {
return switch (type) {
case "email" -> new EmailNotifier();
case "sms" -> new SmsNotifier();
default -> throw new IllegalArgumentException("Unknown type: " + type);
};
}
}
public class PatternDemo {
public static void main(String[] args) {
System.out.println(AppConfig.getInstance().getEnvironment());
Notifier notifier = NotifierFactory.create("sms");
notifier.send("Order shipped");
}
}
production
SMS: Order shipped
A fluent Builder and a minimal Observer (publish-subscribe)
class User {
private final String name;
private final int age;
private final String email;
private User(Builder b) {
this.name = b.name;
this.age = b.age;
this.email = b.email;
}
public String toString() {
return name + " (" + age + ") <" + email + ">";
}
static class Builder {
private String name;
private int age;
private String email;
Builder name(String name) { this.name = name; return this; }
Builder age(int age) { this.age = age; return this; }
Builder email(String email) { this.email = email; return this; }
User build() { return new User(this); }
}
}
interface Observer {
void update(double price);
}
class StockTicker {
private final java.util.List<Observer> observers = new java.util.ArrayList<>();
void subscribe(Observer o) { observers.add(o); }
void setPrice(double price) {
for (Observer o : observers) {
o.update(price);
}
}
}
public class BuilderObserverDemo {
public static void main(String[] args) {
User user = new User.Builder().name("Asha").age(28).email("asha@webnest.dev").build();
System.out.println(user);
StockTicker ticker = new StockTicker();
ticker.subscribe(price -> System.out.println("Dashboard sees price: " + price));
ticker.subscribe(price -> System.out.println("Alert service sees price: " + price));
ticker.setPrice(142.75);
}
}
Asha (28) <asha@webnest.dev>
Dashboard sees price: 142.75
Alert service sees price: 142.75
Common Mistakes
- Writing a lazily-initialized Singleton without synchronization and assuming it is safe — under concurrent access, two threads can create two separate instances.
- Using Singleton as a substitute for proper dependency injection, which makes classes hard to unit test because a mock instance cannot easily be substituted.
- Making Builder fields mutable after
build(), defeating the purpose of producing a safe, immutable object. - Forgetting to unsubscribe an Observer, which causes memory leaks (the subject holds a reference to the observer forever) in long-lived applications.
Exercise
Complete the nested Builder of the User class so that a user can be created with a required name and optional email and age, set through chained calls. Then complete the singleton Config so that every call to getInstance() returns the same object.
Expected output: asha a@example.com 30 then true
class User {
private final String name;
private final String email;
private final int age;
private User(Builder builder) {
this.name = builder.name;
this.email = builder.email;
this.age = builder.age;
}
@Override
public String toString() {
return name + " " + email + " " + age;
}
static class Builder {
private final String name;
private String email = "";
private int age;
Builder(String name) {
this.name = name;
}
Builder email(String email) {
// TODO: store the email
return this;
}
Builder age(int age) {
// TODO: store the age
return this;
}
User build() {
// TODO: return a new User made from this builder
return null;
}
}
}
class Config {
// TODO: a single private static instance and a private constructor
static Config getInstance() {
// TODO: return the single instance
return new Config();
}
}
public class Patterns {
public static void main(String[] args) {
User user = new User.Builder("asha").email("a@example.com").age(30).build();
System.out.println(user);
System.out.println(Config.getInstance() == Config.getInstance());
}
}
Show hints
- Each setter of the builder assigns its field and returns
this, which is what allows the calls to be chained. - The simplest thread-safe singleton creates its instance in a
private static finalfield.
Show solution
class User {
private final String name;
private final String email;
private final int age;
private User(Builder builder) {
this.name = builder.name;
this.email = builder.email;
this.age = builder.age;
}
@Override
public String toString() {
return name + " " + email + " " + age;
}
static class Builder {
private final String name;
private String email = "";
private int age;
Builder(String name) {
this.name = name;
}
Builder email(String email) {
this.email = email;
return this;
}
Builder age(int age) {
this.age = age;
return this;
}
User build() {
return new User(this);
}
}
}
class Config {
private static final Config INSTANCE = new Config();
private Config() { }
static Config getInstance() {
return INSTANCE;
}
}
public class Patterns {
public static void main(String[] args) {
User user = new User.Builder("asha").email("a@example.com").age(30).build();
System.out.println(user); // asha a@example.com 30
System.out.println(Config.getInstance() == Config.getInstance()); // true
}
}
Quick quiz
1. Which pattern suits an object with many optional constructor parameters?
- A. Singleton
- B. Builder
- C. Observer
- D. Adapter
Show answer
B. It replaces long constructors, and overloads for every combination, with named, chained calls.
2. What does the Factory pattern centralise?
- A. The creation of objects, so callers need not know the concrete class
- B. Logging
- C. Thread scheduling
- D. Database access
Show answer
A. The caller asks for a product, and the factory decides which implementation to return.
3. In the Observer pattern, what happens when the subject changes state?
- A. The subject is destroyed
- B. A new subject is created
- C. Nothing happens until the observers poll it
- D. Every registered observer is notified
Show answer
D. Event listeners and publish-subscribe systems are built on this pattern.
Interview questions
How do you implement a thread-safe singleton in Java?
Show answer
The simplest safe forms rely on class initialisation, which the JVM performs once and thread-safely. Eager initialisation holds the instance in a private static final field. The holder idiom places it in a nested static class, so that it is created only on first use. An enum with a single constant is the most robust, since it is also safe against reflection and serialization. Double-checked locking works too but requires the field to be volatile.
Why is the singleton sometimes called an anti-pattern?
Show answer
A singleton is global state reached through a static method. Classes that use it hide that dependency, which makes them hard to test, because the singleton cannot easily be replaced with a fake, and state can leak from one test to the next. Dependency injection gives the same single shared instance without these problems: a container creates one object and passes it to whatever needs it, which is how Spring's default bean scope works.
Key Points to Remember
- Singleton guarantees one instance and one access point; eager initialization is the simplest thread-safe approach.
- Factory centralizes "which concrete class" decisions so client code depends only on an interface.
- Builder replaces telescoping constructors with a fluent, readable, immutable-object construction style.
- Observer implements publish-subscribe: a subject notifies all registered listeners automatically on state change.
Practice in the playground
Write and run your own code for this topic, or start from the exercise or an example. Runs plain Java 17. Frameworks such as Spring, and databases, are not available here. Your code for each topic is kept in this browser.
Run the examples
Change an input, predict the result, then compare it with the output. Explain why the result changes.
These examples need a JDK project with their libraries on your computer, so they can be copied but not run here. Codelab runs Python, FastAPI backends with SQLite, plain Java 17 and HTML/CSS/JavaScript.
Written by
WebNest Studio Engineering Team
Practical lessons from developers working with Java, Python, React, databases and production systems.
Led by Vansh Duggal, Founder, WebNest Studio · Full-stack & software engineering