Introduction: The Notification Challenge in SaaS

Modern SaaS products rely on timely, personalized notifications to engage users, drive retention, and communicate critical system events. Users expect to receive alerts via their preferred channels—email, SMS, push notifications, in-app messages, or even webhooks to third-party services. As the product evolves, the number of channels grows, and managing them through scattered conditional logic becomes a maintenance nightmare. A clean, scalable architecture is needed.

Directus, an open-source headless CMS and backend platform, provides a flexible foundation for building SaaS applications. Its data model, Flows engine, and extensible hooks make it an ideal environment to implement a robust notification system. By applying a classic creational design pattern—the Factory Pattern—you can encapsulate channel-specific creation logic, decouple your core business code from delivery details, and effortlessly add new channels as your product matures.

Understanding the Factory Pattern

The Factory Pattern is a creational design pattern that provides an interface for creating objects in a super‑class, but allows subclasses to alter the type of objects that will be created. It promotes loose coupling and adheres to the Open/Closed Principle: software entities should be open for extension but closed for modification. Instead of littering your application with if‑else or switch statements to decide which notification object to instantiate, you delegate that responsibility to a dedicated factory.

This pattern shines when you have a family of related objects and the exact type to instantiate is determined at runtime based on dynamic input—such as a user’s notification preferences or the event being triggered. In a notification context, every channel is a different “product,” yet they all share a common interface (e.g., a send(message) method).

Notification Channels in a SaaS Product

Common notification channels include:

  • Email – transactional and marketing emails via services like SendGrid or Mailgun.
  • SMS – short messages via Twilio or Vonage.
  • Push Notifications – web push, mobile push via Firebase Cloud Messaging or OneSignal.
  • In‑App Notifications – UI toasts or dashboard alerts.
  • Webhooks – HTTP POST requests to external endpoints for integrations.
  • Slack / Discord – team‑focused messaging.

Without a pattern, your code might look like this:

function sendNotification(channel, message, recipient) {
  if (channel === 'email') {
    // Email logic here
  } else if (channel === 'sms') {
    // SMS logic here
  } else if (channel === 'push') {
    // Push logic here
  } else {
    throw new Error('Unknown channel');
  }
}

This approach works for a few channels, but every new channel forces you to modify your send function and retest all existing logic. The factory pattern eliminates this fragility.

Implementing the Factory Pattern for Notifications

Below we walk through a TypeScript implementation that can be adapted for a Directus‑based SaaS. TypeScript is natural for Directus because the platform itself is built with it and provides a rich type system.

1. Define the Notification Interface

interface Notification {
  send(message: string, recipient: string): Promise<void>
}

2. Concrete Notification Classes

class EmailNotification implements Notification {
  async send(message: string, recipient: string): Promise<void> {
    // Use Directus’s internal mailer or an external SDK
    console.log(`Sending email to ${recipient}: ${message}`);
  }
}

class SMSNotification implements Notification {
  async send(message: string, recipient: string): Promise<void> {
    // Call Twilio API
    console.log(`Sending SMS to ${recipient}: ${message}`);
  }
}

class PushNotification implements Notification {
  async send(message: string, recipient: string): Promise<void> {
    // Use Firebase or OneSignal SDK
    console.log(`Sending push to ${recipient}: ${message}`);
  }
}

3. The Notification Factory

type ChannelType = 'email' | 'sms' | 'push';

class NotificationFactory {
  static createNotification(channel: ChannelType): Notification {
    switch (channel) {
      case 'email':
        return new EmailNotification();
      case 'sms':
        return new SMSNotification();
      case 'push':
        return new PushNotification();
      default:
        throw new Error(`Unknown notification channel: ${channel}`);
    }
  }
}

4. Using the Factory in Your Application

async function notifyUser(event: string, userId: string, channel: ChannelType) {
  const notification = NotificationFactory.createNotification(channel);
  await notification.send(event, userId);
}

The client code never needs to know the concrete type. It only depends on the Notification interface. This decoupling makes unit testing straightforward—you can mock the notification object without touching the factory or the concrete classes.

Integrating the Factory Pattern with Directus

Directus offers multiple entry points for triggering notifications:

  • Flows – Visual automation that can call custom operations or webhooks.
  • Hooksaction.* or filter.* hooks that run on CRUD events.
  • Custom Endpoints – Express routes injected into the Directus server.
  • Webhooks – Outgoing webhook dispatchers.

In a Directus project, you might install your notification factory as a service inside a custom module or an extension. For example, inside a Flow you could call an internal API endpoint that instantiates the correct channel based on a user’s stored preferences. Alternatively, you can wrap the factory in a hook that fires after a new user sign‑up and sends a welcome email, an SMS confirmation, and a push notification to the mobile app.

Because the factory lives in a single place, you can update its mappings without touching every hook or flow. And since Directus Flows can pass arbitrary JSON data, you can easily feed the factory with channel identifiers from your database.

Benefits of the Factory Pattern Approach

Scalability

Adding a new channel means writing a new class that implements Notification and adding one line to the factory’s switch statement. No client code changes are required. This is especially valuable in multi‑tenant SaaS products where different tenants may enable different sets of channels.

Maintainability

Notification delivery logic is isolated from business logic. Each concrete class can be maintained independently. If the SMS provider changes its API, you modify only SMSNotification. The factory and all consumers remain unchanged.

Testability

You can unit test each notification class in isolation, and you can mock the Notification interface when testing higher‑level functions. The factory itself can be verified with a simple test that checks that it returns instances of the correct type for each channel.

Flexibility

The factory can be extended to build complex objects. For example, you might want to pass configuration (API keys, retry policies) at creation time. You can overload the factory method or use a Builder pattern inside the factory to assemble fully configured notification objects. Directus’s environment variables or database settings (e.g., a directus_settings table) are ideal sources for such configuration.

Potential Drawbacks and Considerations

While the factory pattern is a powerful tool, it is not a silver bullet:

  • Over‑engineering: If you only have two channels and no plans to add more, a simple conditional might be fine. The pattern introduces additional files and abstractions.
  • Decision logic still exists: The factory itself uses a switch statement. If you have dozens of channels, consider a registry pattern where channels self‑register, or use a strategy pattern that swaps algorithms rather than objects. Both can complement the factory.
  • Dependency injection: If your notification classes depend on external services (e.g., an HTTP client or a logger), you need to inject those dependencies. The factory must either accept a DI container or receive the dependencies as parameters—something to plan for early.

In a Directus extension, you can leverage the platform’s built‑in dependency injection (the init function and services) to pass shared instances like the logger or database access to the factory.

Real‑World Example: Multi‑Channel Notifications in a B2B SaaS

Consider a project management tool built on Directus. When a task is assigned, the system must notify:

  • the assignee via email (if they prefer that)
  • the assignee via in‑app toast
  • the project channel on Slack

Using the factory, the code under a Directus action.items.create hook becomes:

import { NotificationFactory } from './services/NotificationFactory';

async function onTaskCreate(payload, { accountability }) {
  const { assigneeId } = payload;
  const userPreferences = await getUserNotificationPreferences(assigneeId);
  for (const channel of userPreferences.channels) {
    const notifier = NotificationFactory.createNotification(channel);
    await notifier.send(`You have a new task!`, assigneeId);
  }
}

Each notification class handles its own idiomatic delivery. The factory remains the single source of truth for channel mapping.

To see a complete implementation of such a system, refer to Directus’s official documentation on creating custom hooks and services. For a deeper understanding of the factory pattern itself, the Refactoring Guru page on Factory Method is an excellent resource. You might also be interested in how other platforms handle notification orchestration—Twilio Notify showcases a similar concept at the infrastructure level.

Conclusion

The Factory Pattern provides a clean, maintainable way to manage multiple notification channels in a SaaS product. By encapsulating object creation, you decouple your core application logic from delivery specifics, making it easy to add, remove, or modify channels without ripple effects across the codebase. When combined with Directus’s flexible hooks and Flows, you gain a notification architecture that scales with your product’s growth.

Whether you are building a simple email notifier or a complex multi‑channel engine, starting with the factory pattern early pays off as your feature set expands. The investment in a small amount of abstraction today saves days of refactoring tomorrow.