DEV Community

Cover image for Designing a Scalable Notification System in Node.js: An OOP + SOLID Approach for Production
Abanoub Kerols
Abanoub Kerols

Posted on

Designing a Scalable Notification System in Node.js: An OOP + SOLID Approach for Production

Notifications are a critical component of modern applications — from real-time alerts to marketing emails and push notifications. Designing a robust, maintainable, and scalable notification system is a classic system design interview question and a common real-world requirement for backend engineers.
This article walks through a professional, production-oriented design using Node.js, emphasizing OOP principles, SOLID, and practical tools like NestJS, BullMQ, and Socket.IO.

1. Clarifying Requirements
Start every design discussion by clarifying requirements:
Functional Requirements:

  • Send notifications in real-time or asynchronously
  • Support multiple channels: Email, Push (mobile/web), SMS
  • Respect user preferences (opt-in/opt-out per channel)
  • Support bulk notifications
  • Track delivery status (sent/failed)

Non-Functional Requirements:

  • High scalability (millions of users)
  • Low latency for real-time notifications
  • Fault tolerance and retry mechanisms
  • Extensibility (easy to add new channels like WhatsApp)

2. High-Level Architecture
A typical production-grade flow looks like this:
Client → API Gateway → Notification Service → Queue (BullMQ/Redis) → Workers → External Providers (SendGrid, Firebase, Twilio)

  • Database: Stores notification history, status, and user preferences (MongoDB or PostgreSQL)
  • Real-time: WebSockets (Socket.IO) for instant delivery

This architecture ensures decoupling, scalability, and retry capabilities.

3. OOP Design & SOLID Principles (The Clean Core)
Instead of messy if/else chains, we use proper object-oriented design.

Core Abstraction

// notification.interface.ts
export interface Notification {
  send(to: string, message: string): Promise<void>;
}
Enter fullscreen mode Exit fullscreen mode

Concrete Implementations

// email.notification.ts
export class EmailNotification implements Notification {
  async send(to: string, message: string): Promise<void> {
    console.log(`📧 Sending EMAIL to ${to}: ${message}`);
    // Integrate SendGrid / Nodemailer here
  }
}

// Similarly for SMSNotification and PushNotification
Enter fullscreen mode Exit fullscreen mode

Notification Service (Polymorphism in Action)

export class NotificationService {
  constructor(private notifier: Notification) {}

  async notify(userId: string, message: string): Promise<void> {
    await this.notifier.send(userId, message);
  }
}
Enter fullscreen mode Exit fullscreen mode

SOLID Principles Applied:

  • Single Responsibility: Each notification class handles only its channel.
  • Open/Closed: Add new channels (e.g., WhatsAppNotification) without modifying existing code.
  • Liskov Substitution: Any Notification implementation can replace another seamlessly.
  • Interface Segregation: Small, focused interface.
  • Dependency Inversion: High-level modules depend on abstractions, not concrete classes.

4. Factory Pattern for Flexibility

// notification.factory.ts
export class NotificationFactory {
  static create(type: string): Notification {
    switch (type.toLowerCase()) {
      case 'email': return new EmailNotification();
      case 'sms':   return new SMSNotification();
      case 'push':  return new PushNotification();
      default: throw new Error('Invalid notification type');
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

5. Production-Ready Implementation with Queue (BullMQ)

// notification.queue.ts
import { Queue } from 'bullmq';

export const notificationQueue = new Queue('notifications', {
  connection: { host: 'localhost', port: 6379 }
});
Enter fullscreen mode Exit fullscreen mode

Worker (Background Processing)

// notification.processor.ts
import { Worker } from 'bullmq';
import { NotificationFactory } from '../factory/notification.factory';

const worker = new Worker('notifications', async (job) => {
  const { type, to, message } = job.data;
  const notifier = NotificationFactory.create(type);
  await notifier.send(to, message);
}, { connection: { host: 'localhost', port: 6379 } });
Enter fullscreen mode Exit fullscreen mode

Service Layer (NestJS)

@Injectable()
export class NotificationService {
  async sendNotification(type: string, to: string, message: string) {
    await notificationQueue.add('send', { type, to, message }, {
      attempts: 3,
      backoff: { type: 'exponential' }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

6. API Controller + Real-time WebSockets

// notification.controller.ts
@Post()
async send(@Body() body: { type: string; to: string; message: string }) {
  await this.service.sendNotification(body.type, body.to, body.message);
  return { status: 'queued' };
}
Enter fullscreen mode Exit fullscreen mode

WebSocket Gateway (Real-time)

@WebSocketGateway()
export class NotificationGateway {
  @WebSocketServer() server: Server;

  sendToUser(userId: string, message: string) {
    this.server.to(userId).emit('notification', message);
  }
}
Enter fullscreen mode Exit fullscreen mode

7. Project Structure (Clean Architecture Style)

notification-system/
├── src/
│   ├── modules/notification/
│   │   ├── interfaces/
│   │   ├── implementations/
│   │   ├── factory/
│   │   ├── services/
│   │   ├── queue/
│   │   └── notification.controller.ts
│   └── app.module.ts
├── docker-compose.yml
Enter fullscreen mode Exit fullscreen mode

8. Advanced Production Considerations

  • Rate Limiting & anti-spam protection
  • Idempotency keys to prevent duplicate notifications
  • User Preferences stored in DB
  • Notification Templates service
  • A/B Testing for different notification strategies
  • Monitoring: Job success/failure rates, latency
  • Scaling: Horizontal scaling of workers + Kafka for higher throughput

Tech Stack Recommendation:

  • NestJS (structure + DI)
  • BullMQ + Redis (queue)
  • Socket.IO (real-time)
  • MongoDB/PostgreSQL + Redis (cache)
  • Docker for local/prod

Conclusion & Interview Tips

When asked to "design a notification system," demonstrate both high-level architecture thinking and clean code principles. Mentioning OOP + SOLID, Factory Pattern, and queue-based processing will set you apart from candidates who only talk about queues and providers.

Top comments (0)