Design Patterns in Object-Oriented Programming: A Beginner’s Guide

Updated on
10 min read

A design pattern is a universal solution to recurring design problems in software development. Instead of concrete code that you directly implement, design patterns are templates that help developers communicate their intent effectively. If you’re new to programming or want to enhance your knowledge of Object-Oriented Programming (OOP), this guide is tailored for you. We will explore essential design patterns organized into three categories: creational, structural, and behavioral. Additionally, you’ll find explanations, code examples, comparisons, and practical exercises to apply what you learn.

For further illustrated references, visit Refactoring Guru.


Why Use Design Patterns? Benefits and Cautions

Benefits

  • Readability & Communication: Named patterns, such as Factory, Observer, and Strategy, allow for quick discussion of design choices.
  • Maintainability: Patterns help decouple responsibilities and minimize code duplication.
  • Reuse of Experience: They encompass design wisdom, showing that common problems have common solutions.

Cautions

  • Overuse: Applying patterns indiscriminately, or “pattern fever,” can complicate your design.
  • Premature Optimization: Patterns should address real problems rather than hypothetical situations.
  • Testing Challenges: Some patterns, like Singleton, can complicate testing if misapplied.

Practical Tip: Always prefer straightforward, readable solutions. Use a pattern only when it enhances clarity, extensibility, or testability. Start with principles like SOLID — patterns often emerge naturally when refactoring towards those goals.


Pattern Classification: Creational, Structural, Behavioral (Overview)

Design patterns can be classified into three high-level categories:

  • Creational: Focus on object creation and lifecycle management (e.g., Singleton, Factory Method, Builder).
  • Structural: Help organize classes and objects into larger structures (e.g., Adapter, Facade, Decorator).
  • Behavioral: Define communication patterns between objects (e.g., Observer, Strategy, Command).

A quick method to determine a category is to ask whether your problem involves creating objects, organizing them, or managing their interactions.


Core Creational Patterns (with Simple Examples)

Singleton — Intent and Pitfalls

Intent: Ensure a class has a single instance with global access.

Use Cases: Typically used for configuration holders, loggers, or connection managers. Exercise caution.

Pitfalls: Risks include global state, hidden dependencies, and testing difficulties.

Java Example (Thread-safe Lazy Singleton):

public class Logger {
  private static volatile Logger instance;
  private Logger() {}
  public static Logger getInstance() {
    if (instance == null) {
      synchronized(Logger.class) {
        if (instance == null) instance = new Logger();
      }
    }
    return instance;
  }
  public void log(String m) { System.out.println(m); }
}

Python Example (Simpler Module-Level Singleton):

# logger.py
class Logger:
    def log(self, msg):
        print(msg)

logger = Logger()  # module-level singleton

Advice: Favor dependency injection over Singletons for better testability.

Factory Method & Simple Factory — When to Use

Intent: Encapsulate object creation allowing clients to depend on interfaces rather than concrete classes.

Use When: Creating objects from a family of types while hiding the construction logic.

Java-Like Example (Simple Factory):

interface Notification { void send(String msg); }
class EmailNotification implements Notification { public void send(String m){} }
class SMSNotification implements Notification { public void send(String m){} }

class NotificationFactory {
  public static Notification create(String type) {
    switch(type) {
      case "email": return new EmailNotification();
      case "sms": return new SMSNotification();
      default: throw new IllegalArgumentException();
    }
  }
}

// Usage
Notification n = NotificationFactory.create("email");

The Factory Method is more extensible as it allows subclasses to decide which concrete class to instantiate, while the Simple Factory is a pragmatic choice.

Builder — For Complex Object Construction

Intent: Construct complex objects step-by-step, especially with many optional parameters while avoiding cumbersome overloaded constructors.

Java Fluent Builder Example:

public class User {
  private final String name; private final String email; private final int age;
  private User(Builder b) { name=b.name; email=b.email; age=b.age; }
  public static class Builder {
    private String name; private String email; private int age;
    public Builder name(String n){ this.name=n; return this; }
    public Builder email(String e){ this.email=e; return this; }
    public Builder age(int a){ this.age=a; return this; }
    public User build(){ return new User(this); }
  }
}

// Usage
User u = new User.Builder().name("Ana").email("[email protected]").age(30).build();

The Builder pattern enhances readability and allows the creation of immutable objects.


Core Structural Patterns (with Simple Examples)

Adapter — Adapting One Interface to Another

Intent: Convert the interface of a class into another interface that clients expect.

Real-World Analogy: Think of a power plug adapter.

Example: Adapting a legacy logging API to a new Logger interface (Python-like pseudocode):

class OldLogger:
    def write(self, level, message):
        # old method
        pass

class LoggerAdapter:
    def __init__(self, old_logger):
        self.old = old_logger
    def info(self, msg):
        self.old.write('INFO', msg)
    def error(self, msg):
        self.old.write('ERROR', msg)

Use the Adapter pattern when integrating with legacy code that cannot be altered.

Facade — Simplifying Complex Subsystems

Intent: Provide a simplified, unified interface to a subsystem.

Use Case: A start-up/shutdown sequence requiring multiple class interactions can be encapsulated in a single Facade method.

Example (Pseudo):

class PaymentSubsystem { /* processors and gateway interactions */ }
class InventorySubsystem { /* stock checks */ }
class OrderFacade {
  void placeOrder(Order o) {
    // validate, reserve stock, process payment, notify
  }
}

The Facade pattern enhances usability while shielding clients from changes within the subsystem.

Decorator — Adding Responsibilities at Runtime

Intent: Attach additional behavior to objects dynamically without resorting to subclassing.

Example: A text renderer using decorators to add bold/italic styles (Python-like):

class Renderer:
    def render(self): return "text"

class BoldDecorator(Renderer):
    def __init__(self, inner): self.inner = inner
    def render(self): return "<b>" + self.inner.render() + "</b>"

Decorators are preferable to deep inheritance structures for adding features.


Core Behavioral Patterns (with Simple Examples)

Observer — Publish/Subscribe Relationships

Intent: Define a one-to-many dependency so changes in one object result in notifications to all dependents.

Use Cases: Commonly used in event processing, UI updates, and reactive pipelines.

Simplified Pseudocode Example:

class Subject:
    def __init__(self): self.listeners = []
    def attach(self, l): self.listeners.append(l)
    def notify(self, data):
        for l in self.listeners: l.update(data)

class Listener:
    def update(self, data): print('got', data)

Frameworks often provide optimized event systems; the Observer pattern serves as the foundational model for these systems.

Strategy — Swapping Algorithms at Runtime

Intent: Define a set of algorithms, encapsulating each and making them interchangeable.

Use When: Different methods of executing a task exist, and conditional logic is to be avoided.

Java-Like Example:

interface SortStrategy { void sort(List<Integer> data); }
class BubbleSort implements SortStrategy { /* ... */ }
class QuickSort implements SortStrategy { /* ... */ }

class Sorter {
  private SortStrategy strategy;
  public Sorter(SortStrategy s) { this.strategy = s; }
  public void sort(List<Integer> d) { strategy.sort(d); }
}

Switch algorithms by altering the strategy object at runtime or using configuration.

Command — Encapsulating Requests/Undo Support

Intent: Encapsulate a request as an object for parameterization, queuing, logging, and undo functionality.

Use Case: Common in GUI actions, macros, or job queues.

Simple Command Example (Pseudo):

interface Command { void execute(); void undo(); }
class AddCommand implements Command {
  private Receiver r; private int value;
  public void execute(){ r.add(value); }
  public void undo(){ r.remove(value); }
}

Commands simplify implementation of redo/undo stacks and allow operation serialization for background processing.


Comparison Table: When to Use Which Pattern

PatternCategoryWhen to UseTrade-offs
SingletonCreationalSingle shared resource (config)Global state, testing difficulty
Factory / Factory MethodCreationalHide construction, return interfaceIndirection, extra classes
BuilderCreationalComplex objects, many paramsSlightly more code, but clearer
AdapterStructuralIntegrate legacy/3rd-party APIAdapter layer to maintain
FacadeStructuralSimplify subsystem surfaceCan hide needed flexibility
DecoratorStructuralAdd responsibilities at runtimeMany small wrapper classes
ObserverBehavioralPublish/subscribe eventsCan be hard to trace flow
StrategyBehavioralSwap algorithms without conditionalsSlightly more objects, easier testing
CommandBehavioralQueueing/undo/log operationsRequires command objects and receivers

How to Recognize When to Use a Pattern (Practical Advice)

Symptoms Indicating Pattern Use:

  • Recurring duplication or similar construction code across modules (consider Factory/Builder).
  • Large conditional blocks that switch behavior (Strategy or Command may assist).
  • Tight coupling to concrete classes (introduce a Factory or Adapter for decoupling).
  • Complex subsystem initialization repeated in various places (Facade is beneficial).

Key Questions Before Applying a Pattern:

  1. Does this make the code easier to read or extend? 2. Is there a simpler refactor that addresses the issue? 3. Will this pattern complicate testing? 4. Can dependency injection resolve this instead?

Simple Checklist:

  • Identify the problem (creation/structure/behavior).
  • Begin with the simplest solution and include tests.
  • If issues arise again, refactor toward a pattern while keeping tests passing.

Common Anti-patterns and Pitfalls

  • Over-engineering: Avoid creating patterns for unique problems.
  • Reinventing the Wheel: Utilize existing language features and libraries first, like Java’s DI frameworks or .NET decorators.
  • Hidden Complexity: Custom implementations of patterns can confuse team members without documentation.
  • Performance: Some structural patterns, like deep stacks of decorators, may introduce runtime overhead.

Introduce patterns gradually and ensure unit tests accompany enhancements for safer refactoring.


Learning Path and Practice Exercises

Practice Approach:

  1. Implement a naive version first. 2. Add unit tests. 3. Refactor to a pattern. 4. Compare clarity and testability.

Exercises (try them in Java, C#, or Python):

  1. Singleton Logger: Implement a simple logger as a Singleton and refactor to eliminate global state using dependency injection.
  2. Adapter: Adapt a legacy API OldPaymentProcessor.process(amount) to use with a new PaymentGateway.charge(amount, currency). Write an Adapter for compatibility.
  3. Strategy: Create a sorter that accepts multiple sorting strategies (QuickSort, MergeSort) and assess performance on various data sizes.
  4. Command + Undo: Develop a text editor buffer where edit operations are represented as Command objects that support undo/redo functionalities.

Project Ideas:

  • Develop a plugin system (Observer/Command) for a CLI tool.
  • Construct a small e-commerce checkout system using Facade to streamline interactions among payment, inventory, and notification subsystems.

For an architectural framework that complements these patterns, consider reading about the Ports and Adapters (Hexagonal) design pattern.


Resources, Further Reading, and Next Steps

Authoritative References and Tutorials:

  • Gang of Four — Design Patterns: Elements of Reusable Object-Oriented Software (Classic Catalog): Amazon
  • Refactoring Guru — Illustrated Pattern Reference: Refactoring.Guru
  • Oracle Java Tutorials — Design Patterns in Java: Oracle

Other Learning Methods:

Interested in sharing a case study? Consider contributing a guest post that demonstrates how design patterns benefitted your project: Submit Guest Post.


Conclusion

Design patterns are invaluable tools in your development toolkit — not rigid rules to be implemented without thought. They enable effective communication of intent and reuse of established solutions for real issues. Start small by choosing one exercise, implementing a straightforward solution, adding tests, and refactoring to the corresponding pattern. Share your experiences or snippets with your team, or submit a guest post to educate others on your learning journey.

If you found this guide useful, explore Refactoring Guru for visual illustrations and additional code examples, and consider diving into the foundational GoF book for a comprehensive understanding.

TBO Editorial

About the Author

TBO Editorial writes about the latest updates about products and services related to Technology, Business, Finance & Lifestyle. Do get in touch if you want to share any useful article with our community.