Back

Understanding the Factory Pattern in JavaScript

Understanding the Factory Pattern in JavaScript

You’ve written new UserService() in twelve different files. Now the constructor signature changes, and you’re hunting through your codebase fixing each call. This is exactly the problem the Factory Pattern solves.

The Factory Pattern abstracts object creation, giving you a single point of control over how objects get built. It’s not just a function that returns objects—it’s a design pattern that decouples what you need from how it gets constructed.

Key Takeaways

  • The Factory Pattern centralizes object creation, making constructor changes easier to manage across your codebase.
  • Factory functions provide privacy via closures, while classes can provide runtime privacy with #private fields and share methods via the prototype chain.
  • Combining factories with dependency injection simplifies testing by allowing easy substitution of dependencies.
  • Use factories when you need runtime type decisions or complex construction logic—skip them for simple, stable object creation.

What the Factory Pattern Actually Is

A factory function returns an object. The Factory Pattern is broader: it’s an architectural approach where creation logic lives separately from usage code.

Here’s the distinction:

// Simple factory function
const createUser = (name) => ({ name, createdAt: Date.now() })

// Factory Pattern: abstracts which class to instantiate
function createNotification(type, message) {
  const notifications = {
    email: () => new EmailNotification(message),
    sms: () => new SMSNotification(message),
    push: () => new PushNotification(message)
  }
  
  if (!notifications[type]) {
    throw new Error(`Unknown notification type: ${type}`)
  }
  
  return notifications[type]()
}

The pattern shines when you need to decide which object to create based on runtime conditions, or when construction requires configuration the calling code shouldn’t know about.

JavaScript Factory Functions vs Classes

Both approaches have their place. The choice depends on what you’re optimizing for.

Classes with static factory methods work well when you want prototype-based method sharing and clear instantiation paths:

class ApiClient {
  #baseUrl
  #authToken
  
  constructor(baseUrl, authToken) {
    this.#baseUrl = baseUrl
    this.#authToken = authToken
  }
  
  static forProduction(token) {
    return new ApiClient('https://api.example.com', token)
  }
  
  static forDevelopment() {
    return new ApiClient('http://localhost:3000', 'dev-token')
  }
}

Factory functions excel when you need true privacy through closures or want to avoid this binding complexity:

function createCounter(initial = 0) {
  let count = initial
  
  return {
    increment: () => ++count,
    decrement: () => --count,
    getCount: () => count
  }
}

The trade-off: factory functions create new method instances per object. Classes share methods via the prototype chain. For most frontend applications, this difference is negligible—but it matters if you’re creating thousands of instances.

Dependency Injection with Factories

Factories become powerful when combined with dependency injection. Instead of hardcoding dependencies, you pass them to the factory:

function createUserService({ apiClient, logger, cache }) {
  return {
    async getUser(id) {
      const cached = cache.get(`user:${id}`)
      if (cached) return cached
      
      logger.debug(`Fetching user ${id}`)
      const user = await apiClient.get(`/users/${id}`)
      cache.set(`user:${id}`, user)
      return user
    }
  }
}

// In your application setup
const userService = createUserService({
  apiClient: productionApiClient,
  logger: consoleLogger,
  cache: memoryCache
})

// In tests
const testUserService = createUserService({
  apiClient: mockApiClient,
  logger: nullLogger,
  cache: noOpCache
})

This pattern makes testing straightforward—no mocking libraries required.

When Not to Use Factories

Factories add indirection. That’s valuable when you need flexibility, but costly when you don’t.

Skip the factory when:

  • Object creation is simple and unlikely to change
  • You’re only creating one type of object
  • The added abstraction obscures rather than clarifies

A factory wrapping a single new Date() call isn’t a pattern—it’s noise.

Object Creation Patterns in Modern JS Modules

ES modules change how factories fit into application architecture. You can export configured factory functions directly:

// services/notifications.js
import { EmailService } from './email.js'
import { config } from '../config.js'

export const createNotification = (type, message) => {
  // Factory has access to module-scoped dependencies
  const emailService = new EmailService(config.smtp)
  // ... creation logic
}

The module boundary provides natural encapsulation. Consumers import the factory without knowing its dependencies.

Conclusion

Use the Factory Pattern when you need to centralize complex construction logic, swap implementations without changing calling code, inject dependencies for testing, or decide object types at runtime.

Start with direct instantiation. Introduce factories when the pain of scattered new calls becomes real. The pattern exists to solve problems—not to satisfy architectural ideals.

FAQs

A factory function simply returns an object. The Factory Pattern is a broader architectural approach that separates creation logic from usage code, often deciding which type of object to create based on runtime conditions. The pattern provides a single point of control for object instantiation across your application.

Use factories when constructor signatures might change, when you need to decide object types at runtime, when construction involves complex configuration, or when you want to inject dependencies for easier testing. For simple objects with stable constructors, direct instantiation is cleaner.

Factory functions create new method instances for each object, while classes share methods via the prototype chain. This difference is negligible for most applications but becomes significant when creating thousands of instances. Choose based on your specific performance requirements and privacy needs.

Factories combined with dependency injection let you pass mock dependencies directly to the factory during tests. This eliminates the need for mocking libraries or complex test setup. You can substitute real API clients, loggers, or caches with test doubles simply by passing different arguments.

Truly understand users experience

See every user interaction, feel every frustration and track all hesitations with OpenReplay — the open-source digital experience platform. It can be self-hosted in minutes, giving you complete control over your customer data. . Check our GitHub repo and join the thousands of developers in our community..

OpenReplay