Skip to content
Go back

Design Patterns for Creating Objects

Some common Design Patterns for creating objects.

Factory

A Factory is useful when the caller only needs to say what kind of object it wants, while something else takes care of creating the concrete class.

Factory Method

Suppose a report export system supports multiple formats:

def export_report(rows, fmt):

    if fmt == "csv":
        exporter = CsvExporter()

    elif fmt == "xlsx":
        exporter = XlsxExporter()

    elif fmt == "pdf":
        exporter = PdfExporter()

    else:
        raise ValueError(f"unsupported format: {fmt}")

    return exporter.export(rows)

There is nothing wrong with an if/elif like this.

The problem starts when the same logic is repeated in multiple places: the API, cron jobs, and CLI. When you add a new format, you have to update all of them. Forgetting just one can lead to different behavior in different parts of the system.

We can move the object creation logic into a factory:

_EXPORTERS = {
    "csv": CsvExporter,
    "xlsx": XlsxExporter,
    "pdf": PdfExporter,
}


def make_exporter(fmt: str):
    try:
        return _EXPORTERS[fmt]()
    except KeyError:
        raise ValueError(f"unsupported format: {fmt}")


def export_report(rows, fmt):
    return make_exporter(fmt).export(rows)

Now the logic for choosing the class lives in one place.

Adding JSON only requires creating JsonExporter and adding it to _EXPORTERS. Code that calls export_report() does not need to know which class is actually created.

The trade-off is that when you see make_exporter("pdf"), you cannot immediately tell which class will be used. You have to open the factory to find out.

In Python, a factory can often be as simple as a function. You do not necessarily need an abstract class or a complicated framework.

Use it when: there are multiple object types and more are likely to be added.

Avoid it when: there are only one or two types and the if statement is still easy to understand. A factory that only creates one type of object usually does not add much value.


Abstract Factory

Abstract Factory is also about creating objects, but instead of creating one object, it creates a family of related objects.

For example, a storage system might need three components:

blobs = ...
metadata = ...
locks = ...

In production, we use AWS:

blobs = S3BlobStore(session)
metadata = DynamoMetadata(session)
locks = DynamoLock(session)

Locally, we use local storage:

blobs = DiskBlobStore(root)
metadata = SqliteMetadata(root)
locks = FileLock(root)

The important part is that these three objects are supposed to be used together.

Someone could accidentally write:

blobs = DiskBlobStore(root)
metadata = SqliteMetadata(root)
locks = DynamoLock(session)

The code might still work locally, but the system is now using an inconsistent set of components.

Abstract Factory groups them into a backend:

class S3Backend:

    def blobs(self):
        return S3BlobStore(self._session)

    def metadata(self):
        return DynamoMetadata(self._session)

    def locks(self):
        return DynamoLock(self._session)


class LocalBackend:

    def blobs(self):
        return DiskBlobStore(self._root)

    def metadata(self):
        return SqliteMetadata(self._root)

    def locks(self):
        return FileLock(self._root)

At startup:

backend = (
    S3Backend(session)
    if env == "production"
    else LocalBackend(root)
)

From then on, all objects come from the same backend.

The main benefit of Abstract Factory is keeping related objects together as a consistent set.

The downside is that when you add a new type of object, such as events(), you have to add that method to every factory.

So Abstract Factory makes sense when you have multiple families of objects, and the objects within each family must always be used together.


Dependency Injection

Dependency Injection is also related to object creation, but its main goal is:

A class should not create the things it depends on.

For example:

class OrderService:

    def __init__(self):
        self.db = PostgresConnection(os.environ["DB_URL"])
        self.payments = VnpayGateway(os.environ["VNPAY_KEY"])
        self.clock = datetime

    def place(self, cart):
        ...

OrderService is deciding:

  • which database to use
  • which payment gateway to use
  • where to get configuration from
  • which clock to use

This makes the class harder to test and harder to change.

Instead:

class OrderService:

    def __init__(
        self,
        db: Database,
        payments: PaymentGateway,
        clock: Clock,
    ):
        self.db = db
        self.payments = payments
        self.clock = clock

    def place(self, cart):
        ...

Production:

service = OrderService(
    PostgresDatabase(url),
    VnpayGateway(key),
    SystemClock(),
)

Test:

service = OrderService(
    InMemoryDatabase(),
    FakeGateway(),
    FrozenClock("2026-01-01"),
)

Now OrderService does not need to know where those objects come from.

This gives us three benefits:

  1. The constructor clearly shows what the class depends on.
  2. Dependencies can easily be replaced when testing or changing implementations.
  3. Object creation is moved to one place, usually main() — also known as the composition root.

In Python, you usually do not need a DI framework. Constructor injection like the example above is enough for most projects.

Good candidates for injection are dependencies that can change or are difficult to control, such as:

  • database
  • HTTP client
  • external service
  • clock
  • random generator

Simple and stable things, such as format strings or a JSON serializer, usually do not need to be injected.


Builder

Builder solves a different problem: an object has too many options or needs to be built step by step.

For example:

report = Report(
    "Q3",
    "2026-01-01",
    "2026-03-31",
    True,
    False,
    True,
    None,
    "pdf",
    3,
)

Looking at True, False, True, it is hard to tell what they actually mean.

Another option is to create an empty object and set each field:

report = Report()
report.title = "Q3"
report.include_charts = True

# ... many more lines

render(report)

The problem is that this allows an incomplete object to exist. For example, if you forget to set the date range, you may not discover the problem until render() is called.

A Builder moves the object construction into one place:

class ReportBuilder:

    def __init__(self, title):
        self._cfg = {
            "title": title,
            "format": "pdf",
            "include_charts": False,
        }

    def date_range(self, start, end):
        self._cfg["start"] = start
        self._cfg["end"] = end
        return self

    def with_charts(self):
        self._cfg["include_charts"] = True
        return self

    def build(self):
        if "start" not in self._cfg:
            raise ValueError("date range is required")

        return Report(**self._cfg)

Usage:

report = (
    ReportBuilder("Q3")
    .date_range("2026-01-01", "2026-03-31")
    .with_charts()
    .build()
)

The methods return self, so they can be chained together.

A Builder can also validate the configuration before creating the object, ensuring that only a complete object is returned.

However, in Python, you do not always need a Builder.

If an object only has a few fields, keyword arguments are often enough:

@dataclass(frozen=True, kw_only=True)
class Report:
    title: str
    start: date
    end: date
    include_charts: bool = False
    format: str = "pdf"


report = Report(
    title="Q3",
    start=...,
    end=...,
    include_charts=True,
)

This is easier to read and does not require an additional Builder class.

Use a Builder when: the object is complex, has many options, needs to be built in multiple steps, or requires validation across multiple related fields.

If there are only a few fields, keyword arguments are usually enough.


Prototype

Prototype is useful when you have a pre-configured object and want to create multiple variations from it.

For example, suppose we have three very similar experiments:

variant_a = ExperimentConfig(
    model="v3",
    batch_size=32,
    layers=[128, 64],
    seed=1,
)

variant_b = ExperimentConfig(
    model="v3",
    batch_size=32,
    layers=[128, 64],
    seed=2,
)

variant_c = ExperimentConfig(
    model="v3",
    batch_size=32,
    layers=[128, 64],
    seed=3,
)

The three objects are almost identical.

If we change batch_size from 32 to 64 tomorrow, we have to remember to update all three places.

Instead, we can create a base object:

base = ExperimentConfig(
    model="v3",
    batch_size=32,
    layers=[128, 64],
    seed=1,
)

Then create variations from it:

from dataclasses import replace

variants = [
    replace(base, seed=seed)
    for seed in (1, 2, 3)
]

Now the shared configuration lives in one place.

Prototype is especially useful when objects have a lot of configuration and new objects only differ in a few values.

If you use copy, you also need to understand the difference between copy.copy() and copy.deepcopy().

v = copy.copy(base)
v.layers.append(32)

copy.copy() only creates a shallow copy. The layers list is still shared, so changing v.layers also changes base.layers.

deepcopy() copies the objects inside as well, but it is not always the right choice. If an object holds a database connection, socket, or another resource, copying the entire object can cause problems.

In Python, when possible, immutable objects combined with dataclasses.replace() are often a simpler approach.


Object Pool

Object Pool is useful when creating an object is expensive, but the object can be reused.

Database connections are a common example.

If every request creates a new connection:

def handle_request(req):

    conn = psycopg.connect(DB_URL)

    try:
        return query(conn, req)

    finally:
        conn.close()

You pay the cost of creating a connection for every request.

Instead, we can create a number of connections in advance and let requests borrow them:

with pool.connection() as conn:
    return query(conn, req)

When the request is done, the connection is returned to the pool so another request can reuse it.

The idea is simple:

Create once → use multiple times → return for reuse.

But implementing a pool yourself is more complicated than it looks.

A pool needs to handle questions such as:

  • What happens when there are no available connections — wait or fail?
  • How long should a request wait?
  • Is the connection still healthy?
  • Has the previous transaction been reset?
  • What happens when multiple threads or processes access the pool?

For example, forgetting to reset a connection could allow state from one request to affect the next one.

Because of this, you generally should not implement your own Object Pool.

Database drivers, HTTP clients, and many other libraries already provide connection pools.

What you mainly need to understand is how to configure the pool correctly:

  • How many connections should the pool allow?
  • Should requests wait or fail when the pool is exhausted?
  • Should connections be checked before they are reused?

Singleton

Singleton ensures that a class has only one instance across the entire application.

For example:

class Config:

    _instance = None

    def __new__(cls):

        if cls._instance is None:
            cls._instance = super().__new__(cls)

        return cls._instance

At first, this looks convenient:

Config().timeout

You can get the same object from anywhere.

But this is also where problems start.

For example, in tests:

def test_short_timeout():
    Config().timeout = 1


def test_normal_case():
    # timeout might still be 1

The second test might pass when run by itself but fail when run together with another test.

The reason is that the Singleton keeps shared state between tests.

There is another, bigger problem with Singleton: dependencies become hidden.

def process(order):
    timeout = Config().timeout

Looking at process(order), there is no obvious indication that the function depends on Config.

A simpler approach is to create the config once at startup and pass it down:

def process(order, config: Config):
    timeout = config.timeout

Production:

config = Config.from_env()
process(order, config)

Test:

process(order, Config(timeout=1))

This is Dependency Injection.

If the only goal is to have one shared object, Python does not necessarily need a Singleton class. Imported modules are already loaded and cached once:

# config.py

settings = Settings.from_env()

Then:

from config import settings

However, this still creates global state and can still hide dependencies.

So the question should not be:

“How do I make sure there is only one instance?”

Instead, ask:

“Do we really need every part of the application to share the same instance?”

If all you need is shared access, creating the object in main() and passing it down is usually simpler and easier to test.

Which One Should You Choose?

SituationPattern
The caller should not need to know the concrete classFactory Method
A group of objects must always be used togetherAbstract Factory
A class should not create its own dependenciesDependency Injection
An object has many options and needs to be built step by stepBuilder
You need multiple variations of an existing objectPrototype
A resource is expensive to create and can be reusedObject Pool
Multiple places need to share one instanceModule or Dependency Injection

The important thing is not to start with the pattern.

Start with the problem:

  • Too many places need to know the concrete class? → Think about Factory.
  • Several objects must always be used together? → Think about Abstract Factory.
  • A class creates its own database, HTTP client, or other dependencies? → Think about Dependency Injection.
  • An object has too many options? → Think about Builder.
  • You have a base object and need many variations? → Think about Prototype.
  • A resource is expensive to create and can be reused? → Think about a Pool.
  • You simply want multiple places to share one object? → You may not need Singleton.

A good pattern is not the pattern that gets used the most. It is the pattern that solves the actual problem without making the code more complicated.

Previous Post
Next Post