Design Patterns in OOP

Understanding Design Patterns

Design patterns are proven solutions to common software‐design problems, providing standardised templates that improve code robustness, readability and maintainability.

We cover three key patterns, Singleton, Factory and Observer, and see how they tackle recurring challenges in object-oriented design.

Overview: Key Patterns

  • Singleton: Ensures only one instance of a class exists and provides a global access point.
  • Factory: Encapsulates object creation, returning different instances based on input parameters such as type, role or configuration.
  • Observer: Defines a one-to-many dependency so that when one object changes state, its dependents are notified automatically.

Deep Dive: Singleton

Restricts a class to a single instance and provides a global access point. A common real-world use is a shared database connection pool.

A database connection pool maintains a ready-to-use set of open connections to your database. Opening a new connection for every query is slow and can overwhelm the database with too many simultaneous requests. Instead, the pool keeps a limited number of connections open; when your code needs to talk to the database, it “borrows” a connection from the pool, uses it, then returns it. This approach reduces connection latency, caps resource usage on the database server, and ensures you don’t hit maximum-connection limits, leading to more reliable and better-performing applications.

Scenario: Database Connection Pool

All parts of your app draw from the same pool to avoid exhausting database resources.

# Python: ConnectionPool Singleton
import queue

class ConnectionPool:
    _instance = None  # single shared pool

    def __new__(cls, maxsize=5):
        # Create the pool only once
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance._init_pool(maxsize)
        return cls._instance

    def _init_pool(self, maxsize):
        # Initialise a queue of dummy connections
        self._pool = queue.Queue(maxsize)
        for _ in range(maxsize):
            self._pool.put(self._create_connection())

    def _create_connection(self):
        # Simulate a database connection object
        return object()

    def get_connection(self):
        # Borrow a connection from the pool
        return self._pool.get()

    def release(self, conn):
        # Return the connection to the pool
        self._pool.put(conn)

# Usage
pool1 = ConnectionPool(10)
pool2 = ConnectionPool()
assert pool1 is pool2       # both references are the same instance
conn = pool1.get_connection()
pool1.release(conn)
// Java: ConnectionPool Singleton (thread-safe)
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

class ConnectionPool {
    private static volatile ConnectionPool instance;
    private final BlockingQueue<Object> pool;

    private ConnectionPool(int maxsize) {
        pool = new ArrayBlockingQueue<>(maxsize);
        for (int i = 0; i < maxsize; i++) {
            pool.add(new Object()); // simulate a DB connection
        }
    }

    public static ConnectionPool getInstance(int maxsize) {
        if (instance == null) {
            synchronized (ConnectionPool.class) {
                if (instance == null) {
                    instance = new ConnectionPool(maxsize);
                }
            }
        }
        return instance;
    }

    public Object borrow() throws InterruptedException {
        return pool.take();
    }

    public void release(Object conn) {
        pool.offer(conn);
    }

    public static void main(String[] args) throws Exception {
        ConnectionPool p1 = ConnectionPool.getInstance(5);
        ConnectionPool p2 = ConnectionPool.getInstance(10);
        System.out.println(p1 == p2); // true (same instance)
        Object c = p1.borrow();
        p1.release(c);
    }
}

Deep Dive: Factory

Delegates object creation to a method or class. Ideal for services like notifications where you might support multiple methods of notification (channels).

By centralising the instantiation logic in a Factory, you separate (decouple) client code from concrete classes. Instead of sprinkling new EmailNotifier() or new SMSNotifier() throughout your application, you call a single NotifierFactory.create(channel) method. This makes your code easier to maintain - adding a new channel means updating only the Factory, and client code remains untouched - and adheres to the Open–Closed Principle (open for extension, closed for modification).

Scenario: Notification Factory

In a notification service, users may opt to receive alerts via email, SMS, or push notifications. The Factory checks the requested channel string and returns the correct Notifier subclass instance (e.g. EmailNotifier, SMSNotifier, PushNotifier). When a new channel is introduced - say, in-app notifications - you simply add a new notifier class and extend the Factory’s logic, without touching any of the existing notification calls.

Create EmailNotifier or SMSNotifier based on user preference without exposing constructors.

# Python: NotificationFactory
class Notifier:
    def notify_user(self, message: str) -> None:
        raise NotImplementedError

class EmailNotifier(Notifier):
    def notify_user(self, message: str) -> None:
        # send email
        print(f"[EMAIL] {message}")

class SMSNotifier(Notifier):
    def notify_user(self, message: str) -> None:
        # send SMS
        print(f"[SMS] {message}")

class NotifierFactory:
    @staticmethod
    def create(channel: str) -> Notifier:
        ch = channel.lower()
        if ch == "email":
            return EmailNotifier()
        if ch == "sms":
            return SMSNotifier()
        raise ValueError("Unknown channel")

# Usage
pref = "email"
n = NotifierFactory.create(pref)
n.notify_user("Welcome!")
// Java: NotificationFactory
interface Notifier {
    void notifyUser(String message);
}

class EmailNotifier implements Notifier {
    public void notifyUser(String message) {
        // send email
        System.out.println("[EMAIL] " + message);
    }
}

class SMSNotifier implements Notifier {
    public void notifyUser(String message) {
        // send SMS
        System.out.println("[SMS] " + message);
    }
}

class NotifierFactory {
    public static Notifier create(String channel) {
        switch (channel.toLowerCase()) {
            case "email": return new EmailNotifier();
            case "sms":   return new SMSNotifier();
            default:      throw new IllegalArgumentException("Unknown channel");
        }
    }
}

class DemoFactory {
    public static void main(String[] args) {
        Notifier n = NotifierFactory.create("email");
        n.notifyUser("Welcome!");
    }
}

Deep Dive: Observer

Defines a subscription mechanism to notify multiple observers automatically of state changes.

In the Observer pattern, a Subject maintains a list of Observer objects that have registered interest in its state. When the Subject’s data changes - say, a new message arrives in a chatroom or a sensor’s reading updates - it simply calls each observer’s update method. Observers can subscribe or unsubscribe at runtime, allowing dynamic interest management without modifying the Subject’s core logic.

This decouples (separates) the event source from its handlers: the Subject knows nothing about what Observers do with the update. Whether you’re updating UI components in response to model changes, broadcasting market data to trading algorithms, or pushing notifications to mobile devices, the Observer pattern keeps your architecture flexible and extensible.

Scenario: Stock Price Ticker

In a trading platform, multiple Trader modules register with a PriceFeed subject. When a new price comes in, the feed calls notifyAllObservers(), and each trader makes its decision, without the feed knowing trader internals.

# Python: Stock Ticker Observer
class StockTicker:
    def __init__(self):
        self._subscribers = []  # traders listening for updates

    def register(self, trader):
        # register a new trader observer
        self._subscribers.append(trader)

    def price_update(self, symbol, price):
        # notify all registered traders
        for t in self._subscribers:
            t.update(symbol, price)

class Trader:
    def __init__(self, name):
        self.name = name

    def update(self, symbol, price):
        # handle the new price
        print(f"{self.name} sees {symbol} at £{price}")

# Usage
ticker = StockTicker()
alice = Trader("Alice")
bob   = Trader("Bob")
ticker.register(alice)
ticker.register(bob)
ticker.price_update("ACME", 123.45)
// Java: Stock Ticker Observer
import java.util.*;

interface PriceObserver {
    void update(String symbol, double price);
}

class StockTicker {
    private final List<PriceObserver> subs = new ArrayList<>();

    public void register(PriceObserver obs) {
        subs.add(obs);
    }

    public void priceUpdate(String symbol, double price) {
        for (PriceObserver o : subs) {
            o.update(symbol, price);
        }
    }
}

class Trader implements PriceObserver {
    private final String name;
    public Trader(String name) { this.name = name; }
    public void update(String symbol, double price) {
        System.out.println(name + " sees " + symbol + " at £" + price);
    }
}

class ObserverDemo {
    public static void main(String[] args) {
        StockTicker ticker = new StockTicker();
        Trader alice = new Trader("Alice");
        Trader bob   = new Trader("Bob");
        ticker.register(alice);
        ticker.register(bob);
        ticker.priceUpdate("ACME", 123.45);
    }
}

Example: ThemeManager (Singleton)

In a desktop or mobile app, you want every screen and component to use the same theme settings (colours, fonts, spacing). A Singleton ThemeManager ensures all parts of the UI stay in sync.

# Only one ThemeManager instance for consistent UI settings
class ThemeManager:
    _instance = None  # holds the single instance

    def __new__(cls, *args, **kwargs):
        # create instance only once
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance._init_defaults()  # set up defaults
        return cls._instance

    def _init_defaults(self):
        # default theme settings
        self.colour  = "light"
        self.font    = "Arial"
        self.spacing = 8

    def set_theme(self, colour: str, font: str, spacing: int) -> None:
        # update the shared theme
        self.colour  = colour
        self.font    = font
        self.spacing = spacing

    def get_theme(self) -> dict:
        # retrieve current theme settings
        return {"colour": self.colour, "font": self.font, "spacing": self.spacing}

# Usage example
tm1 = ThemeManager()
tm2 = ThemeManager()
assert tm1 is tm2               # both variables reference the same instance
tm1.set_theme("dark", "Roboto", 10)
print(tm2.get_theme())          # {'colour': 'dark', 'font': 'Roboto', 'spacing': 10}
// Singleton ThemeManager for application-wide UI settings
public class ThemeManager {
    private static ThemeManager instance;  // holds the single instance
    private String colour;
    private String font;
    private int spacing;

    private ThemeManager() {
        // initialise default theme
        this.colour  = "light";
        this.font    = "Arial";
        this.spacing = 8;
    }

    public static synchronized ThemeManager getInstance() {
        // create on first call, then always return same instance
        if (instance == null) {
            instance = new ThemeManager();
        }
        return instance;
    }

    public void setTheme(String colour, String font, int spacing) {
        // update theme settings
        this.colour  = colour;
        this.font    = font;
        this.spacing = spacing;
    }

    public String getColour() { return colour; }
    public String getFont()   { return font; }
    public int getSpacing()   { return spacing; }

    public static void main(String[] args) {
        ThemeManager t1 = ThemeManager.getInstance();
        ThemeManager t2 = ThemeManager.getInstance();
        System.out.println(t1 == t2);  // true, same instance
        t1.setTheme("dark", "Roboto", 10);
        System.out.printf("Theme: %s, %s, %d%n",
            t2.getColour(), t2.getFont(), t2.getSpacing());
    }
}

Example: ShapeFactory (Factory)

When creating multiple related object types, a Factory centralises the instantiation logic. Clients request a shape by name and receive the correct instance without knowing the concrete constructors.

# Factory hides instantiation details from the client
class Circle:
    def __init__(self, r):
        self.r = r  # radius property
    def draw(self):
        # drawing logic for circle
        print(f"Circle radius {self.r}")

class Rectangle:
    def __init__(self, w, h):
        self.w, self.h = w, h  # width and height properties
    def draw(self):
        # drawing logic for rectangle
        print(f"Rectangle {self.w}×{self.h}")

class ShapeFactory:
    @staticmethod
    def create(type: str, *args):
        # decide which shape to create based on type
        if type == "circle":
            return Circle(*args)
        elif type == "rectangle":
            return Rectangle(*args)
        else:
            # handle unknown types
            raise ValueError("Unknown type")

# Usage
s1 = ShapeFactory.create("circle", 5)       # returns a Circle instance
s2 = ShapeFactory.create("rectangle", 2, 3) # returns a Rectangle instance
s1.draw()   # prints: Circle radius 5
s2.draw()   # prints: Rectangle 2×3
// Defines a common interface for all shape products
interface Shape {
    void draw();
}

class Circle implements Shape {
    private double r;  // radius property
    public Circle(double r) {
        this.r = r;
    }
    @Override
    public void draw() {
        // drawing logic for circle
        System.out.println("Circle radius " + r);
    }
}

class Rectangle implements Shape {
    private double w, h;  // width and height properties
    public Rectangle(double w, double h) {
        this.w = w;
        this.h = h;
    }
    @Override
    public void draw() {
        // drawing logic for rectangle
        System.out.println("Rectangle " + w + "×" + h);
    }
}

// Central factory method encapsulating creation logic
class ShapeFactory {
    public static Shape create(String type, double... params) {
        switch (type) {
            case "circle":
                return new Circle(params[0]);
            case "rectangle":
                return new Rectangle(params[0], params[1]);
            default:
                // handle unknown types
                throw new IllegalArgumentException("Unknown type");
        }
    }
}

public class Demo {
    public static void main(String[] args) {
        // client code does not need to know constructors
        Shape s1 = ShapeFactory.create("circle", 5);
        Shape s2 = ShapeFactory.create("rectangle", 2, 3);
        s1.draw();  // Circle radius 5
        s2.draw();  // Rectangle 2×3
    }
}

Example: Chatroom (Observer)

In a chat application, users subscribe to a chatroom and receive messages whenever anyone posts. The Observer pattern decouples the chatroom (subject) from user clients (observers).

# ChatRoom subject that notifies all subscribed users
class ChatRoom:
    def __init__(self):
        self._users = []  # list of User observers

    def join(self, user):
        # add user to subscriber list
        self._users.append(user)

    def broadcast(self, message):
        # send message to all subscribers
        for u in self._users:
            u.receive(message)

class User:
    def __init__(self, name):
        self.name = name

    def receive(self, message):
        # handle incoming chat message
        print(f"{self.name} got: {message}")

# Usage
room = ChatRoom()
alice = User("Alice")
bob   = User("Bob")
room.join(alice)                 # Alice subscribes
room.join(bob)                   # Bob subscribes
room.broadcast("Hello everyone!")  # both users receive the message
import java.util.ArrayList;
import java.util.List;

// Observer interface for chat users
interface UserListener {
    void receive(String message);
}

// ChatRoom subject managing subscribers
class ChatRoom {
    private List<UserListener> users = new ArrayList<>();

    public void join(UserListener u) {
        // add user to list
        users.add(u);
    }

    public void broadcast(String msg) {
        // notify each user of new message
        for (UserListener u : users) {
            u.receive(msg);
        }
    }
}

// User implements the observer interface
class User implements UserListener {
    private String name;
    public User(String name) {
        this.name = name;
    }
    @Override
    public void receive(String message) {
        // handle incoming chat message
        System.out.println(name + " got: " + message);
    }
}

public class ChatDemo {
    public static void main(String[] args) {
        ChatRoom room = new ChatRoom();
        User a = new User("Alice");
        User b = new User("Bob");
        room.join(a);               // Alice joins chat
        room.join(b);               // Bob joins chat
        room.broadcast("Hello everyone!");  // both notified
    }
}

 Key Takeaways

  • Singleton ensures a single shared instance, ideal for global services like configuration.
  • Factory abstracts object creation, improving flexibility and decoupling.
  • Observer decouples publishers from subscribers, automating update notifications.
  • Choose patterns to solve recurring design challenges cleanly and consistently.