Engineering Atlas › Low-Level Design

Factory Method pattern

13 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

The problem

A data export job writes reports to whatever storage a customer configures: local disk in development, S3 for most customers, Google Cloud Storage for a few, an in-memory store in tests. The first version has this in the export function:

def export(report, dest):
    if dest.startswith("s3://"):
        store = S3Store(bucket_from(dest), region=os.environ["AWS_REGION"])
    elif dest.startswith("gs://"):
        store = GcsStore(dest[5:])
    else:
        store = FileStore(dest)
    store.put(report.name, report.render())

The export logic now knows every backend, their constructor arguments and the environment variables they need. A second job that also writes to storage copies the same chain, and the copies drift: one supports gs://, the other forgot. Tests of export cannot use an in-memory store without adding a branch for it.

Pull the decision into one place

A factory takes the decision out of the business logic:

def open_store(url: str) -> Store:
    scheme, _, rest = url.partition("://")
    factory = STORES.get(scheme.lower())
    if factory is None:
        raise ValueError(f"unknown scheme: {scheme}")
    return factory(rest)

def export(report, store: Store):
    store.put(report.name, report.render())

export depends only on the Store interface and receives a store from its caller. The composition root calls open_store(config.dest) once. Adding Azure Blob Storage means writing a class and one registration; nothing that uses stores changes. That is the open/closed principle applied to object creation.

The classic form: an overridable method

In the Gang of Four version, a base class has a template algorithm that calls an abstract create_x() method, and each subclass overrides it:

class ReportExporter:
    def export(self, rows):
        writer = self.create_writer()   # the factory method
        for row in rows:
            writer.write(row)
        return writer.finish()

class CsvExporter(ReportExporter):
    def create_writer(self):
        return CsvWriter(delimiter=",")

This is useful inside frameworks, where the framework owns the algorithm and the application supplies one piece of it (Django's class-based views call get_queryset(), which you override). Outside frameworks, passing the writer or a writer factory as a parameter does the same job with composition, and avoids a class hierarchy whose only purpose is to choose one object.

Alternative constructors

Python's dict.fromkeys, datetime.fromtimestamp and int.from_bytes are factory methods in the plainest sense: classmethods that build an instance from a different kind of input. Go's convention is the same idea without classes: NewReader(r io.Reader) *Reader, errors.New, http.NewRequest. They exist because a constructor has one signature and cannot return an error (in Python) or can only be one function (in Go). Named factories give each way of building an object its own name and validation.

What a factory can do that a constructor cannot

When not to use it

Reviewing agent-written code

Asked to "support GCS as well as S3", an agent will find the if dest.startswith("s3://") chain and add an elif, usually in the business function, often in two places if the chain was copied. It may also invent a StorageFactory class with one static method, a StorageFactoryProvider and an abstract base for both. Both shapes are signs to stop and steer. Point the agent at one factory function at the edge, a registry if backends come from configuration, and constructor injection of the resulting Store into the code that uses it. In review, check that unknown kinds produce an error that names the bad value, that the factory is the only place that reads backend-specific environment variables, and that tests pass an in-memory implementation instead of patching the factory.

The same shape elsewhere

A factory keyed by scheme or name is the finding things fast shape in miniature: a lookup table from a key to a handler. The same structure routes HTTP paths to handlers, maps MIME types to decoders, maps Linux filesystem types (ext4, xfs) to their mount code in the kernel's filesystem registry, and resolves jdbc:postgresql:// URLs to drivers. Each one has the same failure modes: duplicate registration, a missing entry discovered only at runtime, and initialisation order deciding what is registered.

Down the stack

Layer 1The contract

A factory promises: for a known key and valid input it returns an object that satisfies the interface; for an unknown key or invalid input it fails with an error that names the problem, and never returns a half-built object or a nil interface without an error. Whether it returns a fresh or a shared instance is part of the contract and must be documented, because callers that mutate a shared instance will affect each other. A registry promises that registration happens before first use and that duplicate keys are rejected rather than silently overwritten.

Layer 2The mechanism

Go's database/sql is the textbook plugin factory. A driver package calls sql.Register("postgres", &Driver{}) in its init() function; Register takes a write lock on a package-level map[string]driver.Driver and panics on a duplicate name. Importing the driver for side effects (import _ "github.com/lib/pq") runs that init. sql.Open("postgres", dsn) takes a read lock, looks up the name, and returns an error sql: unknown driver "postgres" (forgotten import?) if it is missing. The image package does the same with image.RegisterFormat, matching magic bytes at the start of a file to pick a decoder. Python packages advertise factories through entry points in their metadata, which tools such as pytest load with importlib.metadata.entry_points(group=...). In the browser, document.createElement(tag) looks up the tag in a table of element interfaces and custom elements registered with customElements.define.

Layer 3The machine

Returning through an interface has a cost in Go: a concrete value stored in an interface usually escapes to the heap, so a factory returning Store instead of *MemoryStore may add one allocation per call, and the compiler can no longer inline methods called through it. Each call becomes an indirect call through the itab, about 1 to 2 ns when predicted. Go's profile-guided optimisation can devirtualise hot interface call sites when the profile shows one dominant concrete type. The JVM does this speculatively: if class-hierarchy analysis or the profile shows one receiver type at a call site, it inlines the method behind a cheap type guard. In V8, objects created by one factory with fields assigned in the same order share a hidden class, which keeps property access monomorphic; a factory that builds the "same" object in different field orders on different branches creates several shapes and slows every reader.

The other machine-level win is that factories can recycle. Because callers never call the constructor directly, the factory can hand out objects from a free list or a sync.Pool, which turns per-request allocation into reuse and cuts GC work. Constructors cannot do that.

Why it's built this way

  1. The code that uses an object rarely needs to know its concrete type, but someone has to choose it.
  2. Choosing in every caller duplicates the decision, and the copies drift, so the choice moves into one function.
  3. The choice often depends on runtime data (a URL, a config file, a file's magic bytes), so it must be a lookup, not a compile-time new.
  4. A function can return subtypes, cached instances and errors, which a constructor cannot.
  5. Indirection costs allocations, inlining and readability, so a factory is worth adding only when there are real alternatives to choose between.

Diagram

flowchart LR
  CFG["config: dest URL"] --> F["open_store(url)"]
  F --> P{"parse scheme"}
  P -- "missing" --> E1["error: bad url"]
  P --> R{"registry lookup"}
  R -- "not found" --> E2["error: unknown scheme"]
  R -- "mem" --> M["MemoryStore (cached per name)"]
  R -- "s3" --> S["S3Store"]
  R -- "gs" --> G["GcsStore"]
  M --> U["export(report, store)"]
  S --> U
  G --> U

Worked example

A company runs 14 batch jobs that write files. Before the refactor each job had its own if/elif over destinations, 14 copies in total, and an audit found 5 different sets of supported schemes. Two customers on GCS could not use 3 of the jobs. Adding a new backend meant 14 pull requests across 6 repositories.

The team created a small shared package with a Store interface, a registry, and open_store(url). Each backend lives in its own module and registers itself. The jobs take a Store as a parameter, and each job's main calls open_store once. Adding Azure Blob Storage afterwards was one module of about 120 lines and one line in the registry's setup, deployed by a dependency bump. Test suites switched to mem:// stores; the 14 jobs' tests dropped 230 mock.patch calls on cloud clients.

The performance check came out as expected: open_store runs once per job, so the lookup and the extra interface hop are invisible next to a 40 ms object-storage round trip. The one regression was in a hot path where a job opened a store per row (2 million rows) and each S3 store built a new HTTP client with its own connection pool. Caching stores by bucket inside the S3 factory, the same trick the memory factory uses per name, brought that job from 50 minutes to 6.

Trade-offs

OptionStrengthsWeaknessesChoose when
Direct constructor callExplicit; inlinable; errors at compile timeCaller knows the concrete typeOne implementation, or the caller genuinely needs it
Factory function with a switchOne place to decide; still compile-time checkedAdding a variant edits the functionSmall, closed set of variants in one codebase
Registry keyed by nameNew variants plug in without edits; choice from configRuntime errors on typos; registration order mattersPlugins, drivers, backends chosen by URL or config
GoF overridable factory methodFramework owns the algorithm; subclass supplies one partInheritance couples subclass to baseFrameworks with template algorithms
Inject the object or a factory parameterCaller decides; trivial to testWiring code in the composition rootMost application code

Pitfalls in production

Leadership lens

Check yourself

  1. Why can a factory return a cached instance when a constructor cannot?

    Show answer

    A constructor always allocates a new object; a factory is an ordinary function that can look up and return an existing one, as logging.getLogger and Integer.valueOf do.

  2. Go's guidance is "accept interfaces, return structs". When should a Go factory return an interface anyway?

    Show answer

    When the concrete type is chosen at runtime from input, such as a URL scheme or driver name, so the caller cannot know it in advance.

  3. A registry is keyed by strings from a config file. What failure does that introduce, and how do you contain it?

    Show answer

    A typo or missing plugin is only found when the key is looked up. Validate every configured key at startup and fail fast with an error that names the key.

  4. When is an exhaustive switch over an enum better than a registry?

    Show answer

    When the set of variants is closed and owned by one team; the compiler then reports every unhandled case, and nothing depends on registration order.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →