Skip to content

From Factory Design Pattern to Service Registry Pattern

The Factory Design Pattern is a creational design pattern that provides an interface (a method or class) for creating objects without exposing the instantiation logic to the client. Instead of calling new directly in multiple places, the client asks the factory to produce an object of a certain type thereby encapsulating the object creation logic, this way clients depend on abstractions, not concrete classes. Using the factory pattern, the creation logic is centralized, making the system easier to maintain, extend, or test. In this article we will look deeply at how to implement the factory design pattern while highlighting its strengths and also how service registry pattern can be used to fix some of the weaknesses of factory pattern.

The problem

In our example we seek to support multiple payment providers (PayPal, Stripe, or Mobile Money). So instead of sprinkling if/else or switch statements all over the codebase, we can use the Factory Pattern to centralize the logic.

1. Defining the Payment Gateways

Here we define our payment gateways for our app such as Paypal, m-pesa ...

js
// PayPal gateway
class PayPalPayment {
  constructor(apiKey) {
    this.apiKey = apiKey;
  }
  async pay(amount) {
    console.log(`Paying $${amount} via PayPal with API key ${this.apiKey}`);
    // API call to Paypal would go here
  }
}

// Stripe gateway
class StripePayment {
  constructor(secret) {
    this.secret = secret;
  }
  async pay(amount) {
    console.log(`Charging $${amount} via Stripe using secret ${this.secret}`);
    // API call to Stripe would go here
  }
}

// Mobile money gateway
class MpesaPayment {
  constructor(phone) {
    this.phone = phone;
  }
  async pay(amount) {
    console.log(`Paying $${amount} via M-Pesa for phone ${this.phone}`);
    // API call to Mpesa would go here
  }
}

2. Creating our factory

We create a PaymentFactory whose sole responsibility is to create and return different types of payment processor objects (PayPalPayment, StripePayment, MpesaPayment) depending on the input type

js
class PaymentFactory {
  static createProcessor(type, options = {}) {
    switch (type) {
      case "paypal":
        return new PayPalPayment(options.apiKey);
      case "stripe":
        return new StripePayment(options.secret);
      case "mpesa":
        return new MpesaPayment(options.phone);
      default:
        throw new Error(`Unknown payment type: ${type}`);
    }
  }
}

3. Using it in our application

In the code below we see how the Factory Pattern decouples client code from specific implementations: based on a configuration value (process.env.PAYMENT_METHOD or a default "paypal"), the PaymentFactory creates the appropriate payment processor (PayPalPayment, StripePayment, or MpesaPayment) while the calling code simply calls processor.pay(100) without caring which processor it is. The advantage is clear—object creation logic is centralized and abstracted, making the system flexible (easy to switch providers), testable (can inject mocks), and maintainable (new processors can be added with minimal changes to client code). In other words, the checkout flow remains stable even if the underlying payment provider changes

js
// Suppose the user selected "stripe" at checkout
const paymentMethod = process.env.PAYMENT_METHOD || "paypal";

const processor = PaymentFactory.createProcessor(paymentMethod, {
  apiKey: "PAYPAL_KEY_123",
  secret: "STRIPE_SECRET_456",
  phone: "+254700123456",
});

// The calling code doesn't care WHICH processor it is
processor.pay(100);

But

The classic factory has its strengths but also falls short

4. Where the factory pattern falls short

  • 1. Closed for extension If we need to add Apple Pay we need to modify the factory itself as follows:
js
class PaymentFactory {
  static createProcessor(type, options = {}) {
    switch (type) {
      case "paypal": return new PayPalPayment(options.apiKey);
      case "stripe": return new StripePayment(options.secret);
      case "mpesa":  return new MpesaPayment(options.phone);
      case "applepay": return new ApplePayProcessor(options.token); // new case
      default: throw new Error(`Unknown payment type: ${type}`);
    }
  }
}
  • 2. Harder to test Suppose we want to test checkout logic without hitting real APIs with the classic factory, we have two bad options:

  • Add a case "mock": return new MockProcessor() inside the factory (pollutes prod code with test code).

  • Or bypass the factory in tests, which defeats the purpose of having it.

js
// Ugly — production code knows about mocks
case "mock": return new MockProcessor();

Testing would require us to do;

  • Define a MockPaymentProcessor
js
class MockPaymentProcessor {
  async pay(amount) {
    console.log(`[MOCK] Pretending to pay $${amount}`);
    return { status: "success", amount };
  }
}
  • Extend the Factory for Tests
js
class TestablePaymentFactory extends PaymentFactory {
  static createProcessor(type, options = {}) {
    if (type === "mock") {
      return new MockPaymentProcessor();
    }
    return super.createProcessor(type, options);
  }
}
  • To use it
js
// In your Jest / Mocha test
test("checkout should use payment processor", async () => {
  const processor = TestablePaymentFactory.createProcessor("mock");

  const result = await processor.pay(50);

  expect(result.status).toBe("success");
  expect(result.amount).toBe(50);
});
  • 3. Switch, switch Every time the switch grows. Over years, it becomes unmaintainable and brittle.

5. Solution to #4 - The Service Registry Factory

The registry factory becomes

js
class PaymentFactory {
  static registry = new Map();

  static register(type, creatorFn) {
    this.registry.set(type, creatorFn);
  }

  static createProcessor(type, options = {}) {
    const creator = this.registry.get(type);
    if (!creator) throw new Error(`Unknown payment type: ${type}`);
    return creator(options);
  }
}

We register real providers

js
PaymentFactory.register("paypal", opts => new PayPalPayment(opts.apiKey));
PaymentFactory.register("stripe", opts => new StripePayment(opts.secret));
PaymentFactory.register("mpesa",  opts => new MpesaPayment(opts.phone));

and now we can do

js
const processor = PaymentFactory.createProcessor("stripe", { secret: "STRIPE_SECRET" });
await processor.pay(250);

6. Why is registry pattern better

  • 1. Open for extension To add Apple Pay:
js
PaymentFactory.register("applepay", opts => new ApplePayProcessor(opts.token));

No changes to the factory itself. The factory is closed for modification, open for extension -2.Test-Friendly

You can register a mock in your test setup thus no pollution of production code

js
class MockPaymentProcessor {
  async pay(amount) { return { status: "success", amount }; }
}

PaymentFactory.register("mock", () => new MockPaymentProcessor());

// In tests
const mock = PaymentFactory.createProcessor("mock");
expect(await mock.pay(50)).toEqual({ status: "success", amount: 50 });
  • 3. Decoupled & Pluggable

The factory doesn’t even “know” what Stripe or PayPal are thus third-party teams could register their own processors without touching core code.

Tip

If your system is small, a classic factory with a switch may be okay but if your system is large, evolving, or test-heavy - a Service Registry Factory gives you extensibility, testability, and clean separation of concerns.

javascript book

If this interested you, check out my Javascript Book

Enjoy every byte.