Notifications
Notification Center

Notification Service

A flexible, extensible, and type-safe notification system for managing multi-channel notifications with support for various providers and comprehensive logging capabilities.

Overview

The Notification Service provides a centralized system for sending notifications through different channels (such as email and SMS) using various providers (such as SendGrid, Mailgun, and Twilio). The architecture follows a modular design pattern that allows for easy extension and configuration.

The service is divided into two main parts:

  1. Core Framework (_core directory) - A reusable, provider-agnostic notification framework that can be added to any project
  2. Custom Implementation - Specific channels, providers, and logging implementations built on top of the core framework

This separation allows you to reuse the core notification infrastructure across different projects while implementing only the specific channels and providers you need for each application.

Features

  • Multi-Channel Support: Send notifications through different channels (email, SMS)
  • Provider Flexibility: Use multiple providers for each channel with automatic fallback
  • Type Safety: Fully typed interfaces ensure correct payload and configuration usage
  • Comprehensive Logging: Track notification events in real-time and store them persistently
  • Error Handling: Robust error handling with detailed error reporting
  • Extensibility: Easily add new channels and providers

Architecture

The notification service follows these design patterns:

  • Facade Pattern: NotificationCenter provides a unified interface for all notification operations
  • Registry Pattern: Channels and providers are registered and managed centrally
  • Strategy Pattern: Different providers implement the same interface but with different strategies
  • Factory Pattern: Channels create and manage provider instances

Core Components

The core framework (_core directory) consists of:

  1. NotificationCenter - The central orchestrator that manages channels and coordinates notification delivery
  2. BaseNotificationChannel - Abstract base class for implementing notification channels
  3. BaseNotificationProvider - Abstract base class for implementing notification providers
  4. Type Definitions - Type-safe interfaces for notifications, channels, and providers

These core components are designed to be framework-agnostic and can be integrated into any TypeScript/JavaScript project.

Base Channel Payloads

Each notification channel has a base payload interface that defines the minimum required fields for sending a notification through that channel. These base payloads serve as fallbacks when provider-specific values are not provided.

Email Channel

export interface BaseEmailPayload {
  to: string | string[];
  subject: string;
  content: string; // if provider specific values are not passed, "content" is used as fallback for other providers.
  from?: string;
}

When implementing a new channel, you should define a base payload interface with common fields that all providers in that channel can use. For example, an SMS channel might have:

export interface BaseSmsPayload {
  to: string | string[];
  content: string;
  from?: string;
}

Benefits of Base Payloads

  1. Fallback Mechanism: When provider-specific fields are missing, the base payload provides essential data that can be used by any provider
  2. Consistency: Ensures all providers in a channel support a minimum set of common fields
  3. Type Safety: Provides a clear contract for what data is required to send a notification through a specific channel
  4. Extensibility: Provider-specific payloads can extend the base payload with additional fields

Implementation Pattern

Provider-specific payloads should extend the base payload for their channel:

// Example for a hypothetical email provider
export interface SendGridEmailPayload extends BaseEmailPayload {
  templateId?: string;
  dynamicTemplateData?: Record<string, any>;
  // Additional SendGrid-specific fields
}

This pattern ensures that all providers can fall back to using the base payload fields when specific provider fields are not available.

Custom Implementation

Built on top of the core framework, the custom implementation includes:

  1. Specific Channels - Email, SMS, etc.
  2. Provider Implementations - SendGrid, Mailgun, Twilio, etc.
  3. Logging Mechanisms - Winston for real-time tracking, Prisma for persistent storage
  4. Configuration - Environment-specific settings for each provider

File Structure

notificationService/

├── _core/                          # Core reusable framework
│   ├── BaseNotificationChannel.ts  # Abstract base for all channels
│   ├── BaseNotificationProvider.ts # Abstract base for all providers
│   ├── NotificationCenter.ts       # Central orchestrator
│   ├── NullLogger.ts               # Null implementation of loggers
│   └── types/                      # Type definitions

├── channels/                       # Custom channel implementations
│   ├── email/                      # Email channel
│   │   ├── index.ts                # Email channel implementation
│   │   └── providers/              # Email providers
│   │       └── mailgun/            # Mailgun provider

├── logging/                        # Custom logging implementations
│   ├── NotificationSchema.ts       # Schema for notification logs
│   ├── PrismaNotificationLogger.ts # Persistent logging with Prisma
│   └── WinstonNotificationTracker.ts # Real-time logging with Winston

├── NotificationCenterInstance.ts   # Singleton instance
└── providerTypes.ts                # Type definitions for providers

Usage

Basic Usage

import { notificationCenter } from "@/app/notificationService/NotificationCenterInstance";
import { EmailChannel } from "@/app/notificationService/channels/email";
import { MailgunProvider } from "@/app/notificationService/channels/email/providers/mailgun";
 
// Create and configure a channel
const emailChannel = new EmailChannel();
 
// Create and configure a provider
const mailgunProvider = new MailgunProvider(mailgunConfig);
 
// Register provider with channel
emailChannel.registerProvider(mailgunProvider);
 
// Register channel with notification center
notificationCenter.registerChannel(emailChannel);
 
// Send a notification
await notificationCenter.sendNotification("email", {
  to: "recipient@example.com",
  subject: "Hello",
  content: "This is a test email",
});

Using Multiple Providers

import { EmailChannel } from "@/app/notificationService/channels/email";
import { SendGridProvider } from "@/app/notificationService/channels/email/providers/sendgrid";
import { MailgunProvider } from "@/app/notificationService/channels/email/providers/mailgun";
 
// Create channel with fallback option
const emailChannel = new EmailChannel(tracker, { sendToAll: false });
 
// Register multiple providers
emailChannel.registerProvider(new SendGridProvider(sendGridConfig));
emailChannel.registerProvider(new MailgunProvider(mailgunConfig));
 
// Register channel
notificationCenter.registerChannel(emailChannel);
 
// Send notification (will try SendGrid first, then Mailgun if SendGrid fails)
await notificationCenter.sendNotification("email", emailPayload);

Specifying a Provider

// Send using a specific provider
await notificationCenter.sendNotification("email", emailPayload, "mailgun");

Examples

Simple Email Notification

This pattern is commonly used throughout the codebase for sending simple email notifications:

import { EmailChannel } from "@/app/notificationService/channels/email";
import { MailgunProvider } from "@/app/notificationService/channels/email/providers/mailgun";
import { mailgunConfig } from "@/app/notificationService/channels/email/providers/mailgun/config";
import {
  notificationCenter,
  tracker,
} from "@/app/notificationService/NotificationCenterInstance";
 
async function dispatchEmailNotification(payload) {
  try {
    const emailChannel = new EmailChannel(tracker);
    emailChannel.registerProvider(new MailgunProvider(mailgunConfig));
 
    notificationCenter.registerChannel(emailChannel);
 
    const result = await notificationCenter.sendNotification(
      "email",
      payload,
      "mailgun"
    );
 
    if (!result.success) {
      throw result.error;
    }
 
    return result;
  } catch (error) {
    // Error handling (e.g., logging, reporting to monitoring service)
    captureException(error);
  }
}

Common Patterns and Best Practices

Singleton Pattern

The notification service uses a singleton pattern through the NotificationCenterInstance.ts file, which exports a pre-configured notification center:

// NotificationCenterInstance.ts
import { NotificationCenter } from "@/app/notificationService/_core/NotificationCenter";
import { WinstonNotificationTracker } from "@/app/notificationService/logging/WinstonNotificationTracker";
import { PrismaNotificationLogger } from "@/app/notificationService/logging/PrismaNotificationLogger";
import prisma from "@/prisma/client";
import { NotificationMap } from "@/app/notificationService/providerTypes";
 
const persistentLogger = new PrismaNotificationLogger(prisma);
export const tracker = new WinstonNotificationTracker();
 
export const notificationCenter = new NotificationCenter<NotificationMap>({
  tracker,
  persistentLogger,
});

Helper Functions

Create reusable helper functions for common notification patterns:

// Common helper for sending emails
export async function sendEmail(payload, providerName = undefined) {
  const emailChannel = new EmailChannel(tracker);
 
  // Register all available providers
  emailChannel.registerProvider(new MailgunProvider(mailgunConfig));
  emailChannel.registerProvider(new SendGridProvider(sendGridConfig));
 
  notificationCenter.registerChannel(emailChannel);
 
  return notificationCenter.sendNotification("email", payload, providerName);
}

Utility Function Pattern

When creating utility functions for sending notifications, follow this recommended pattern:

/**
 * Sends an email notification with proper error handling
 * @param payload The email payload
 * @returns A promise that resolves when the notification is sent successfully
 * @throws Error if the notification fails
 */
async function sendEmailNotification(payload) {
  try {
    // 1. Set up the channel and provider
    const emailChannel = new EmailChannel(tracker);
    emailChannel.registerProvider(new MailgunProvider(mailgunConfig));
    notificationCenter.registerChannel(emailChannel);
 
    // 2. Send the notification (will throw if it fails)
    await notificationCenter.sendNotification("email", payload, "mailgun");
 
    // 3. Return success or result if needed
    return true;
  } catch (error) {
    // 4. Report to monitoring service
    captureException(error, {
      extra: {
        notificationType: "email",
        payload: JSON.stringify(payload),
      },
    });
 
    // 5. Re-throw the error to allow the caller to handle it
    throw error;
  }
}

This pattern ensures:

  1. Consistent Setup: Standard way to set up channels and providers
  2. Caller Flexibility: Re-throwing allows the caller to decide how to handle failures
  3. Clear Contract: The function signature makes it clear that errors may be thrown

You can adapt this pattern based on your specific requirements, such as adding fallback mechanisms or custom error handling.

Fire-and-Forget Pattern

For non-blocking notifications where you don't want to wait for the notification to complete, use the fire-and-forget pattern:

/**
 * Sends an email notification in the background without blocking
 * @param payload The email payload
 */
function sendEmailNotificationInBackground(payload) {
  // Set up the channel and provider
  const emailChannel = new EmailChannel(tracker);
  emailChannel.registerProvider(new MailgunProvider(mailgunConfig));
  notificationCenter.registerChannel(emailChannel);
 
  // Fire and forget - don't await, but handle errors with .catch()
  notificationCenter
    .sendNotification("email", payload, "mailgun")
    .then(() => {
      // Optional: Log success
      console.log("Background notification sent successfully");
    })
    .catch((error) => {
      // Always handle errors to prevent unhandled rejections
      console.error("Background notification failed:", error);
      captureException(error);
    });
 
  // Function returns immediately
  return true;
}

Important notes about this pattern:

  • The function returns immediately without waiting for the notification to complete
  • Always include a .catch() handler to prevent unhandled promise rejections
  • This approach is useful for notifications that aren't critical to the main workflow
  • Consider using a proper message queue for mission-critical notifications

Error Handling

Always check for success and handle errors appropriately:

const result = await notificationCenter.sendNotification("email", payload);
 
if (!result.success) {
  // Log the error
  logger.error(`Failed to send notification: ${result.error?.message}`);
 
  // Report to monitoring service
  captureException(result.error);
 
  // Optionally implement fallback mechanism
  if (fallbackEnabled) {
    return sendFallbackNotification(payload);
  }
}

Configuration Management

Store provider configurations in separate files and use environment variables:

// config.ts
export const mailgunConfig = {
  apiKey: process.env.MAILGUN_API_KEY,
  domain: process.env.MAILGUN_DOMAIN,
  defaultFrom: process.env.DEFAULT_FROM_EMAIL || "noreply@example.com",
};

Extending the System

Adding a New Provider

  1. Create a new directory under the appropriate channel:

    channels/email/providers/new-provider/
  2. Create the following files:

    • types.ts - Define provider config and payload types
    • index.ts - Implement the provider
    • config.ts - Provider configuration
  3. Update providerTypes.ts to include the new provider

Adding a New Channel

  1. Create a new directory under channels:

    channels/new-channel/
  2. Create the channel implementation and provider(s)

  3. Update providerTypes.ts to include the new channel

Logging

The notification service includes two types of logging:

  1. Real-time Tracking (WinstonNotificationTracker)

    • Logs events as they happen
    • Uses structured logging for better observability
  2. Persistent Logging (PrismaNotificationLogger)

    • Stores notification events in the database
    • Includes schema validation

Error Handling

The notification service provides comprehensive error handling:

  • Each provider implements its own error handling
  • The notification center standardizes error responses
  • All errors are logged for debugging and monitoring

Type System and Provider Configuration

The notification service uses TypeScript's type system to ensure type safety across all components. The central type definition is in providerTypes.ts, which defines the relationship between channels, providers, and their respective configurations and payloads.

NotificationMap

The NotificationMap type is the core type definition that ties the entire notification system together:

/**
 * Main notification map type that defines all available channels
 * and their respective providers, configs, and payloads.
 *
 * To add a new channel:
 * 1. Create the channel's provider configs and payloads
 * 2. Add a new entry here with the channel name
 */
export type NotificationMap = {
  email: EmailChannel;
  sms: SMSChannel;
};

This type map serves several important purposes:

  1. Type Safety: Ensures that notifications are sent with the correct payload type for each channel and provider
  2. Channel Registration: Defines which channels are available in the system
  3. Provider Configuration: Maps provider configurations to their respective channels
  4. Extensibility: Provides a clear pattern for adding new channels and providers

How It Works

The NotificationMap connects:

  • Channel names (e.g., "email", "sms")
  • Channel configurations (e.g., EmailChannel, SMSChannel)
  • Provider configurations (e.g., MailgunConfig, TwilioConfig)
  • Payload types (e.g., MailgunPayload, TwilioSMSPayload)

When you send a notification, the type system ensures that:

  1. You're using a valid channel name
  2. You're providing the correct payload type for that channel
  3. You're specifying a valid provider for that channel (if explicitly specified)

Adding New Channels

To add a new notification channel:

  1. Define provider config types:

    export type NewChannelProviderConfigs = {
      providerA: ProviderAConfig;
      providerB: ProviderBConfig;
    };
  2. Define provider payload types:

    export type NewChannelProviderPayloads = {
      providerA: ProviderAPayload;
      providerB: ProviderBPayload;
    };
  3. Define the channel type:

    export type NewChannel = {
      config: NewChannelProviderConfigs;
      payload: NewChannelProviderPayloads;
      defaultProvider?: keyof NewChannelProviderConfigs;
    };
  4. Add the new channel to the NotificationMap:

    export type NotificationMap = {
      email: EmailChannel;
      sms: SMSChannel;
      newChannel: NewChannel;
    };

This approach ensures that the type system guides developers in correctly implementing and using new notification channels and providers.

Best Practices

  1. Configuration Management

    • Store provider configurations securely
    • Use environment variables for sensitive information
  2. Error Handling

    • Always check the success status of notification responses
    • Implement appropriate fallback mechanisms
  3. Logging

    • Use the built-in logging for monitoring and debugging
    • Consider adding custom loggers for specific needs
  4. Performance

    • Register channels and providers during application initialization
    • Reuse the notification center instance

Troubleshooting

Common Issues

  1. Provider Authentication Failures

    • Check API keys and credentials
    • Verify environment variables are correctly set
  2. Missing Dependencies

    • Ensure all required packages are installed
    • Check for version compatibility issues
  3. Type Errors

    • Ensure payload types match the provider requirements
    • Check for missing or incorrect type definitions