Most code that is hard to change has new in the wrong places. When business logic decides which concrete class to instantiate, and reads the configuration to construct it, every new variant means editing that logic. The factory family of patterns gives creation its own home.

There are three members, from simplest to most structured:

  • Simple Factory: one function or class that decides which concrete type to create. Not one of the 23 GoF patterns, but by far the most used.
  • Factory Method: a base class defines a workflow and leaves one step, "create the product", to subclasses.
  • Abstract Factory: an object that creates a whole family of related products (for example, all the UI widgets of one theme). Mentioned at the end; it is rarer.

Difficulty: ★★☆☆☆. Popularity: ★★★★★.

The problem: new spread through business code

A shop ships with three carriers. Every place that needs a carrier builds one itself:

interface Carrier {
  readonly code: string;
  quote(weightGrams: number, province: string): number;
  book(orderId: string): string; // returns a tracking number
}

class GhnCarrier implements Carrier {
  readonly code = "ghn";
  constructor(private readonly apiKey: string) {}
  quote(weightGrams: number) {
    return 22_000 + Math.ceil(weightGrams / 500) * 4_000;
  }
  book(orderId: string) {
    return `GHN-${orderId}`;
  }
}

class GhtkCarrier implements Carrier {
  readonly code = "ghtk";
  constructor(
    private readonly token: string,
    private readonly sandbox: boolean,
  ) {}
  quote(weightGrams: number, province: string) {
    return (province === "Hà Nội" ? 18_000 : 25_000) + Math.ceil(weightGrams / 1000) * 5_000;
  }
  book(orderId: string) {
    return `${this.sandbox ? "TEST-" : ""}GHTK-${orderId}`;
  }
}

class InHouseFleet implements Carrier {
  readonly code = "in-house";
  quote() {
    return 15_000;
  }
  book(orderId: string) {
    return `SHOP-${orderId}`;
  }
}

// In the checkout handler...
function shippingOptions(weightGrams: number, province: string) {
  const carriers: Carrier[] = [
    new GhnCarrier(process.env.GHN_KEY!),
    new GhtkCarrier(process.env.GHTK_TOKEN!, process.env.NODE_ENV !== "production"),
  ];
  if (province === "Hồ Chí Minh") carriers.push(new InHouseFleet());
  return carriers.map((c) => ({ code: c.code, fee: c.quote(weightGrams, province) }));
}

The same new GhtkCarrier(process.env.GHTK_TOKEN!, ...) appears in the checkout handler, the admin "rebook shipment" screen and the nightly reconciliation job. When GHTK adds a required shopId parameter, you edit all three. When a fourth carrier arrives, you find every place that lists carriers.

Simple Factory: one place that knows how to build carriers

Collect creation into a single function. Callers ask for what they want by code; the factory knows how.

type CarrierCode = "ghn" | "ghtk" | "in-house";

type ShippingConfig = { ghnKey: string; ghtkToken: string; sandbox: boolean };

const carrierFactories: Record<CarrierCode, (config: ShippingConfig) => Carrier> = {
  ghn: (config) => new GhnCarrier(config.ghnKey),
  ghtk: (config) => new GhtkCarrier(config.ghtkToken, config.sandbox),
  "in-house": () => new InHouseFleet(),
};

export function createCarrier(code: CarrierCode, config: ShippingConfig): Carrier {
  return carrierFactories[code](config);
}

And the business code no longer imports any concrete carrier:

const config: ShippingConfig = { ghnKey: "key", ghtkToken: "token", sandbox: true };

function shippingOptions(weightGrams: number, province: string, available: CarrierCode[]) {
  return available
    .map((code) => createCarrier(code, config))
    .map((carrier) => ({ code: carrier.code, fee: carrier.quote(weightGrams, province) }));
}

console.log(shippingOptions(1_200, "Hà Nội", ["ghn", "ghtk"]));
// [ { code: "ghn", fee: 34000 }, { code: "ghtk", fee: 28000 } ]

That is all a simple factory is, and it already gives you most of the benefit:

  • Constructor changes happen in one file.
  • Callers depend on the Carrier interface, not on three classes.
  • Which carriers exist becomes data (available), which can come from configuration or a feature flag.
  • The Record<CarrierCode, ...> type forces you to provide a factory for every code.

The trade-off is that the factory itself must be edited when a carrier is added. For a set of variants you own, that is fine and honest. If third parties need to add variants without touching your code, turn the map into a registry they can register() into.

Factory Method: let the workflow's subclasses choose

Factory Method solves a slightly different problem. You have a workflow with fixed steps, and only one step, the creation of some object, differs between variants.

Say fulfillment always does the same thing: pick a carrier, quote, book, record the tracking number. What differs is how the carrier is chosen. Same-day orders in the city use the shop's own fleet; nationwide orders use the cheapest external carrier.

type Order = { id: string; weightGrams: number; province: string };

abstract class FulfillmentFlow {
  /** The workflow is fixed here and shared by every flow. */
  fulfill(order: Order) {
    const carrier = this.createCarrier(order);
    const fee = carrier.quote(order.weightGrams, order.province);
    const tracking = carrier.book(order.id);
    return { orderId: order.id, carrier: carrier.code, fee, tracking };
  }

  /** The factory method: each flow decides which product to create. */
  protected abstract createCarrier(order: Order): Carrier;
}

class SameDayFlow extends FulfillmentFlow {
  protected createCarrier() {
    return createCarrier("in-house", config);
  }
}

class NationwideFlow extends FulfillmentFlow {
  protected createCarrier(order: Order) {
    const candidates = (["ghn", "ghtk"] as const).map((code) => createCarrier(code, config));
    return candidates.reduce((best, c) =>
      c.quote(order.weightGrams, order.province) < best.quote(order.weightGrams, order.province) ? c : best,
    );
  }
}

const order: Order = { id: "A-17", weightGrams: 3_000, province: "Đà Nẵng" };
console.log(new SameDayFlow().fulfill(order)); // carrier: "in-house", fee: 15000, tracking: "SHOP-A-17"
console.log(new NationwideFlow().fulfill(order)); // carrier: "ghtk", fee: 40000, tracking: "TEST-GHTK-A-17"

The base class never says new. It calls createCarrier, and each subclass decides what that returns. Adding an "express" flow is a new subclass; the fulfillment steps are not touched. That is the Open/Closed Principle from chapter 4, applied to creation.

Simple Factory and Factory Method often work together, as here: the factory method decides which product, and the simple factory knows how to build it.

The modern version: inject the factory

In TypeScript, the inheritance-based Factory Method is often replaced by passing the creation step in as a function. Same idea, composition instead of inheritance:

class Fulfillment {
  constructor(private readonly chooseCarrier: (order: Order) => Carrier) {}

  fulfill(order: Order) {
    const carrier = this.chooseCarrier(order);
    return {
      orderId: order.id,
      carrier: carrier.code,
      fee: carrier.quote(order.weightGrams, order.province),
      tracking: carrier.book(order.id),
    };
  }
}

const sameDay = new Fulfillment(() => createCarrier("in-house", config));
const cheapestGhnOrGhtk = new Fulfillment((o) =>
  [createCarrier("ghn", config), createCarrier("ghtk", config)].sort(
    (a, b) => a.quote(o.weightGrams, o.province) - b.quote(o.weightGrams, o.province),
  )[0]!,
);
console.log(cheapestGhnOrGhtk.fulfill(order).carrier); // ghtk

Prefer this when the variants differ only in the creation step. Prefer the subclass version when each variant also overrides other hooks, or when a framework expects you to extend a base class.

You meet factories in frameworks constantly: NestJS useFactory providers, React.createElement, document.createElement, http.createServer, database drivers' createPool, and every fromJSON/of/from static method.

Abstract Factory in one paragraph

When products come in families that must be used together, an abstract factory creates the whole family. A payment provider integration might need a Charger, a Refunder and a WebhookVerifier, and mixing the Stripe charger with the VNPay webhook verifier is a bug. A PaymentKit interface with createCharger(), createRefunder() and createWebhookVerifier(), implemented once per provider, guarantees a consistent set. Reach for it only when you actually have families; for single products, the patterns above are enough.

When to use it, and when not to

Use a factory when:

  • Construction needs configuration or secrets that business code should not know about.
  • The concrete type depends on runtime data (a carrier code, a file extension, a tenant setting).
  • The same construction is repeated in several places.

Skip it when:

  • There is one implementation and it is built in one place. new Thing() is clear.
  • The factory would just forward its arguments to a constructor. That is indirection without a decision.

Common pitfalls

  1. A factory full of ifs that grows forever. Use a typed map or a registry, and keep each entry one line.
  2. Returning concrete types. A factory that returns GhnCarrier | GhtkCarrier makes callers check which one they got. Return the interface.
  3. Factories that do work. Creating a carrier should not call the carrier's API. Keep factories cheap and side-effect free so they are easy to test.
  4. Subclassing for one line. If a Factory Method subclass only overrides create...(), the injected-function version is simpler.

Exercises

  1. Add a fourth carrier, viettel-post, that charges a flat 20,000đ up to 2 kg and 3,500đ per extra kg. Which files did you edit?
  2. Turn carrierFactories into a registry with registerCarrier(code, factory) so a plugin module can add a carrier without editing the factory file.
  3. Add an ExpressFlow that always uses GHN regardless of price, first as a subclass and then with the injected-function version. Compare.
  4. Write a test for NationwideFlow that does not depend on real carrier prices. What did you have to change to make that possible?