Understanding the Factory Pattern
Generate configurable services dynamically using centralized factories that respond to environment, flags, or runtime config.
We'll cover the following...
Why this pattern matters
Most of us have hardcoded service instances at some point. Initially, we might only require a PostgreSQL client in db.js and proceed. Then later, we need to switch to SQLite for local development or MongoDB for a quick prototype. Suddenly, the code is tangled with if (env === ...) checks, scattered imports, and fragile logic that’s easy to break.
We hit the same problems when supporting multiple APIs—REST vs. GraphQL—or when toggling features, like local file logging vs. remote log aggregation. These aren’t edge cases—they’re exactly the kinds of changes our systems face as they grow. Every time we add conditional behavior, we risk duplicating logic, breaking imports, or overcomplicating startup code.
The Factory Pattern solves this by centralizing creation logic. We can simply say, “I want a logger,” instead of handling all the environment-specific details inline. By decoupling usage from instantiation, we make the system easier to extend and modify without breaking existing code.
How the pattern works
The Factory Pattern encapsulates object creation within a function or class that determines what to return. The caller doesn’t know (or care) which exact class or dependency is instantiated—it just gets something that conforms to a shared interface.
Think of it like ordering a sandwich at Subway or a drive-thru: you tell the cashier what you want—bread, meat, cheese, toppings—but you don’t assemble it yourself, and you don’t care which employee grabs the ingredients or in what order. You just get back a sandwich that matches your preferences.
Similarly, a factory function takes a configuration (such as an environment or feature flag) and returns a fully constructed service. Internally, it might pick from different classes, pass custom arguments, use dynamic imports, or wire up dependencies. The complexity stays hidden behind the counter.
This is powerful in Node.js, where service choice often depends on:
process.env.NODE_ENVruntime config (
config.json,dotenv)feature flags or request context
Pattern anatomy in Node.js
Let’s say we want a database client factory. In development, we use SQLite. In production, PostgreSQL. Here’s how the Factory Pattern plays out:
Explanation:
Lines 2–11: We define the
SqliteClientclass to simulate a development-mode database client. It reads thedbFilefrom the config (defaulting to'dev.db') and exposes a.connect()method to simulate behavior. We're creating a concrete implementation that reflects the kinds of config needs and methods a real service might expose.Lines 13–22: The
PostgresClientclass mirrors the structure ofSqliteClient, but expects adbHostinstead—something you'd see in production. By shaping both clients around a shared.connect()method, we create a consistent interface despite their internal differences. This sets up the core idea behind the Factory Pattern: multiple implementations, one usage pattern.Lines 25–28: The
clientMapacts as a lookup table from environment names to class constructors. This replaces the need foriforswitchstatements, and makes it easier to scale. Adding support for a new environment is as simple as adding a new entry. It also keeps creation logic cleanly decoupled from client details.Lines 31–35: The
createDatabaseClient()factory reads the environment from config, looks up the correct class, validates that it exists, and then instantiates it. This encapsulates the decision-making process: the caller doesn't need to know which client is being used—they just ask for one. It also guards against misconfiguration by throwing if the env is unsupported.Lines 38–44: We simulate development usage by passing in a
developmentconfig. The factory returns a ready-to-use client, and we call.connect()without knowing (or caring) which class we got. This demonstrates the key payoff: behavior that adapts at runtime, without changing call-site code.
This is called a declarative factory. Instead of using if or switch, we use a plain object (clientMap) to declare which class to use for each config value. It’s easier to extend and avoids branching logic.
Note: One powerful advantage of the Factory Pattern is that it enables lazy loading. Instead of importing all possible dependencies upfront, we can move those imports inside the factory. This way, modules are loaded only when needed—reducing startup cost and making it easier to support dynamic features, plugins, or per-environment logic. If using import(), remember it’s asynchronous.
This pattern gives us a clean, scalable way to construct services dynamically. It’s not just about code cleanliness—it’s about making our systems adaptable, testable, and primed for change. In real-world Node.js apps, factories are a core pattern for building pluggable backends, toggling behavior via config, and preparing for the next service migration before it hits.
Strengths and trade-offs
Strengths:
Decouples instantiation from usage.
Enables conditional service wiring.
Centralizes config-driven logic (especially helpful for testing, mocking, or hot-swapping dependencies).
Promotes DRYness across environments and features.
Trade-offs:
Adds indirection—harder to trace what’s actually being returned.
Can lead to overly generic factories if not scoped narrowly.
May hide coupling if the caller assumes too much about what’s returned.
Common pitfalls
Leaky abstractions: If consumers rely on implementation-specific features (e.g., using
sqlite.specialMethod()), they break the factory’s promise of abstraction.Over-engineered branching: Avoid stuffing too much conditional logic into one factory. It’s better to have targeted factories (e.g.,
createLogger(),createDatabaseClient()), not one mega “createEverything()”.Missing validation: Don’t assume config will always be valid. Validate inputs inside the factory.
Alternative approaches
Service locator pattern: A central registry returns shared service instances. Unlike factories, these are often prebuilt singletons. Good for shared services, but harder to test in isolation.
Dependency injection: You can inject concrete services directly at runtime, especially in test scenarios. Useful when factories add too much dynamic complexity.
Static configuration + lazy require: In some setups, a static config that maps service names to module paths (then lazy-loads them) can act as a simpler, declarative factory.
Where you’ve seen it before
Mongoose: Uses factory-style
mongoose.createConnection()to produce independent connections.Winston: Lets you create loggers with different transports using
winston.createLogger({ transports: [...] }).Express middleware wrappers: Many utility packages export a factory like
createRateLimiter(config)to produce custom middleware.