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>;
}
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
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);
}
}
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');
}
}
}
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 }
});
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 } });
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' }
});
}
}
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' };
}
WebSocket Gateway (Real-time)
@WebSocketGateway()
export class NotificationGateway {
@WebSocketServer() server: Server;
sendToUser(userId: string, message: string) {
this.server.to(userId).emit('notification', message);
}
}
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
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)