Skip to content

9.4.2 — Factory Method

What the original Gang of Four book says: Define an interface for creating an object, but let subclasses decide which class to create. Factory Method lets a class hand off the choice of what to create to its subclasses.

What that means when you are actually writing code: Put the decision "which concrete class do we build here?" in exactly one place, and let every other part of the codebase speak only in roles, never in vendor names.

Factory Method is the first of the creational patterns, and it answers the most basic question in object-oriented design: who is allowed to write new, and on what? The answer sounds trivial until you watch a single new spread across a codebase and turn one small decision into a change that touches dozens of files. This chapter is about stopping that from happening, and about recognising the shape early enough that you never let it start.

1. The story: two lines that ate a company

A payments team ships to India first. The code that charges a customer is one line:

typescript
const gateway = new RazorpayGateway(config.razorpayKey);   // ← the only place it exists. Fine.
await gateway.charge(order.total, token);

This is genuinely correct code, and it is worth saying so clearly. There is exactly one payment provider, so naming it directly is honest and readable. Anybody who opens this file can see precisely what happens. There is nothing to abstract yet, because nothing varies.

Six months later Europe launches, and the one line becomes two:

typescript
const gateway = user.region === "EU"                        // ← the first if. Still fine.
  ? new AdyenGateway(config.adyenKey)
  : new RazorpayGateway(config.razorpayKey);

Still fine. But now watch what actually happens inside a real codebase, because this is the part the textbooks skip over. That little expression is convenient, so it gets copied. It goes into the refund service, then into the subscription renewer, then into the chargeback handler, then into a nightly cron job, then into an admin tool, then into two tests. Eighteen months later, if you run grep -c "new AdyenGateway" across the repo, it returns 23.

Then the United States launches, and Stripe arrives as a third provider. Here is the bill for that one business decision.

Twenty-three files must change for one conceptual choice. This is the smell 9.1 calls shotgun surgery: a single idea ("which payment provider?") is scattered across the codebase, so changing it means firing a shotgun at twenty-three targets at once. The cost is measured in merge conflicts and in the sites you forget.

Two of the twenty-three get missed. United States refunds silently route to Razorpay, fail, and page somebody at three in the morning. This is not a scary hypothetical to make the pattern look good. Missing sites is the normal outcome of shotgun surgery, because nothing in the type system knows that the list of twenty-three is supposed to be complete. The compiler cannot warn you about a place you forgot to edit.

Every one of those twenty-three files now imports three vendor SDKs. Compile times go up. The refund service, which never touches Adyen at all, still cannot be understood, tested, or deployed without the Adyen library present, because the file imports it.

The tests turn hostile. To test the refund service, you now need a real Razorpay key, or you have to reach in and monkey-patch a module, because the file builds its own collaborator instead of receiving one from outside.

Nothing exotic went wrong here. One decision — which concrete class — was allowed to live in many places at once. Factory Method is the pattern that says: this decision lives in exactly one place.

2. How you arrive at the pattern

Here is the derivation, walked through the five steps from 9.4.1, so that you could reconstruct the pattern yourself even if you had never heard its name.

Step 1 — Start naive. Write new ConcreteThing() directly at the point where you use it. This is correct as long as there is exactly one concrete thing and you do not yet need a test seam. The one-line India version was right for the situation it was in.

Step 2 — Wait for the force. A second implementation appears, then a third, and the choice between them is made at runtime based on data the code did not previously care about — the customer's region, their plan tier, a file extension, a feature flag, or which environment the code is running in.

Step 3 — Draw the line between what varies and what stays fixed. Look hard at those twenty-three call sites and ask what actually differs between them.

Before the forceAfter the force
What variesnothingwhich class gets created, and what arguments it needs
What stays fixedeverythingthe operations the caller performscharge, refund, verifyWebhook

The fixed part is a role, which we express as an interface. The varying part is a decision, and a decision is a thing you can move. That is the whole insight, and it is worth stating plainly: you cannot delete the decision, because some code somewhere has to choose. But you can relocate the decision so that it exists in one place instead of twenty-three.

Step 4 — Decide when the choice is made. There are two spellings, and the fork between them matters.

The first is runtime selection, which is the everyday spelling. A plain function takes the deciding data and returns the role. This is what people mean about ninety-five percent of the time when they say "factory".

The second is compile-time selection by subclass, which is the Gang of Four's literal Factory Method. A base class has an abstract createX() hook, and each subclass answers it. This form survives, and is genuinely the right one, wherever a framework creates your classes for you — we come back to it in section 5.3.

Step 5 — Name the pattern and say what it costs. The name is Factory Method. The cost is real, and you should be able to state it out loud: you have added a layer of indirection, so the question "which class actually runs here?" can no longer be answered by reading the call site. You have to jump to the factory to find out. In exchange, changing which class runs became a one-file edit. That is the trade in a single phrase: you gave up some ease of navigation to buy ease of change. And if the thing never actually changes, you paid that cost and bought nothing — which is exactly the misuse section 10 warns about.

3. The mental model

In one sentence: a factory is the single room in the building where the concrete vendor names are allowed to be spoken. Everywhere else in the building, people use job titles instead.

The analogy that makes it stick — a hospital. A patient does not walk in and ask for "Dr. Sharma". They ask for a cardiologist. Reception, which is the factory, maps that need to a specific person using information the patient does not have and should not need: who is on shift, who is qualified, who is currently free. When Dr. Sharma leaves the hospital, no patient's behaviour changes at all. What changes is reception's roster. The patient's script — "I need a cardiologist" — is the interface. Reception is the factory. The roster is the registry that the factory reads.

When to reach for it. Any sentence of the form "depending on ⟨some data⟩, we need a different ⟨thing⟩" — where that ⟨thing⟩ has a stable set of operations — is describing a factory. Say the sentence out loud and the two halves fall into place on their own: the ⟨data⟩ is the factory's parameter, and the ⟨thing⟩'s operations are the interface it returns.

The one-line test for whether you actually need it. Ask: does more than one place in the codebase need to know a concrete class name? If the answer is yes, you want a factory. If the answer is no — a single construction site, no runtime choice, no test double needed — then plain new Thing() is the correct design, and wrapping it in createThing() is ceremony that adds a layer and hides nothing (9.4.1 section 6).

4. Structure

The Gang of Four's structure has four participants. The modern function spelling collapses two of them into one, which is why the diagram below shows both forms.

① callers know only the ROLEcheckout servicerefund servicesubscription jobadmin toolcreateGateway(region)THE FACTORY — the onlymodule importing vendorsinterface PaymentGatewaycharge · refund · verifyRazorpayGatewayAdyenGatewayStripeGatewayFakeGateway (tests)returns the role② only the factory says `new`✗ the arrow the pattern deletesevery caller importing every vendor SDK — 23 files, 3 imports eachAdding a provider touches: the factory (1 line) + one new class. Nothing else compiles differently.
Figure 1 — Where the knowledge lives. Blue arrows are dependencies on the role. Green arrows are the factory's private knowledge of concrete classes. The dashed red arc is the coupling the pattern exists to delete. Read the whole figure as an answer to one question: how many files must change to add a provider?

Here are the participants, in the Gang of Four's vocabulary and in plain terms side by side.

GoF namePlain nameIn the exampleNotes
Productthe roleinterface PaymentGatewaywhat callers depend on — the only thing they import
ConcreteProductthe implementationsStripeGateway, FakeGatewaymay live in other packages; callers never name them
Creatorthe factorycreateGateway(region)the single home of the decision
ConcreteCreatorthe subclass (GoF form only)WebDialog.createButton()disappears in the function spelling

5. The code, walked through line by line

5.1 The everyday spelling — a function that returns the role

typescript
// payments/gateway.ts — the ONLY module in the repo that imports vendor SDKs
import { RazorpayGateway } from "./vendors/razorpay";      // (1)
import { AdyenGateway }    from "./vendors/adyen";
import { StripeGateway }   from "./vendors/stripe";

export interface PaymentGateway {                           // (2) the ROLE: the fixed part
  charge(amount: Money, token: PaymentToken): Promise<ChargeResult>;
  refund(chargeId: ChargeId, amount: Money): Promise<RefundResult>;
  verifyWebhook(raw: Buffer, signature: string): boolean;
}

export function createGateway(                              // (3) the FACTORY
  region: Region,
  config: Config,
): PaymentGateway {                                         // (4) ← returns the ROLE, never a vendor type
  switch (region) {
    case "IN": return new RazorpayGateway(config.razorpayKey);
    case "EU": return new AdyenGateway(config.adyenKey, { sca: true });  // (5)
    case "US": return new StripeGateway(config.stripeKey);
  }
}

Now the numbered lines, one at a time.

(1) The import fence. Every single vendor import in the whole codebase now lives on these three lines. This is a measurable property, and that is what makes it powerful. You can write a lint rule — no-restricted-imports — that says "only payments/gateway.ts is allowed to import from ./vendors/*", which turns your design intention into a build failure the moment somebody breaks it. That enforcement is what keeps the pattern from quietly eroding back into twenty-three call sites in month nine, when the original author has moved teams and a new engineer reaches for the convenient copy-paste.

(2) The role declares operations, not vendors. It has charge, refund, and verifyWebhook, which are things a caller wants done. If this interface ever grows a method called stripeCustomerId, then the abstraction has sprung a leak and the pattern is already failing, because a Stripe-specific concept has escaped into the shared role (9.3.8, Interface Segregation).

(3) The factory is a plain exported function. There is no class here, and there is certainly no AbstractGatewayFactoryProvider. In TypeScript, this function is the pattern in full. The role structure is completely intact — callers depend on the interface, the decision lives in one place — and the class ceremony that the 1994 book needed is simply not required in a language with first-class functions.

(4) The return type is the line everything else on this page rests on. If this said : StripeGateway instead of : PaymentGateway, then every caller would silently get back an object with Stripe-specific methods on it. Somebody would use one of those methods, and the coupling you just deleted would walk straight back in through the function's signature. So annotate the return type explicitly as the interface, and do not let type inference quietly widen it to a union of the three concrete classes.

(5) The per-vendor construction arguments live here, and only here. Adyen needs an SCA flag; Razorpay does not. Callers must never have to learn this difference. Notice how the variation in how each vendor is built gets absorbed silently inside the factory. That is the second, less-advertised benefit of a factory: it does not just hide which class you get, it also hides how each one is assembled.

What this does when you run it: createGateway("EU", config) returns an object whose static type is PaymentGateway and whose real runtime type is AdyenGateway. Calling gateway.charge(…) on it runs Adyen's charging code. Nothing else in the program can tell which vendor it got, and that is exactly the point.

5.2 The registry spelling — when the set grows

A switch statement is perfectly fine at three cases. At ten cases, with new providers being added by different teams, you want a data structure instead of control flow:

typescript
type Region = "IN" | "EU" | "US" | "BR" | "JP";           // the closed set of regions

// (1) A typed registry: the compiler now enforces that every region is covered
const builders: Record<Region, (c: Config) => PaymentGateway> = {
  IN: (c) => new RazorpayGateway(c.razorpayKey),
  EU: (c) => new AdyenGateway(c.adyenKey, { sca: true }),
  US: (c) => new StripeGateway(c.stripeKey),
  BR: (c) => new StripeGateway(c.stripeKeyBr),
  JP: (c) => new StripeGateway(c.stripeKeyJp),
};                                                         // ← omit one key and `tsc` fails

export function createGateway(region: Region, config: Config): PaymentGateway {
  const build = builders[region];                          // (2) O(1) lookup, no branching
  if (!build) throw new UnsupportedRegionError(region);    // (3) defensive: data can lie
  return build(config);
}

(1) The Record<Region, …> type is the trick worth stealing and reusing everywhere. Adding "BR" to the Region union and then forgetting to add its entry to the registry is now a compile error, not a three-in-the-morning page. This converts the question "did we cover every case?" from something a human has to check in review into something the type system guarantees for you (3.7.3).

(2) A lookup replaces the branching. This is not for performance, because a five-case switch costs nothing. It is because a table can be inspected, looped over (Object.keys(builders) answers "which regions do we support?" as plain data), and added to at runtime by plugins. A switch statement can do none of those things.

(3) The runtime guard is not redundant with the compiler. The compiler protects the literal type Region. But a region value that arrives from a database row or an HTTP request body is really just a string wearing a Region costume. So validate it at the boundary where it enters your system (9.9.3), and also guard here. Defence in depth costs one line and is worth it.

5.3 The Gang of Four's literal Factory Method — the inheritance spelling

The original form hands the choice off to a subclass, not to a parameter. Fewer engineers write this by hand, but you live inside it every single time a framework creates your classes for you:

typescript
abstract class Dialog {                              // the Creator
  // The template: a fixed algorithm with one variable step. This is Template Method
  // applied to creation — the two patterns are the same shape underneath ([9.4.17]).
  render(): HtmlElement {
    const button = this.createButton();              // (1) ← the FACTORY METHOD hook
    button.onClick(() => this.onConfirm());
    return wrap(this.header(), button);
  }

  protected abstract createButton(): Button;         // (2) subclasses answer this
  protected abstract onConfirm(): void;
}

class WebDialog extends Dialog {                     // ConcreteCreator #1
  protected createButton(): Button { return new HtmlButton(); }     // (3)
  protected onConfirm() { window.location.assign("/done"); }
}

class NativeDialog extends Dialog {                  // ConcreteCreator #2
  protected createButton(): Button { return new NativeButton(); }
  protected onConfirm() { bridge.post("confirmed"); }
}

(1) The base class calls a method it does not itself implement. The render() method is written once and works for every platform, because the only platform-specific decision — which kind of button to build — has been pushed down behind createButton(). This inverted call direction, where the base class calls down into the subclass, is the exact mechanism the Gang of Four named.

(2) Marking the hook abstract makes the hole mandatory. A new platform physically cannot forget to answer it, because the compiler refuses to let you create a subclass that left it blank.

(3) The subclass supplies the concrete class. Here the selection is by type rather than by parameter. It is chosen at the moment you decide which subclass to create, one level up in the code.

When this spelling is the right one: when the creator has other behaviour that depends on the thing it creates — here, the whole render() method — so the factory and its usage genuinely belong together in one class. And when the framework, rather than you, decides which subclass exists. When it is the wrong one: when all you wanted was to choose a class. In that case you have written three classes to do one function's job.

5.4 Python, for contrast

python
from typing import Protocol, Callable

class PaymentGateway(Protocol):                     # structural typing: no inheritance needed
    def charge(self, amount: int, token: str) -> str: ...

_BUILDERS: dict[str, Callable[[Config], PaymentGateway]] = {
    "IN": lambda c: RazorpayGateway(c.razorpay_key),
    "EU": lambda c: AdyenGateway(c.adyen_key, sca=True),
    "US": lambda c: StripeGateway(c.stripe_key),
}

def create_gateway(region: str, config: Config) -> PaymentGateway:
    try:
        return _BUILDERS[region](config)
    except KeyError:
        raise UnsupportedRegion(region) from None

A Protocol in Python gives you the role without requiring the implementations to inherit anything. This means a third-party class that you cannot edit still counts as a PaymentGateway, purely because it has the right methods. That is structural typing doing exactly what TypeScript's interfaces do (3.3), and it makes factories in Python unusually cheap to write.

6. Five domains, the same shape

(a) Document export, chosen by file extension. The user clicks "Export as…" and the chosen format arrives as a string from a dropdown.

typescript
type Format = "pdf" | "csv" | "xlsx" | "json";
interface Exporter { readonly mime: string; render(rows: Row[]): Promise<Buffer>; }

const exporters: Record<Format, () => Exporter> = {
  pdf:  () => new PdfExporter(),
  csv:  () => new CsvExporter(),
  xlsx: () => new XlsxExporter(),
  json: () => new JsonExporter(),
};
export const createExporter = (f: Format): Exporter => exporters[f]();

The payoff is that the HTTP handler becomes completely format-agnostic — it just runs res.type(exporter.mime).send(await exporter.render(rows)) — and adding "markdown" as a new format is one new class plus one new line, with tsc telling you the line you forgot.

(b) Test doubles, chosen by environment. This is the most underrated use of a factory, and the one that pays back the fastest.

typescript
export function createEmailer(env: Env): Emailer {
  if (env.NODE_ENV === "test")        return new InMemoryEmailer();  // tests read sent[]
  if (env.NODE_ENV === "development") return new ConsoleEmailer();   // prints to the terminal
  return new SesEmailer(env.AWS_REGION);
}

Notice that this factory has no production variation at all. Every production run returns SesEmailer. It still earns its keep completely, because the test seam is a legitimate stakeholder in the design (9.3.9, Dependency Inversion). The lesson to carry: "there is only one production implementation" is not by itself a reason to skip a factory. The reason to skip one is "there is no second implementation of any kind, including fakes."

(c) Parsing by content type, chosen by data rather than configuration.

typescript
export function createParser(contentType: string): BodyParser {
  const base = contentType.split(";")[0].trim().toLowerCase();   // strip "; charset=utf-8"
  switch (base) {
    case "application/json":                  return new JsonParser();
    case "application/x-www-form-urlencoded": return new FormParser();
    case "multipart/form-data":               return new MultipartParser();
    case "text/csv":                          return new CsvParser();
    default: throw new UnsupportedMediaTypeError(base);          // → HTTP 415
  }
}

This is exactly how Express's body-parser family is organised, and it is why 415 Unsupported Media Type exists as an HTTP status code (9.6.1). The factory's default branch is that HTTP error.

(d) Storage backends, chosen by deployment target.

typescript
export function createBlobStore(cfg: Config): BlobStore {
  switch (cfg.storage.driver) {
    case "s3":    return new S3Store(cfg.storage.bucket, cfg.aws);
    case "gcs":   return new GcsStore(cfg.storage.bucket, cfg.gcp);
    case "local": return new LocalDiskStore(cfg.storage.path);   // dev and CI
  }
}

The same application binary runs on AWS, on Google Cloud, and on a laptop with no cloud credentials at all. This is the factory acting as a portability boundary, and the reason LocalDiskStore exists is developer onboarding time, which is a real business metric.

(e) Named constructors, the factory you already write without realising it.

typescript
class Money {
  private constructor(readonly cents: bigint, readonly currency: Currency) {}

  static fromCents(cents: bigint, c: Currency): Money { return new Money(cents, c); }
  static fromDecimalString(s: string, c: Currency): Money {           // "19.99"
    const [whole, frac = ""] = s.split(".");
    return new Money(BigInt(whole) * 100n + BigInt(frac.padEnd(2, "0")), c);
  }
  static zero(c: Currency): Money { return new Money(0n, c); }
}

Static factory methods, which is Joshua Bloch's term for these, solve a different sub-problem. A class needs several different ways to be built, and plain constructors cannot be given names or overloaded in a way that makes their meaning clear. Money.fromDecimalString("19.99", "USD") reads correctly at the call site. Compare new Money("19.99", "USD"), where the reader has to guess whether that string is dollars or cents — which is precisely the bug class that produces charges that are a hundred times too large. So the rule to carry is this: when a constructor's arguments are ambiguous about units, format, or meaning, replace it with named static factory methods and make the plain constructor private.

7. Variants, precisely distinguished

VariantShapeUse when
Simple factoryone function, a switch or a registrythe default; ninety-five percent of real uses
Factory Method (GoF)an abstract hook overridden by subclassesthe creator has other behaviour that depends on the product (frameworks)
Static factory methodMoney.fromCents(…), Buffer.from(…)one class, several ways to build it; naming clarifies units and intent
Registry / pluggable factorya Map or Record of buildersthe set is open, or plugins register entries at startup
Async factoryasync function createX(): Promise<X>construction needs I/O (fetching keys, connecting, warming up)
Caching factory (multiton)a factory plus a Map keyed by identityone instance per key — per tenant, per shard (9.4.6)
Abstract Factoryan object of related factory methodsthe products must match each other9.4.3

On async factories, which is a detail that bites hard in Node. Constructors cannot be async. So any object that needs to do I/O before it is usable — a database pool that must connect, a client that must fetch a signing key — has two bad options and one good one. It can construct itself and then require the caller to await init(), which means a half-built object exists and somebody will eventually use it before it is ready. It can connect lazily on the first call, which hides latency and surfaces connection errors on some unrelated later call. Or it can use an async factory that returns only fully-usable objects:

typescript
export async function createSearchClient(cfg: Config): Promise<SearchClient> {
  const conn = await connect(cfg.url);          // the I/O happens here, once, visibly
  await conn.ping();                            // fail at startup, not at the first request
  return new OpenSearchClient(conn);            // ← only fully-working objects escape this function
}

This is the same "no half-built object ever escapes" principle that drives 9.4.4, applied here to I/O rather than to validation. Failing at startup instead of at the first request is what turns a mysterious production outage into a clean, obvious failed deploy.

8. Where you already use it

Call you have writtenWhat it hides
document.createElement("video")a tag name picks one of about a hundred element classes
crypto.createHash("sha256")a string picks the hashing algorithm
fs.createReadStream(path)you get a stream; the exact class is never named (3.8.4)
Buffer.from(x), Array.from(x)the shape of the input picks the build strategy (3.8.3)
express()a ready-made app; the internals stay private

Take the first row and look at what is really happening. You write document.createElement("video") and you get back a video element with play() and pause() on it. Write "table" instead and you get a completely different class with completely different methods. You never wrote new HTMLVideoElement(), and you could not have, because the browser does not promise that class name will exist tomorrow. One string, one function, and the whole family of element classes stays out of your code.

The point running through the whole table is that almost every library's front door is a factory rather than a class. That is deliberate. It is how a library keeps the freedom to reorganise its own classes without breaking the people using it. When you publish something of your own, exporting a factory function instead of a class is the cheapest future-proofing available to you (10.11).

9. Ways to get it wrong

  1. Returning the concrete type. Writing function createGateway(): StripeGateway re-couples every caller through the signature. This is the single most common way the pattern is written and defeated at the same time.

    The fix: an explicit role return type, plus a lint rule against exporting vendor types.

  2. A factory with one product and no test double. A createUser() that only ever does new User() is a layer of indirection with no tension behind it (9.4.1 section 6).

    The fix: delete it, and bring it back when the second implementation or the first fake actually arrives.

  3. The god factory. One createEverything(kind: string): unknown serving twelve unrelated hierarchies. It has as many reasons to change as it has products (9.3.5, Single Responsibility), and its return type is useless because it is unknown.

    The fix: one factory per role.

  4. Selection that is not exhaustive. A switch with a silent default: return new StripeGateway(…) means a mistyped region charges the wrong provider and nobody ever finds out.

    The fix: a Record<Kind, …> for compile-time completeness, a never-check for exhaustiveness, or a thrown error.

  5. The factory that builds eagerly. Running Object.values(builders).map(b => b(config)) at module load connects to every vendor the moment the file is imported. That means slow startups, credentials required for all providers even in a test that uses one, and a crash in any single vendor taking down the whole process.

    The fix: build on demand and keep factories lazy.

  6. Hidden global config. A factory that reaches into process.env internally is a 9.4.6-style hidden dependency wearing a factory's clothes.

    The fix: config is a parameter, and then tests configure it by passing an argument rather than by mutating the environment.

  7. The factory used as a service locator. Calling factory.get("emailer") deep inside business logic is the Service Locator anti-pattern: dependencies become invisible to function signatures and to the compiler.

    The fix: resolve everything at the root and inject it downward.

  8. A leaky role. The interface grows stripeCustomerId?: string because one vendor needed it. Now every implementation has to pretend to have it, and callers start branching on which vendor they claimed not to know about.

    The fix: model the concept generically (externalRef), or accept that you have two genuinely different roles.

10. Factory Method compared with its neighbours

Compared withThe difference in one lineChoose Factory Method when
Abstract FactoryFactory Method makes one product; Abstract Factory makes a matched familythere is no cross-product consistency requirement
Buildera factory answers which class; a Builder answers how to assemble onethe construction itself is simple once the class is chosen
Prototypea factory builds from parameters; Prototype copies an existing configured instanceyou have parameters, not an example object to copy
Singletona factory answers which; Singleton answers how manyyou care about type, not lifetime (they combine happily)
Strategyalmost the same shape; Strategy is about behaviour swapped at use, a factory about creationthe question is "what object do I get", not "what algorithm runs"
A wiring librarybuilds every object in the app for youyour object list is small enough to wire by hand

On that last row, because the name comes up constantly. Some frameworks ship a tool that builds all of your objects for you. You tell it, once, which class fills which role, and from then on it works out what needs what and hands each object its dependencies. The usual name for this tool is a dependency injection container, often shortened to DI container, and you will hear it in interviews and code reviews as if everyone already knows it.

It is doing the same job as a factory, just for every object in the application at once instead of one at a time, and it adds a rule about how long each object lives — one shared copy for the whole program, or a fresh one per web request. It earns its place in a large application. In a small one, writing the wiring by hand in a single startup file is easier to read and easier to search, because you can see the whole object graph in one screen instead of inferring it from annotations scattered across forty files.

The Strategy confusion, resolved. People mix these two up because a factory that returns a strategy looks like both at once. It is both, doing two different jobs. The Strategy is the role being varied, such as ShippingCalculator. The factory is the mechanism that picks which one you get. In sentences: Strategy says the algorithm is a parameter; the factory says the choice of that parameter lives in one place. Almost every real Strategy deployment ships with a factory beside it, which is exactly why 9.4.12 points back here for the selection step.

11. Interview calibration

The 45-second answer, in the order that scores best: "Factory Method puts the choice of concrete class in one place, so callers depend only on the interface. I usually write it as a function that returns the interface, and often as a typed registry — a Record<Kind, Builder> — so that a missing case is a compile error rather than a runtime surprise. The return type has to be the interface; returning the concrete type is the classic way to write a factory that doesn't actually decouple anything. The Gang of Four's original form is the inheritance one, an abstract createX() hook that a subclass answers, and that form is still very much alive wherever a framework creates your classes. The cost is a level of indirection: you can no longer see at the call site which class runs, so I don't add one until there's a second implementation or a test double."

Follow-up questions you should expect, with the seed of each answer:

  • "Isn't the switch still a violation of Open/Closed?" — Selection is essential; something has to decide. Open/Closed asks that the decision exist once, and that adding a case not require editing unrelated code. The registry form goes further: adding a provider is one entry plus one class.
  • "How is this different from Abstract Factory?" — One product versus a family that must match each other; see 9.4.3.
  • "Where do you call the factory?" — At the composition root, once. Calling it inside business logic brings the coupling back as a Service Locator.
  • "What if construction needs a network call?" — An async factory that returns Promise<Role>, failing at startup and never exposing half-built objects.
  • "When would you not use it?" — One implementation, one construction site, no fake needed. Then plain new Thing() is the design.

Recall

  • Factory Method is the "which concrete class?" decision, made once, behind a function that returns the role. The disease it cures is shotgun surgery: new ConcreteThing() copy-pasted to N sites, so one conceptual change becomes N edits with some missed.
  • The line everything rests on is the return type. It must be : PaymentGateway, never : StripeGateway. Returning the concrete type re-couples every caller through the signature, and it is the most common way the pattern is written and defeated at once.
  • Use a registry over a switch as the set grows: Record<Kind, (c: Config) => Role> makes a forgotten case a compile error, turns "what do we support?" into inspectable data, and allows plugins to register at runtime. Keep a runtime guard as well, because external data lies about its type.
  • Variants: the simple factory (default) · the GoF inheritance form (abstract createX(), alive in frameworks) · static factory methods (Money.fromCents — name the units, hide the constructor) · the async factory (does I/O during construction, fails at startup, never exposes half-built objects) · the caching or multiton factory (one instance per tenant). You already use it constantly: crypto.createHash, document.createElement, Buffer.from, fs.createReadStream, express().
  • Cost and misuse: the cost is indirection — the call site no longer reveals which class runs. Misuse is a factory with one product and no fake, a god factory, a silent default branch, eager construction at import time, config read from process.env inside, and a factory used as a service locator deep in business logic. Call it at the composition root and inject roles downward.

Self-test: Which single line in a factory decides whether it decouples anything? What does Record<Kind, Builder> buy over a switch? Give two reasons a factory with only one production implementation can still be correct. Why can a constructor not do I/O, and what replaces it? Name three ways a factory can be written and still leave the caller coupled.

Quiz Bank

FoundationalWalk the derivation of Factory Method from naive code, naming the smell at each step.

Naive: const gw = new RazorpayGateway(key) written at the point of use, which is correct while there is one provider and no test seam is needed.

The force: a second and then a third provider, chosen at runtime by the customer's region.

What breaks: the construction expression gets copy-pasted into every service that needs a gateway, so adding a provider becomes shotgun surgery (9.1) across many files. Sites get missed, because nothing enforces that the list is complete. Every one of those files now imports every vendor SDK, which couples them at compile time and in the reader's head to code they never actually run. And each file builds its own collaborator, so its tests need real credentials or module patching.

The varies/fixed line: what varies is which class, and with what constructor arguments. What is fixed is the operations callers perform. So the operations become an interface, PaymentGateway, and the choice becomes data.

Binding: at runtime, by a parameter — a function that takes region and returns the interface. The Gang of Four's subclass form applies instead when a framework, not you, picks the subclass.

Name and cost: Factory Method. The cost is indirection, because reading a call site no longer tells you which class runs. You trade ease of navigation for ease of change, which is only a good trade when the thing genuinely changes.

FoundationalWhat exactly does a typed registry buy over a switch statement, and what does it not protect against?

Writing const builders: Record<Region, (c: Config) => PaymentGateway> = { … } buys four things.

First, compile-time completeness: adding "BR" to the Region union without adding its entry fails tsc, so the "did we cover every case?" review question becomes a type-system guarantee (3.7.3).

Second, introspection: Object.keys(builders) answers "which regions are supported?" as plain data, which is useful for health endpoints, admin screens, and generated documentation — none of which can read a switch.

Third, runtime extensibility: plugins can add entries at startup, which a switch cannot express.

Fourth, a uniform shape: every entry is a builder function of the same type, so a cross-cutting change like "wrap every gateway in a metrics decorator" becomes one map over the registry rather than N separate edits.

What it does not protect against: values that arrive from outside the type system. A region column in the database, or a field in a JSON body, is really a string, and TypeScript's guarantee evaporates at that boundary. So validate at the edge (9.9.3) and keep the if (!build) throw guard. It also does not protect against a wrong mapping — US → RazorpayGateway compiles perfectly fine — which is why the factory's own table test (section 9) exists.

AppliedA team argues a factory is pointless because there is only one production implementation. Give the cases where they are right and the cases where they are wrong.

They are right when three things hold at once: there is exactly one implementation, exactly one construction site, and no test double is needed because the collaborator is pure, in-process, and cheap. In that situation createThing() is a function that adds a hop and hides nothing — the structure-without-tension case from 9.4.1 section 6 — and deleting it is the senior move.

They are wrong in four common situations.

First, a test double is a second implementation. A createEmailer that returns SesEmailer in production and InMemoryEmailer under test has one production implementation but two implementations in total, and the test seam is a legitimate stakeholder (9.3.9, Dependency Inversion).

Second, multiple construction sites. One implementation built in nine places still centralises badly, because changing the constructor's signature means editing nine files.

Third, construction is non-trivial — credentials assembled from config, an async connect, retry and telemetry wrappers applied. Duplicating that assembly is duplicating a decision, and a factory names it once.

Fourth, it is a published package boundary. Exporting a factory rather than a class preserves your freedom to restructure the internals later without a breaking change (10.11).

The honest rule to give them: the trigger is not "many implementations", it is "more than one place would otherwise need to know the concrete construction."

InterviewDistinguish Factory Method, static factory methods, and Abstract Factory with an example of each and the question each answers.

Factory Method answers which concrete class do I get for this input?createGateway(region): PaymentGateway. One product role, selection by data (or, in the Gang of Four's inheritance form, by which subclass overrides the createX() hook, which is the form that survives inside frameworks that create your classes — for example an abstract Dialog.render() calling this.createButton()).

Static factory methods answer how was this object built, and from what?Money.fromCents(1999n, "USD") versus Money.fromDecimalString("19.99", "USD"). There is one class, several ways to build it, and the value is naming. Constructors cannot carry meaning, so new Money("19.99") leaves the units ambiguous, which is the bug class that produces charges a hundred times too large. Make the constructor private and the routes explicit.

Abstract Factory answers how do I get a whole set of objects that must match each other?createCloudKit("aws") returning an object with blobStore(), queue(), and secrets() that are all AWS-flavoured and share credentials, which makes the mixed-family combination (an AWS store with a Google Cloud queue) impossible to express rather than merely discouraged (9.4.3).

The progression to say out loud: one object → many ways to build one object → many objects that must agree. If there is no must-agree constraint, Abstract Factory is ceremony. If there is no runtime selection, Factory Method is ceremony. If the constructor arguments are self-explanatory, static factories are ceremony.

StaffYou inherit a 400k-line TypeScript monolith where three payment vendors are constructed in 60+ places, tests mock modules with jest.mock, and a recent incident routed US refunds to the wrong vendor. Plan the migration to a factory boundary without a big-bang refactor, and say how you would prevent regression.

Frame it as an incident-driven, mechanically-verifiable migration, not a cleanup. A cleanup gets deprioritised; an incident fix does not.

Measure first, so progress is visible. Run grep -c for each vendor constructor per directory, and record the total as a dashboard number: "files importing ./vendors/*". A migration without a metric stalls at sixty percent, because nobody can see whether it is working.

Define the role from the actual union of usage, not from an ideal. Extract every method that is called on the three vendor clients across the sixty sites; the interface is that set, minus the vendor-specific leaks, which each get an explicit modelling decision (a generic externalRef instead of stripeCustomerId). Doing this discovery before you write the interface is what prevents a leaky role that has to be re-cut later.

Land the factory and the adapters as pure addition. Add payments/gateway.ts plus three thin adapter classes, each wrapping one vendor client (9.4.7 is the Adapter pattern). Nothing is deleted yet; the new path simply exists beside the old one.

Migrate by blast radius, highest first. The refund path, where the incident happened, goes first, then charge, then webhooks, then admin tooling. Each migration is a small pull request that also deletes a jest.mock in favour of passing a fake, so test quality improves with every step and the change justifies itself to reviewers.

Ratchet the improvement; do not just ask for it. Add a no-restricted-imports rule for ./vendors/* with an explicit allowlist of the files not yet migrated, and shrink that allowlist in every pull request. Now the codebase can only get better, never worse, and the rule survives after you leave the team.

Kill the incident's root cause specifically. The wrong-vendor routing came from a duplicated conditional with a silent default. The registry form (Record<Region, Builder>) plus a table test asserting that every region maps to its expected vendor turns that entire class of bug into a compile error plus a red test. Add a startup assertion that every configured region has credentials, so that a misconfiguration fails the deploy rather than a customer's refund.

Prove the fix in production terms. Emit a payments.gateway.selected{region,vendor} counter and alert on any combination that is not in the expected matrix, so a future mis-mapping is caught in minutes rather than by a customer complaint.

The sentence for the write-up: the incident was not caused by a missing pattern, it was caused by a decision that had sixty homes; the deliverable is not "we use a factory now" but "the number of places that can make this decision is one, and a lint rule keeps it that way."

Flashcards

FlashFactory Method in one line

The which-concrete-class decision, made once, behind a function that returns the ROLE. Never return the concrete type.

FlashRegistry trick

Record<Kind, (c: Config) => Role> — a missing case becomes a compile error, the set becomes inspectable data, plugins can register entries. Still guard at runtime, because external data lies.

FlashGoF form vs function form

GoF: an abstract createX() hook answered by subclasses — alive in frameworks that create your classes. Everyday: a function taking the deciding data. Same roles, different binding time.

FlashStatic factory methods

Money.fromCents / Money.fromDecimalString with a private constructor. Constructors cannot be named, and ambiguous units are a bug class. Also Buffer.from, Array.from.

FlashAsync factory

Constructors cannot be async. async createX(): Promise<Role> does the I/O, verifies it, and returns only fully-usable objects — failing at startup instead of at the first request.

FlashFactory misuse list

Concrete return type · one product with no fake · god factory · silent default branch · eager construction at import · reads process.env inside · called deep in business logic (service locator).

Scenario Drill

DrillDesign the creation story for a multi-tenant analytics platform: each tenant chooses a warehouse (Snowflake, BigQuery, or self-hosted ClickHouse), each warehouse needs different credentials and a different SQL dialect, tenants are added daily without deploys, and support engineers must be able to run a query as any tenant from a CLI. Show the factory design, what you would deliberately not build, and how the CLI reuses the same path.

Start with the role, cut from real usage. interface Warehouse { query(sql: Sql, params): Promise<Rows>; explain(sql): Promise<Plan>; dialect: SqlDialect; close(): Promise<void> }. Note that dialect is data on the role, not a vendor name. Query builders read it to shape their SQL without ever learning which vendor exists, which keeps the dialect difference from leaking into business code as if (vendor === "bigquery").

The factory is async and keyed by tenant, not by vendor. It is async because construction requires I/O: fetching credentials from the secret store, opening a connection pool, verifying reachability. It is keyed by tenant because callers have a tenantId, not a vendor name: async function warehouseFor(tenantId): Promise<Warehouse>. Inside, it does three steps in order — resolve the tenant's config (which vendor, which region), fetch the credentials from KMS or Vault, then dispatch through a Record<Vendor, (cfg, creds) => Warehouse> registry — so the vendor-selection table stays one line per vendor and tsc enforces completeness.

"Tenants added without deploys" falls out for free, and getting this axis right is the whole design. The registry keys on vendor, which is a closed set that changes rarely (once per deploy). Tenants are rows in a table, an open set that changes hourly. A common failure is keying the registry by tenant, which forces a deploy for every new customer. Splitting the two rates of change is the point.

Add a caching layer, on purpose. A Map<TenantId, Promise<Warehouse>> remembers connections so that a busy tenant does not open a fresh pool on every request — the multiton variant from section 7. Two things make it safe rather than a leak. Store the promise, so that concurrent first-callers coalesce onto one build rather than racing to create two pools. And attach an idle-eviction policy that calls close(), because a per-tenant cache with no eviction is an unbounded resource leak wearing a performance costume (3.6.11).

What I would deliberately not build. Not an Abstract Factory, because there is no family of products that must match — it is one warehouse per tenant, so 9.4.3's tension is absent and its machinery would be pure ceremony. Not a dependency injection container (the wiring tool from section 10), because the graph is small and hand-wiring at the composition root stays greppable. Not a plugin system for new vendors, because three vendors added yearly do not justify dynamic loading; the registry is already the extension point when a fourth arrives.

The CLI reuses the path exactly, and that reuse is the design's proof. analytics query --tenant acme --sql '…' calls the same warehouseFor(tenantId). Nothing about the factory is HTTP-shaped, because it takes a tenant id and configuration rather than a request object. The CLI forces two healthy additions. Credentials resolution must accept an operator identity, so the audit log records which human ran a query as a tenant — a compliance requirement, and impossible if the factory silently read ambient environment variables. And close() must be honest, so that a short-lived process exits cleanly.

The sentence for the design doc: the only place in this system that knows Snowflake exists is one twelve-line registry; tenants are data, vendors are code, and the boundary between those two rates of change is the entire reason this factory exists.