What is it? #
A factory is code whose job is to decide which concrete class to create and return it.
Without one, the decision gets duplicated. Five places each contain the same if provider == "stripe": ... elif provider == "razorpay": ..., and adding a sixth provider means finding all five.
With a factory, that decision lives in one function or class. Everything else receives an object that satisfies an interface and never learns which one it got.
There are two common shapes: a simple factory function that maps a key to a class, and the Factory Method pattern where subclasses decide what to create.
Think of it like this #
A car rental desk. You ask for "a small automatic" and receive keys. Which exact model you get is the desk's decision, based on what is available.
You drive whatever arrives because every car has the same controls. If you had to know the exact model in advance, every branch would need different instructions.
Simple example #
An application supports three payment providers. Checkout, refunds and reconciliation all need a client, and none of them should contain the provider-choosing logic.
Code #
from abc import ABC, abstractmethod
class PaymentGateway(ABC):
@abstractmethod
def charge(self, amount: float, token: str) -> str: ...
@abstractmethod
def refund(self, transaction_id: str) -> bool: ...
class StripeGateway(PaymentGateway):
def __init__(self, api_key): self.api_key = api_key
def charge(self, amount, token): return f"stripe-{amount}"
def refund(self, transaction_id): return True
class RazorpayGateway(PaymentGateway):
def __init__(self, key_id, key_secret): self.key_id = key_id
def charge(self, amount, token): return f"razorpay-{amount}"
def refund(self, transaction_id): return True
class SandboxGateway(PaymentGateway):
"""Used in development and tests — no network."""
def __init__(self): self.charges = []
def charge(self, amount, token):
self.charges.append(amount)
return f"sandbox-{amount}"
def refund(self, transaction_id): return True
# --- The factory: the ONLY place that knows about concrete gateways ---
def make_gateway(config: dict) -> PaymentGateway:
provider = config.get("provider", "sandbox")
if provider == "stripe":
return StripeGateway(api_key=config["stripe_key"])
if provider == "razorpay":
return RazorpayGateway(key_id=config["rzp_id"], key_secret=config["rzp_secret"])
if provider == "sandbox":
return SandboxGateway()
raise ValueError(f"unknown payment provider: {provider}")
# --- Registry variant: adding a provider requires no edit to the factory ---
GATEWAYS: dict[str, type[PaymentGateway]] = {}
def register(name):
def wrap(cls):
GATEWAYS[name] = cls
return cls
return wrap
@register("paypal")
class PayPalGateway(PaymentGateway):
def charge(self, amount, token): return f"paypal-{amount}"
def refund(self, transaction_id): return True
def make_registered(name: str) -> PaymentGateway:
try:
return GATEWAYS[name]()
except KeyError:
raise ValueError(f"unknown provider: {name}") from None
# Callers depend only on the interface
gateway = make_gateway({"provider": "sandbox"})
print(gateway.charge(999.0, "tok_abc")) # sandbox-999.0
When to use it
- several interchangeable implementations chosen at runtime or by config
- construction needs arguments the caller should not have to know
- you want one place to change when a new implementation is added
When NOT to use it
- only one implementation exists and none is planned
- construction is a single obvious call
- the factory becomes a dumping ground for unrelated creation logic
How it works #
PaymentGateway defines what every provider must do. Callers depend on this and nothing else.
make_gateway is the factory. It reads configuration, picks a class, supplies whatever credentials that class needs, and returns it. All provider-specific construction knowledge is contained here.
Notice that each gateway needs different constructor arguments — Stripe takes one key, Razorpay takes two. Hiding that variation is a large part of the factory's value: callers ask for a gateway, not for a correctly-configured Razorpay client.
Returning SandboxGateway by default means local development and tests work with no credentials at all.
The registry variant goes one step further. New providers register themselves with a decorator, so adding one does not edit the factory. That satisfies the Open/Closed Principle, at the cost of the mapping being less obvious to read.
An error for an unknown provider matters. Silently falling back to a default would mean a typo in configuration sends real payments to a sandbox — or worse, the reverse.
Real-world use #
Configuration-driven implementation choice is everywhere: database drivers, storage backends, cache layers, authentication providers, export formats, notification channels.
Frameworks use the registry variant heavily. Serializer registries, plugin systems and command registries all map a name to a class.
Factories also centralise expensive or fiddly setup. Building an HTTP client with the right timeouts, retries and headers belongs in one factory rather than repeated at twenty call sites.
For tests, a factory that returns a sandbox implementation by default is a quiet but significant productivity win: nobody needs credentials to run the suite.
Common mistakes #
- Letting the factory return different interfaces depending on the input, so callers must check types.
- Putting business logic inside the factory. It should create objects, not make decisions about them.
- Growing one factory that creates twenty unrelated things.
- Falling back to a default silently instead of raising on unknown configuration.
- Adding a factory when there is exactly one implementation.
Practice #
Write a make_storage(config) factory returning local disk, in-memory or cloud storage based on a config key, each requiring different constructor arguments. Then add a fourth backend using the registry variant and confirm the factory function needed no changes.