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. Each carrier quotes a fee and books a shipment, returning a tracking number. Every place that needs a carrier builds one itself, like this checkout handler:
interface Carrier {
readonly code: string;
quote(weightGrams: number, province: string): number;
book(orderId: string): string;
}
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}`;
}
}
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
Carrierinterface, 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 {
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 };
}
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"
fulfill is the fixed workflow shared by every flow, and createCarrier is the factory method. 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.
In NestJS
Nest gives you factories at two levels, and it is worth knowing which one you are using.
useFactory is a Simple Factory run once by the container. It decides how to build a provider at bootstrap, can depend on other providers through inject, and can be async:
import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
export const DEFAULT_CARRIER = Symbol("DEFAULT_CARRIER");
@Module({
imports: [ConfigModule],
providers: [
{
provide: DEFAULT_CARRIER,
inject: [ConfigService],
useFactory: (config: ConfigService): Carrier =>
createCarrier(config.getOrThrow<CarrierCode>("DEFAULT_CARRIER"), {
ghnKey: config.getOrThrow("GHN_KEY"),
ghtkToken: config.getOrThrow("GHTK_TOKEN"),
sandbox: config.get("NODE_ENV") !== "production",
}),
},
],
exports: [DEFAULT_CARRIER],
})
export class DefaultCarrierModule {}
That is the right tool when one instance per app is what you want. It is the wrong tool for our checkout, which needs a different carrier per order. A common mistake is to reach for Scope.REQUEST and read the carrier code from the request inside the provider. It works, but request scope bubbles up: every service that injects the carrier, and every controller above it, is now rebuilt on every request.
For runtime choices, inject the factory itself. The provider's value is a function, built once, that creates products on demand:
import { ConfigurableModuleBuilder, Inject, Injectable, Module } from "@nestjs/common";
export type ShippingModuleOptions = ShippingConfig & { enabled: CarrierCode[] };
export type CarrierFactory = (code: CarrierCode) => Carrier;
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = new ConfigurableModuleBuilder<ShippingModuleOptions>()
.setClassMethodName("forRoot")
.build();
export const CARRIER_FACTORY = Symbol("CARRIER_FACTORY");
@Injectable()
export class ShippingQuoteService {
constructor(
@Inject(CARRIER_FACTORY) private readonly carrierFor: CarrierFactory,
@Inject(MODULE_OPTIONS_TOKEN) private readonly options: ShippingModuleOptions,
) {}
quotes(weightGrams: number, province: string) {
return this.options.enabled
.map((code) => this.carrierFor(code))
.map((carrier) => ({ code: carrier.code, fee: carrier.quote(weightGrams, province) }));
}
}
@Module({
providers: [
{
provide: CARRIER_FACTORY,
inject: [MODULE_OPTIONS_TOKEN],
useFactory: (options: ShippingModuleOptions): CarrierFactory => {
const cache = new Map<CarrierCode, Carrier>();
return (code) => {
if (!options.enabled.includes(code)) throw new Error(`Carrier ${code} is not enabled`);
let carrier = cache.get(code);
if (!carrier) cache.set(code, (carrier = createCarrier(code, options)));
return carrier;
};
},
},
ShippingQuoteService,
],
exports: [ShippingQuoteService, CARRIER_FACTORY],
})
export class ShippingModule extends ConfigurableModuleClass {}
The app configures it once, with values from config:
import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
@Module({
imports: [
ShippingModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService): ShippingModuleOptions => ({
ghnKey: config.getOrThrow("GHN_KEY"),
ghtkToken: config.getOrThrow("GHTK_TOKEN"),
sandbox: config.get("NODE_ENV") !== "production",
enabled: config.getOrThrow<string>("CARRIERS").split(",") as CarrierCode[],
}),
}),
],
})
export class AppModule {}
Three layers of factory, each with one job. ConfigurableModuleBuilder generates forRoot() and forRootAsync() so the module can be configured like any official Nest module. useFactory on CARRIER_FACTORY builds the factory once. The returned function is the Simple Factory from earlier, now with an enabled check and a cache.
Why cache carriers? Because creating an SDK client often opens a keep-alive HTTP agent or a connection pool. Building a new GhnCarrier per order under 200 requests per second can mean 200 new agents per second and a steady rise in open sockets. Carriers here are stateless, so sharing one per code is safe. If a product holds per-order state, do not cache it; create it per call and keep the expensive client in a shared dependency instead.
For a product that needs dependencies from the container (a logger, a repository), Nest offers ModuleRef.create(SomeClass), which instantiates a class outside the registry and resolves its constructor dependencies. It is handy for Factory Method style code inside Nest, but every call builds a new object and its dependency graph, so treat it as a factory for rare events, not for the hot path.
Failure modes at bootstrap
Nest awaits async factories before the app starts listening. That is useful (a missing GHN_KEY stops the deploy instead of failing the first checkout) and dangerous: a useFactory that calls a carrier's API to "verify credentials" turns a GHN outage into an outage of your whole service, including the routes that never ship anything. Keep factories cheap and side-effect free, which is pitfall 3 below in Nest form. If you need a health check against a carrier, run it from a readiness probe, not from the constructor path.
Testing factories
Override the factory, not each carrier. The service then gets whatever products the test chooses, and the test controls prices exactly:
import { Test } from "@nestjs/testing";
it("quotes every enabled carrier through the factory", async () => {
const fake = (code: CarrierCode): Carrier => ({ code, quote: () => 10_000, book: (id) => `FAKE-${id}` });
const moduleRef = await Test.createTestingModule({
imports: [ShippingModule.forRoot({ ghnKey: "", ghtkToken: "", sandbox: true, enabled: ["ghn", "ghtk"] })],
})
.overrideProvider(CARRIER_FACTORY)
.useValue(fake)
.compile();
expect(moduleRef.get(ShippingQuoteService).quotes(1_000, "Hà Nội")).toEqual([
{ code: "ghn", fee: 10_000 },
{ code: "ghtk", fee: 10_000 },
]);
});
That is also the answer to exercise 4: NationwideFlow became testable the moment carrier creation was something you could pass in.
A trade-off I would raise in code review
A factory provider whose value is a function is less discoverable than a class: "find all references" on CARRIER_FACTORY shows where it is injected, but the editor cannot jump from this.carrierFor(code) to the code that runs. When the factory grows beyond a dozen lines, I ask for it to become an @Injectable() class CarrierFactory { create(code) }. Same pattern, same tests, and the call site reads this.carriers.create(code), which a new team member can follow with one click.
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
- A factory full of
ifs that grows forever. Use a typed map or a registry, and keep each entry one line. - Returning concrete types. A factory that returns
GhnCarrier | GhtkCarriermakes callers check which one they got. Return the interface. - 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.
- Subclassing for one line. If a Factory Method subclass only overrides
create...(), the injected-function version is simpler.
Exercises
- 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? - Turn
carrierFactoriesinto a registry withregisterCarrier(code, factory)so a plugin module can add a carrier without editing the factory file. - Add an
ExpressFlowthat always uses GHN regardless of price, first as a subclass and then with the injected-function version. Compare. - Write a test for
NationwideFlowthat does not depend on real carrier prices. What did you have to change to make that possible? - Turn the
CARRIER_FACTORYfunction into an@Injectable()CarrierFactoryclass, keep the cache, and make the test above pass unchanged except for the override.