Tangled in a web of if/elif/else statements just to create objects? You’re not alone. As an application grows, the logic for deciding which object to instantiate can become scattered, duplicated, and a nightmare to maintain. Every new type you add forces you to hunt down and update every conditional block across your codebase.
This violates core software design principles and leads to brittle, unreadable code. But there’s a classic, elegant solution: the Simple Factory pattern.
The Simple Factory is a creational design pattern that provides a centralized, static method to create objects, hiding the complex instantiation logic from the client. Instead of the client deciding which class to instantiate, it simply asks the factory for an object of a certain type.
Let’s break down how it works and refactor a messy codebase into a clean, professional one.
The Core Idea: A Visual Overview
At its heart, the Simple Factory pattern is about centralizing responsibility. It introduces a single point in your application for object creation, making your system cleaner and easier to manage.
mindmap
root((Simple Factory))
Purpose
::icon(fa fa-cogs)
Centralize object creation logic
Decouple client from concrete classes
Problem Solved
::icon(fa fa-bug)
Scattered `if/else` blocks
Code duplication
Violates Single Responsibility Principle (SRP)
Components
::icon(fa fa-puzzle-piece)
Client
Factory
Product (Interface/Abstract Class)
Concrete Products
Benefits
::icon(fa fa-thumbs-up)
Improved Maintainability
Enhanced Extensibility
Cleaner Codebase
The Problem: A Logistic System Riddled with Conditionals
Imagine we’re building a logistics application. The business needs to ship goods via trucks or ships. A junior developer might approach this by creating a couple of classes and using an if/elif block in the client code to decide which transport method to use.
First, let’s set up the project structure.
logistics_project/
├── main.py
└── transports.py
Our transports.py file defines the “products”—an abstract transport class and the concrete implementations.
# transports.py
from abc import ABC, abstractmethod
class Transport(ABC):
"""An abstract base class for all transport types."""
@abstractmethod
def deliver(self, item: str):
pass
class Truck(Transport):
"""A concrete class for truck transport."""
def deliver(self, item: str):
print(f"Delivering '{item}' by land in a truck.")
class Ship(Transport):
"""A concrete class for ship transport."""
def deliver(self, item: str):
print(f"Delivering '{item}' by sea in a container ship.")
The main.py file acts as the client. It takes user input and decides which object to create.
# main.py
from transports import Truck, Ship, Transport
def get_transport(transport_type: str, item: str) -> Transport:
"""
Problematic function that creates transport objects based on a string type.
"""
transport: Transport
if transport_type == "truck":
transport = Truck()
elif transport_type == "ship":
transport = Ship()
else:
raise ValueError(f"Unknown transport type: {transport_type}")
transport.deliver(item)
return transport
# --- Client Code ---
get_transport("ship", "Medical Supplies")
[!WARNING] This approach has several major flaws:
- Violation of Single Responsibility Principle (SRP): The client code is now responsible for both its primary job and the logic of object creation.
- Code Duplication: If another part of the application needs to create a transport object, you’ll have to duplicate this
if/eliflogic.- Poor Maintainability: What happens when the business wants to add a new transport method?
The Pain of Extension
The business now wants to add airplanes as a shipping option. With our current design, we have to modify the if/elif block in main.py.
First, we add the new class to transports.py:
# transports.py
# ... (Truck and Ship classes remain the same) ...
class Airplane(Transport):
"""A new concrete class for airplane transport."""
def deliver(self, item: str):
print(f"Delivering '{item}' by air in a cargo plane.")
Now, we must update the client code. This is where the design starts to break down.
# main.py
- from transports import Truck, Ship, Transport
+ from transports import Truck, Ship, Airplane, Transport
def get_transport(transport_type: str, item: str) -> Transport:
"""
This function gets more complex with every new transport type.
"""
transport: Transport
if transport_type == "truck":
transport = Truck()
elif transport_type == "ship":
transport = Ship()
+ elif transport_type == "airplane":
+ transport = Airplane()
else:
raise ValueError(f"Unknown transport type: {transport_type}")
transport.deliver(item)
return transport
# --- Client Code ---
- get_transport("ship", "Medical Supplies")
+ get_transport("airplane", "Urgent Documents")
Imagine this logic is repeated in 5 different places in your application. You’d have to find and update all 5 locations. If you miss one, you introduce a bug. This is not scalable.
The Solution: Centralizing Creation with a Simple Factory
Let’s refactor this mess using the Simple Factory pattern. We’ll create a dedicated TransportFactory class whose only job is to create transport objects.
First, let’s update our file structure to include the factory.
logistics_project/
├── main.py
├── transports.py
└── factory.py
Now, we create the TransportFactory in factory.py. This class will contain a static method that encapsulates the entire creation logic.
# factory.py
from transports import Transport, Truck, Ship, Airplane
class TransportFactory:
"""
A Simple Factory for creating transport objects.
It centralizes the creation logic.
"""
@staticmethod
def create_transport(transport_type: str) -> Transport:
"""
Creates and returns a transport object based on the type.
This is a more Pythonic way than a switch or if/elif chain.
"""
transport_map = {
"truck": Truck,
"ship": Ship,
"airplane": Airplane,
}
transport_class = transport_map.get(transport_type)
if not transport_class:
raise ValueError(f"Invalid transport type: {transport_type}")
return transport_class()
[!TIP] Pythonic Implementation: Instead of an
if/elif/elsechain or amatch/casestatement, using a dictionary to map strings to classes is a common and highly extensible Python idiom. It’s clean, readable, and easy to add new types to.
The factory’s process is simple and clear:
graph TD
A[Client] -- Requests 'truck' --> B(TransportFactory);
B -- Looks up 'truck' in its map --> C{Finds Truck Class};
C -- Instantiates --> D[Truck Object];
B -- Returns object --> A;
Refactoring the Client to Use the Factory
With the factory in place, our client code in main.py becomes incredibly simple and clean. It no longer needs to know about Truck, Ship, or Airplane. It only knows about the factory.
Here is the evolution of our main.py:
# main.py
- from transports import Truck, Ship, Airplane, Transport
+ from factory import TransportFactory
def get_transport(transport_type: str, item: str):
"""
- Problematic function that creates transport objects based on a string type.
+ Clean function that uses a factory to create transport objects.
"""
- transport: Transport
- if transport_type == "truck":
- transport = Truck()
- elif transport_type == "ship":
- transport = Ship()
- elif transport_type == "airplane":
- transport = Airplane()
- else:
- raise ValueError(f"Unknown transport type: {transport_type}")
+ transport = TransportFactory.create_transport(transport_type)
transport.deliver(item)
- return transport
# --- Client Code ---
- get_transport("airplane", "Urgent Documents")
+ get_transport("truck", "Heavy Machinery")
+ get_transport("ship", "Consumer Electronics")
+ get_transport("airplane", "Vaccines")
Look at how clean that is! The if/elif chain is gone, replaced by a single, descriptive call to the factory.
The benefits are now crystal clear:
- SRP is Restored: The client code is no longer responsible for creation logic.
- Logic is Centralized: All creation logic is in
TransportFactory. If we need to add aDronetransport, we only modify the factory’s dictionary. No client code needs to change. - Code is Decoupled:
main.pyis no longer coupled to the concreteTruck,Ship, orAirplaneclasses. It only depends on theTransportFactory.
🧠 Pop Quiz: Test Your Understanding
Question: If the business decides to add "Drone" as a new transport method, which file(s) MUST you modify? **A)** `main.py` only **B)** `transports.py` and `main.py` **C)** `transports.py` and `factory.py` **D)** `main.py`, `transports.py`, and `factory.py`
Click for Answer
Correct Answer: C) `transports.py` and `factory.py`
You would first create the `Drone` class in `transports.py` (implementing the `Transport` interface). Then, you would update the `transport_map` dictionary in `factory.py` to include the new "drone" type. The client code in `main.py` requires no changes at all!
When Should You Use a Simple Factory?
While powerful, the Simple Factory isn’t a silver bullet.
[!NOTE] Use the Simple Factory pattern when:
- The object creation process is complex and involves conditional logic.
- You want to decouple your client code from the specific classes it needs to instantiate.
- You have a set of classes that implement the same interface or share a common base class, and the client needs to select one at runtime.
- You want to centralize creation logic to improve maintainability and avoid code duplication.
However, if your object creation is simple (e.g., a direct new MyObject()) and unlikely to change, a factory might be an unnecessary layer of abstraction. As the original transcript noted, if the logic is only used in one single place and is very simple, you might not need the pattern.
Conclusion
The Simple Factory pattern is a fundamental tool for writing clean, maintainable, and scalable object-oriented code. By centralizing object creation, you decouple your client from concrete implementations, adhere to the Single Responsibility Principle, and make your system far easier to extend.
The next time you find yourself writing an if/elif/else block to instantiate objects, take a moment to consider if a Simple Factory could do the job better. Your future self will thank you.