By Arjun Mehta
Design patterns are one of those concepts in software engineering that sound academic and theoretical until you realize you've been using them your whole career without knowing they had names.
A design pattern is a reusable solution to a common problem in software design. It's not a library you import. It's not specific code. It's a way of structuring your code to solve a recurring problem in a way that's proven to work and easy for other engineers to understand.
The power of design patterns isn't that they're clever. It's that they're common. When a new engineer sees "we use the Observer pattern here," they immediately understand what's happening and why. The pattern becomes a shared vocabulary that makes communication easier and code more predictable.
This guide walks through the design patterns that actually matter in modern software engineering—the ones you'll see repeatedly and the ones that solve real problems.
Why Design Patterns Matter
Before diving into specific patterns, it's worth understanding why they matter.
Without design patterns, each engineer solves the same problems in different ways. One engineer creates an event system using callbacks. Another uses a pub/sub message queue. A third writes direct coupling between components. All three solve the same problem, but in incompatible ways. New engineers are confused. Code is hard to reason about. Bugs hide in unexpected places.
With design patterns, you have a shared vocabulary. "Let's use the Observer pattern for this" tells everyone exactly what you're doing. Code is predictable. New engineers understand the architecture without extensive explanation. You're not reinventing solutions; you're reusing proven ones.
Design patterns also help with:
Maintainability: Code organized by known patterns is easier to maintain and extend because the structure is predictable.
Onboarding: New engineers learn faster when code follows familiar patterns.
Coupling and Cohesion: Good patterns help you decouple components while keeping related code together.
Testing: Well-designed patterns are usually easy to test because dependencies are explicit and mockable.
Collaboration: When you say "extract this into a Factory," everyone knows what you mean.
The Core Patterns
1. Singleton Pattern
The Singleton pattern ensures only one instance of a class exists, and provides a global point of access to it.
When to use it: When you need a single, shared resource. A logger. A database connection pool. Configuration management.
Example: Your application needs one database connection pool. You don't want to create a new pool every time you need to query the database.
class DatabasePool:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance.connections = []
return cls._instance
Why it matters: Without this pattern, you might accidentally create multiple connection pools, each with its own connections, wasting resources and causing issues.
Common mistake: Using Singletons for things that should be passed as dependencies. This creates hidden coupling. If a class silently depends on a Singleton, it's harder to test and harder to understand.
2. Factory Pattern
The Factory pattern creates objects without specifying the exact classes to create.
When to use it: When object creation is complex, or when you need to create different types based on some condition.
Example: You have a payment system that supports multiple processors (Stripe, PayPal, Square). Instead of code doing:
if processor_type == 'stripe':
processor = StripeProcessor(config)
elif processor_type == 'paypal':
processor = PayPalProcessor(config)
You create a Factory:
class ProcessorFactory:
@staticmethod
def create_processor(processor_type, config):
processors = {
'stripe': StripeProcessor,
'paypal': PayPalProcessor,
'square': SquareProcessor
}
return processors[processor_type](config)
Why it matters: Now you can change which processor is used without changing code everywhere. Adding a new processor means updating one place (the Factory) instead of multiple.
3. Observer Pattern (Pub/Sub)
The Observer pattern lets one object notify many others when something happens.
When to use it: When an object needs to inform other objects about state changes, but you don't want tight coupling.
Example: Your e-commerce system has an Order object. When an order is placed, multiple things should happen: send a confirmation email, update inventory, update analytics. Without the Observer pattern, the Order object calls all these directly:
class Order:
def place(self):
self.save_to_database()
send_confirmation_email(self)
update_inventory(self)
record_analytics_event(self)
Now Order is tightly coupled to email, inventory, and analytics. If you want to add SMS notifications, you modify Order. That's a problem.
With Observer pattern:
class Order:
def __init__(self):
self.observers = []
def subscribe(self, observer):
self.observers.append(observer)
def place(self):
self.save_to_database()
for observer in self.observers:
observer.on_order_placed(self)
Now Order doesn't know or care what happens when it's placed. Other objects subscribe to events. You can add new observers without touching Order.
Why it matters: This is how modern event-driven architecture works. It's how you build systems where components don't need to know about each other.
4. Strategy Pattern
The Strategy pattern lets you select an algorithm at runtime.
When to use it: When you have multiple ways to accomplish something, and you need to choose between them based on conditions.
Example: Shipping costs are calculated differently based on shipping method:
class ShippingCalculator:
def calculate_cost(self, order, method):
if method == 'standard':
return order.weight * 0.5
elif method == 'express':
return order.weight * 2.0
elif method == 'international':
return order.weight * 5.0
This is messy and grows with each new shipping method. With Strategy pattern:
class StandardShippingStrategy:
def calculate(self, order):
return order.weight * 0.5
class ExpressShippingStrategy:
def calculate(self, order):
return order.weight * 2.0
class ShippingCalculator:
def __init__(self, strategy):
self.strategy = strategy
def calculate_cost(self, order):
return self.strategy.calculate(order)
Why it matters: New shipping methods can be added without modifying existing code. Each strategy is testable independently. The calculation logic is separated from the selection logic.
5. Decorator Pattern
The Decorator pattern adds behavior to objects dynamically without modifying their original class.
When to use it: When you need to add features to objects without changing their code or creating many subclasses.
Example: You have a Coffee class. You can add decorators (milk, sugar, caramel):
class Coffee:
def cost(self):
return 2.0
class MilkDecorator:
def __init__(self, coffee):
self.coffee = coffee
def cost(self):
return self.coffee.cost() + 0.5
class CaramelDecorator:
def __init__(self, coffee):
self.coffee = coffee
def cost(self):
return self.coffee.cost() + 0.75
# Use it
coffee = Coffee()
coffee_with_milk = MilkDecorator(coffee)
fancy_coffee = CaramelDecorator(coffee_with_milk)
print(fancy_coffee.cost()) # 3.25
Why it matters: You can compose behavior dynamically. No explosion of subclasses (MilkCoffee, CaramelCoffee, MilkCaramelCoffee, etc.).
Design Patterns in Modern Architecture
Repository Pattern
Abstracts data storage so the rest of your code doesn't care if you're using SQL, NoSQL, or Redis.
class UserRepository:
def get_by_id(self, user_id):
# Could query database, cache, API, whatever
pass
def save(self, user):
# Could write to database, queue, message bus
pass
Why it matters: You can swap storage implementations without changing business logic.
Dependency Injection
Pass dependencies to objects instead of having them create dependencies themselves.
Instead of:
class UserService:
def __init__(self):
self.repository = UserRepository() # Creates its own
Do:
class UserService:
def __init__(self, repository): # Receives dependency
self.repository = repository
Why it matters: Testing becomes trivial (pass mock repository). Code is loosely coupled. Dependencies are explicit.
Command Pattern
Encapsulates a request as an object, allowing you to parameterize clients with different requests, queue requests, and log requests.
Used in undo/redo systems, job queues, and event sourcing.
When NOT to Use Design Patterns
Design patterns are powerful, but they can also be overused. Here's when to skip them:
When you're solving a simple problem. A simple function is better than an over-engineered pattern.
When the pattern makes code harder to understand. If new engineers need 30 minutes of explanation to understand what you did, you've over-engineered.
When you're using a pattern just because it's clever. Use patterns to solve real problems, not to show off.
When the pattern contradicts your framework's philosophy. If you're using Django, Django's "batteries included" approach sometimes conflicts with heavy abstraction. Go with your framework.
Design Patterns and Codebase Health
Well-designed code using appropriate patterns is easier to understand and maintain. Tools like Glue help you see if your codebase is actually following good patterns or if it's devolving into spaghetti. You can see coupling patterns, identify where refactoring would help, and understand your architecture from the actual code (not just diagrams).
Frequently Asked Questions
Q: Should I memorize all design patterns?
A: No. Learn the core ones (Factory, Observer, Strategy, Singleton). When you encounter a problem, if you've seen a pattern that solves it, use it. Don't memorize everything.
Q: Is using design patterns required?
A: No. Well-structured code using appropriate patterns is great. Code that doesn't follow any pattern but is clear and maintainable is fine too. The goal is understandable, maintainable code. Patterns are a tool, not a requirement.
Q: Are design patterns language-specific?
A: No. The same patterns work across languages. A Factory in Python works the same way as a Factory in Java. Some languages make certain patterns easier or harder, but the concepts are universal.
Q: How do I know which pattern to use?
A: Ask what problem you're solving. Are you creating complex objects? Factory. Do you need loose coupling with notifications? Observer. Multiple strategies for the same task? Strategy. The pattern should be obvious from the problem, not the other way around.