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:
- Core Framework (
_coredirectory) - A reusable, provider-agnostic notification framework that can be added to any project - 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:
NotificationCenterprovides 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:
- NotificationCenter - The central orchestrator that manages channels and coordinates notification delivery
- BaseNotificationChannel - Abstract base class for implementing notification channels
- BaseNotificationProvider - Abstract base class for implementing notification providers
- 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
- Fallback Mechanism: When provider-specific fields are missing, the base payload provides essential data that can be used by any provider
- Consistency: Ensures all providers in a channel support a minimum set of common fields
- Type Safety: Provides a clear contract for what data is required to send a notification through a specific channel
- 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:
- Specific Channels - Email, SMS, etc.
- Provider Implementations - SendGrid, Mailgun, Twilio, etc.
- Logging Mechanisms - Winston for real-time tracking, Prisma for persistent storage
- 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 providersUsage
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:
- Consistent Setup: Standard way to set up channels and providers
- Caller Flexibility: Re-throwing allows the caller to decide how to handle failures
- 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
-
Create a new directory under the appropriate channel:
channels/email/providers/new-provider/ -
Create the following files:
types.ts- Define provider config and payload typesindex.ts- Implement the providerconfig.ts- Provider configuration
-
Update
providerTypes.tsto include the new provider
Adding a New Channel
-
Create a new directory under channels:
channels/new-channel/ -
Create the channel implementation and provider(s)
-
Update
providerTypes.tsto include the new channel
Logging
The notification service includes two types of logging:
-
Real-time Tracking (WinstonNotificationTracker)
- Logs events as they happen
- Uses structured logging for better observability
-
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:
- Type Safety: Ensures that notifications are sent with the correct payload type for each channel and provider
- Channel Registration: Defines which channels are available in the system
- Provider Configuration: Maps provider configurations to their respective channels
- 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:
- You're using a valid channel name
- You're providing the correct payload type for that channel
- You're specifying a valid provider for that channel (if explicitly specified)
Adding New Channels
To add a new notification channel:
-
Define provider config types:
export type NewChannelProviderConfigs = { providerA: ProviderAConfig; providerB: ProviderBConfig; }; -
Define provider payload types:
export type NewChannelProviderPayloads = { providerA: ProviderAPayload; providerB: ProviderBPayload; }; -
Define the channel type:
export type NewChannel = { config: NewChannelProviderConfigs; payload: NewChannelProviderPayloads; defaultProvider?: keyof NewChannelProviderConfigs; }; -
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
-
Configuration Management
- Store provider configurations securely
- Use environment variables for sensitive information
-
Error Handling
- Always check the success status of notification responses
- Implement appropriate fallback mechanisms
-
Logging
- Use the built-in logging for monitoring and debugging
- Consider adding custom loggers for specific needs
-
Performance
- Register channels and providers during application initialization
- Reuse the notification center instance
Troubleshooting
Common Issues
-
Provider Authentication Failures
- Check API keys and credentials
- Verify environment variables are correctly set
-
Missing Dependencies
- Ensure all required packages are installed
- Check for version compatibility issues
-
Type Errors
- Ensure payload types match the provider requirements
- Check for missing or incorrect type definitions