Applying the Abstract Factory Pattern to Support Multiple Payment Providers in Directus

Modern e-commerce platforms must support a variety of payment gateways to cater to global audiences. Directus, as a headless CMS and backend-as-a-service, provides the flexibility to integrate payment providers through custom extensions, Flows, and API endpoints. However, managing multiple providers with distinct APIs, authentication schemes, and response formats quickly becomes a maintenance burden. The Abstract Factory Pattern offers a proven solution to encapsulate provider-specific logic, enforce consistency, and simplify the addition of new gateways. This article explains how to implement this pattern within a Directus project, with practical code examples and architectural considerations.

Why the Abstract Factory Pattern?

The Abstract Factory Pattern is a creational design pattern that provides an interface for creating families of related objects without specifying their concrete classes. In payment processing, these "families" often include payment processors, refund handlers, webhook verifiers, and receipt generators. Each provider implements these components differently. Without abstraction, your code becomes tightly coupled to specific APIs, making testing, scaling, and onboarding new providers extremely difficult.

By applying the pattern, you gain:

  • Decoupled client code – Business logic only depends on abstract interfaces, not concrete implementations.
  • Easier provider swaps – Switching from Stripe to Adyen requires only a new concrete factory, not rewrites of checkout logic.
  • Consistent integration – Every provider implements the same set of methods (e.g., createPayment, verifyWebhook), reducing bugs.
  • Testability – Mock factories can simulate payment scenarios without real API calls.

Directus Architecture and Extensibility

Directus is built on a modular extension system. For payment integration, the most relevant hooks are:

  • Flows – Visual automation scripts that trigger on events (e.g., order creation) and run JavaScript operations.
  • Custom API endpoints – Server-side scripts that handle HTTP requests (e.g., POST /payments/charge).
  • Extension bundles – Packages that integrate with Directus via hooks or endpoints.

The Abstract Factory Pattern works best inside custom endpoints or Flows where you need to create payment-related objects dynamically. Flows support JavaScript functions, so you can implement factories using plain Node.js. For more complex scenarios, use a custom extension written in TypeScript.

Implementing the Abstract Factory Pattern in Directus

1. Define the Abstract Product Interfaces

First, define the contracts that all payment providers must satisfy. In TypeScript, this might look like:

// PaymentProvider.ts
export interface PaymentProcessor {
  createPayment(amount: number, currency: string, metadata: object): Promise<PaymentResult>;
}

export interface RefundProcessor {
  refund(paymentId: string, amount?: number): Promise<RefundResult>;
}

export interface WebhookVerifier {
  verify(payload: any, signature: string): boolean;
}

These interfaces form the abstract products that each concrete factory will produce.

2. Build the Abstract Factory

The factory itself is an interface with methods that return the abstract products:

// PaymentFactory.ts
export interface PaymentFactory {
  createPaymentProcessor(): PaymentProcessor;
  createRefundProcessor(): RefundProcessor;
  createWebhookVerifier(): WebhookVerifier;
}

3. Create Concrete Factories for Each Provider

Now implement a concrete factory for Stripe, PayPal, and Square. Each factory returns objects that adhere to the interfaces above but use that provider’s specific SDK.

Stripe Factory Example:

// StripeFactory.ts
import Stripe from 'stripe';
import { PaymentFactory } from './PaymentFactory';
import { StripePaymentProcessor } from './StripePaymentProcessor';
import { StripeRefundProcessor } from './StripeRefundProcessor';
import { StripeWebhookVerifier } from './StripeWebhookVerifier';

export class StripeFactory implements PaymentFactory {
  private stripe: Stripe;

  constructor(apiKey: string) {
    this.stripe = new Stripe(apiKey);
  }

  createPaymentProcessor() {
    return new StripePaymentProcessor(this.stripe);
  }

  createRefundProcessor() {
    return new StripeRefundProcessor(this.stripe);
  }

  createWebhookVerifier() {
    return new StripeWebhookVerifier(this.stripe);
  }
}

PayPal Factory and Square Factory follow the same structure but use their respective SDKs and classes.

4. Implement Concrete Products

Each product class implements the interface and encapsulates provider-specific logic. For instance, StripePaymentProcessor:

// StripePaymentProcessor.ts
import { PaymentProcessor, PaymentResult } from './PaymentProvider';

export class StripePaymentProcessor implements PaymentProcessor {
  constructor(private stripe: Stripe) {}

  async createPayment(amount: number, currency: string, metadata: object): Promise<PaymentResult> {
    const paymentIntent = await this.stripe.paymentIntents.create({
      amount: Math.round(amount * 100), // Stripe expects cents
      currency,
      metadata,
    });
    return { id: paymentIntent.id, status: paymentIntent.status, clientSecret: paymentIntent.client_secret };
  }
}

Similarly, PayPalPaymentProcessor would use the PayPal REST API to create orders.

5. Using the Factory in Directus Custom Endpoints

In a Directus custom endpoint, you would resolve the appropriate factory based on a configuration field (e.g., stored in a payment_providers collection).

// endpoints/payments.js
import { Router } from 'express';
import { StripeFactory } from '../factories/StripeFactory';
import { PayPalFactory } from '../factories/PayPalFactory';
import { SquareFactory } from '../factories/SquareFactory';

const router = Router();

// Assume each store has a selected provider stored in Directus item
router.post('/charge', async (req, res) => {
  const { provider, amount, currency, metadata } = req.body;
  let factory;

  switch (provider) {
    case 'stripe':
      factory = new StripeFactory(process.env.STRIPE_SECRET_KEY);
      break;
    case 'paypal':
      factory = new PayPalFactory(process.env.PAYPAL_CLIENT_ID, process.env.PAYPAL_SECRET);
      break;
    case 'square':
      factory = new SquareFactory(process.env.SQUARE_ACCESS_TOKEN);
      break;
    default:
      return res.status(400).json({ error: 'Unsupported payment provider' });
  }

  const processor = factory.createPaymentProcessor();
  try {
    const result = await processor.createPayment(amount, currency, metadata);
    res.json(result);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

export default router;

This keeps the endpoint clean. To add a new provider, you only create a new factory class and add a case to the switch (or better, use a registry pattern).

Leveraging Directus Flows for Payment Processing

Flows offer a visual way to trigger payment operations. You can embed the Abstract Factory logic inside a Flows "Run Script" operation. For example, after an order is created, you can call a script that:

  1. Reads the store’s configured payment provider from a Directus item.
  2. Uses the corresponding factory to create a payment.
  3. Updates the order with the payment response.

To keep Flows modular, expose the factory creation as a reusable function that accepts the provider string:

// flows-utils/getPaymentFactory.js
export function getPaymentFactory(provider: string, config: Record<string, string>): PaymentFactory {
  const factories = {
    stripe: () => new StripeFactory(config.stripeApiKey),
    paypal: () => new PayPalFactory(config.paypalClientId, config.paypalSecret),
    square: () => new SquareFactory(config.squareAccessToken),
  };
  const factoryCreator = factories[provider];
  if (!factoryCreator) throw new Error(`Unknown provider: ${provider}`);
  return factoryCreator();
}

Then in the Flows script, you import this function and call it with the provider’s configuration stored in environment variables or a Directus singleton.

Handling Webhooks with Abstract Factories

Payment gateways send webhooks to confirm payment status. Each provider signs webhooks differently. Using the Abstract Factory, you can create a webhook verifier per provider and a single endpoint that delegates verification.

// endpoints/webhook.js
router.post('/webhook', async (req, res) => {
  const provider = req.headers['x-payment-provider'];
  const factory = getPaymentFactory(provider, req.app.locals.config);
  const verifier = factory.createWebhookVerifier();

  if (!verifier.verify(req.body, req.headers['x-signature'])) {
    return res.status(401).send('Invalid signature');
  }

  // Process webhook payload...
  res.status(200).send('OK');
});

This architecture ensures that webhook verification logic is centralized and extensible.

Testing and Error Handling

Because the Abstract Factory promotes dependency inversion, testing becomes straightforward. You can create mock factories that return fake processors:

// MockFactory.ts
export class MockFactory implements PaymentFactory {
  createPaymentProcessor() {
    return {
      createPayment: async () => ({ id: 'mock_123', status: 'succeeded', clientSecret: 'secret_mock' })
    };
  }
  // ...
}

In Directus custom endpoints, you can inject mock factories during tests by using a dependency injection container or simply by swapping the environment variable. For integration tests, use Directus’ built-in test utilities.

Error handling must be uniform across providers. The abstract product interfaces should return a standard PaymentResult type that includes success status, error messages, and provider-specific fields in a normalized way. Create a custom error class:

export class PaymentError extends Error {
  constructor(public code: string, message: string, public provider: string) {
    super(message);
  }
}

Each concrete processor catches provider-specific exceptions and throws a PaymentError with a consistent code (e.g., INSUFFICIENT_FUNDS, CARD_DECLINED).

Performance and Scalability Considerations

Using the Abstract Factory introduces minimal overhead because object creation is cheap in Node.js. However, you should be mindful of initializing SDK clients repeatedly. In Directus, custom endpoints run in a long-lived process, so you can cache factory instances keyed by provider and configuration hash. For example:

const factoryCache = new Map<string, PaymentFactory>();

function getCachedFactory(provider: string, config: object): PaymentFactory {
  const key = `${provider}:${JSON.stringify(config)}`;
  if (!factoryCache.has(key)) {
    factoryCache.set(key, getPaymentFactory(provider, config));
  }
  return factoryCache.get(key)!;
}

For Flows, each Flow execution is isolated, so caching may not apply unless you store factories in a global module scope (which is possible but be cautious about memory).

When scaling Directus horizontally, each instance will independently create factories. That’s fine because the important resources (external API connections) are managed by the SDKs themselves (e.g., Stripe’s client handles connection pooling).

Comparison with Other Patterns

The Abstract Factory pattern is often confused with the Factory Method and Strategy patterns. Here’s how they differ in the payment context:

  • Factory Method – Creates one object (e.g., a single payment processor). The Abstract Factory creates families (processor + refund + webhook).
  • Strategy Pattern – Defines interchangeable algorithms, but typically operates on the same data structure. You could use Strategy to switch between different payment algorithms, but it doesn’t enforce consistent creation of multiple related objects. Abstract Factory is better when you need several coordinated objects per provider.
  • Builder Pattern – Useful for constructing complex payment requests (e.g., building a Stripe PaymentIntent with many options). The Abstract Factory can return a Builder instance if desired.

For most multi-provider scenarios, Abstract Factory offers the best balance of structure and flexibility.

Real-World Example: Subscription Billing

Consider an e-commerce site that sells monthly subscription boxes. Different providers handle recurring payments differently:

  • Stripe uses Subscription and Invoice objects.
  • PayPal uses billing plans and agreements.
  • Square uses subscriptions with catalog items.

Using Abstract Factory, you can define a SubscriptionFactory that creates a SubscriptionCreator, InvoiceFetcher, and PauseHandler. Each concrete factory implements these with provider-specific APIs. The Directus Flow that triggers on recurring billing can then use the factory from the store’s configuration, keeping the business logic provider-agnostic.

Integrating with Directus Collections

Store provider mappings in a Directus collection, e.g., payment_providers with fields: id, name, api_key, environment. When a user selects a provider for their store, you save the key in the stores collection. Your factory resolution can receive the configuration directly from Directus items, making it dynamic.

You can also expose admin endpoints to test connections: POST /payments/test/stripe that uses the Stripe factory to ping the API. This helps administrators validate keys without leaving the Directus dashboard.

Security and Secrets Management

Payment API keys should never be hardcoded. In Directus, store secrets in the ENV variables or use the platform’s built-in secret management (if available). Within endpoints, access them via process.env. If you store keys in a collection, ensure the collection uses the hash field interface and restrict access to admin roles.

The Abstract Factory pattern does not introduce security issues; rather, it isolates provider-specific code, making it easier to audit and review.

Conclusion

Integrating multiple payment providers into a Directus-powered e-commerce site does not have to result in spaghetti code. The Abstract Factory pattern provides a clean, scalable architecture that separates concerns, enhances testability, and reduces the cost of adding new gateways. By defining abstract product interfaces, concrete factories, and leveraging Directus’s custom endpoints or Flows, you can build a payment system that is robust and maintainable.

As your business grows and new payment methods emerge, the pattern’s extensibility becomes invaluable. Start with a small set of factories (Stripe, PayPal, Square) and expand as needed. The investment in upfront abstraction pays dividends every time a new provider is added or an existing one changes its API.

For further reading, consult the official Directus documentation on extensions and Flows, the classic GoF book on design patterns, and the API references for Stripe, PayPal, and Square. With the Abstract Factory pattern and Directus, you can manage complexity while delivering a seamless checkout experience to your users.