Factory Pattern
Factories exist because object creation is often a decision, not a constructor call: which implementation, which configuration, which lifecycle, which validation.
The pattern is not "a class named Factory". It is a boundary that turns a runtime input (config, MIME type, feature flag, tenant) into a ready-to-use object, with the construction rules in one place.
The problem
async function sendWelcome(user: User): Promise<void> {
let sender;
if (process.env.EMAIL_PROVIDER === "ses") {
sender = new SesEmailSender(
new AWS.SES({ region: process.env.AWS_REGION }),
process.env.SES_TEMPLATE_ID!,
);
} else if (process.env.EMAIL_PROVIDER === "postmark") {
sender = new PostmarkSender(process.env.POSTMARK_TOKEN!);
} else {
sender = new SmtpSender(process.env.SMTP_URL!);
}
await sender.send(user.email, "Welcome");
}
Construction is duplicated in every caller, reads environment variables deep inside business code, and cannot be tested without manipulating the environment.
Three shapes, three purposes
| Shape | What it does | Use when |
|---|---|---|
| Simple factory (function) | maps an input to a created object | one decision, no subclassing |
| Factory Method | a base class defines the creation hook; subclasses choose the product | behaviour varies with the product family, inheritance is already in play |
| Abstract Factory | creates a family of related objects | the products must be consistent (e.g., UI widgets per theme) |
Start with the function. Most production "Factory Method" needs are a map and a switch in one place.
Simple factory
export interface EmailSender {
send(to: string, subject: string, body: string): Promise<void>;
}
export function createEmailSender(config: EmailConfig): EmailSender {
switch (config.provider) {
case "ses":
return new SesEmailSender(makeSesClient(config), config.templateId);
case "postmark":
return new PostmarkSender(config.token);
case "smtp":
return new SmtpSender(config.url);
default:
throw new UnknownEmailProvider(config.provider satisfies never);
}
}
Callers now depend on the interface and on the factory, not on three vendor SDKs:
class WelcomeFlow {
constructor(private readonly emails: EmailSender) {}
async run(user: User): Promise<void> {
await this.emails.send(user.email, "Welcome", renderWelcome(user));
}
}
The satisfies never trick gives a compile-time error if a new config variant is added without a case.
Registry variant
When plugins are registered at startup:
type SenderFactory = (config: EmailConfig) => EmailSender;
const factories = new Map<string, SenderFactory>();
export function registerEmailProvider(key: string, factory: SenderFactory): void {
if (factories.has(key)) throw new Error(`duplicate provider: ${key}`);
factories.set(key, factory);
}
export function createEmailSender(config: EmailConfig): EmailSender {
const factory = factories.get(config.provider);
if (!factory) throw new UnknownEmailProvider(config.provider);
return factory(config);
}
The registry is Open/Closed, but it is global state: reset it between tests or inject it (see below).
Factory Method
When a base algorithm needs a product chosen by the subclass:
classDiagram
class ReportJob {
+run() string
#createFormatter() Formatter
}
class CsvReportJob {
#createFormatter() Formatter
}
class PdfReportJob {
#createFormatter() Formatter
}
class Formatter {
<<interface>>
+format(rows) string
}
ReportJob <|-- CsvReportJob
ReportJob <|-- PdfReportJob
ReportJob --> Formatter
abstract class ReportJob {
async run(): Promise<string> {
const rows = await this.loadRows();
return this.createFormatter().format(rows);
}
protected abstract createFormatter(): Formatter;
protected abstract loadRows(): Promise<Row[]>;
}
class CsvReportJob extends ReportJob {
protected createFormatter(): Formatter { return new CsvFormatter(); }
protected loadRows(): Promise<Row[]> { /* ... */ }
}
This is useful when the algorithm is fixed and the product varies. It is a poor fit when both vary — that is what composition is for.
Abstract Factory
For consistent families:
interface UiFactory {
button(): Button;
input(): Input;
modal(): Modal;
}
class DarkUiFactory implements UiFactory { /* ... */ }
class LightUiFactory implements UiFactory { /* ... */ }
function renderSettings(factory: UiFactory): View {
return view([factory.button("Save"), factory.input("Name"), factory.modal("Confirm")]);
}
The guarantee is consistency, not just creation: every widget comes from the same theme. If products do not need to match, you do not need an abstract factory.
Factories and dependency injection
A factory often is the composition root for a subtree:
const emailSender = createEmailSender(config.email);
const welcome = new WelcomeFlow(emailSender);
You can also inject the factory itself when the object must be created per request:
class ReportController {
constructor(private readonly reports: ReportFactory) {}
async generate(kind: string): Promise<string> {
return this.reports.create(kind).run();
}
}
Prefer injecting the factory as a function (type ReportFactory = (kind: string) => ReportJob) to injecting a class with one method.
Trade-offs
Gains
- construction rules live in one place;
- callers depend on interfaces, not vendor SDKs;
- config/env access is confined to the edge;
- tests can substitute fakes without touching business code.
Costs
- another hop between the caller and the real object;
- hidden failures at creation time (bad config) unless validated early;
- registries become global mutable state;
- class-heavy versions obscure a simple
if/map.
Do not introduce a factory when:
- construction is a plain
newwith constants; - there is exactly one implementation and no config decision;
- the only "variation" is a boolean you can pass as an argument.
[!WARNING] Validate configuration when the factory is called, at startup if possible. A factory that throws on the first request is worse than a failing fast boot.
Testing
- Factory tests: each provider key returns the expected type; unknown key fails with a typed error; missing config fails fast.
- Consumer tests: inject a fake
EmailSender; no environment variables involved. - Registry tests: reset the registry in
beforeEach, assert duplicate registration fails.
test("rejects unknown provider", () => {
expect(() => createEmailSender({ provider: "carrier-pigeon" } as never))
.toThrow(UnknownEmailProvider);
});
Exercises
- Replace an env-var
if/elseconstruction chain with acreateX(config)factory. - Add a provider to the registry without editing existing providers; count the files changed.
- Design a
ReportJobFactory Method for CSV/PDF, then refactor it to composition and compare. - Identify a factory in your codebase that only wraps
newwith constants. Delete it.
Checklist
- Construction decisions live in one module, not in callers.
- Factories return interfaces/ports, not vendor types.
- Configuration is validated early and errors are typed.
- The simplest shape is used (function before class hierarchy).
- Registries are injectable or reset in tests.
- There is a real variant or configuration decision justifying the indirection.
Next: Singleton — the pattern most often used to hide a dependency.