TechLead
Leccion 17 de 30
7 min de lectura
Diseño de Sistemas

Diseño de Sistemas: Sistema de Notificaciones

Diseña un sistema de notificaciones escalable cubriendo push, SMS, email y notificaciones in-app con colas de prioridad, limitación de tasa y seguimiento de entrega

Enunciado del Problema

Diseña un sistema de notificaciones que pueda enviar notificaciones a través de múltiples canales: notificaciones push, SMS, email y notificaciones in-app. El sistema debe manejar alto rendimiento, respetar preferencias del usuario y proporcionar seguimiento de entrega. Los sistemas de notificaciones son un componente crítico de virtualmente toda aplicación moderna.

Paso 1: Requisitos

Requisitos Funcionales

  • Soportar múltiples tipos de notificación: push (iOS/Android), SMS, email, in-app
  • Contenido de notificación basado en plantillas con personalización
  • Gestión de preferencias del usuario (opt-in/opt-out por canal y categoría)
  • Niveles de prioridad: crítica, alta, media, baja
  • Seguimiento de entrega y analíticas
  • Limitación de tasa para prevenir fatiga de notificaciones
  • Notificaciones programadas

Requisitos No Funcionales

  • Alto rendimiento: 10 millones de notificaciones por día
  • Baja latencia para notificaciones críticas (<1 segundo)
  • Garantía de entrega al menos una vez
  • Tiempo real suave (mayoría de notificaciones entregadas en 5 segundos)
  • Degradación elegante si un proveedor de canal está caído

Paso 2: Tipos de Notificación en Profundidad

Comparación de Canales

Canal Latencia Costo Alcance Proveedor
Push (iOS)~1sGratisApp instaladaAPNs
Push (Android)~1sGratisApp instaladaFCM
Email1-30sBajoTiene emailSendGrid, SES
SMS1-5sAltoTiene teléfonoTwilio, SNS
In-AppInstantáneoGratisEn la appWebSocket/SSE

Paso 3: Arquitectura del Sistema

// Modelo de datos de notificación principal
interface Notification {
  id: string;
  userId: string;
  type: "push" | "sms" | "email" | "in_app";
  category: string;           // "marketing", "transactional", "social", "security"
  priority: "critical" | "high" | "medium" | "low";
  templateId: string;
  templateData: Record<string, any>;  // Variables para personalización
  scheduledAt?: Date;
  status: "pending" | "queued" | "sent" | "delivered" | "failed" | "read";
  createdAt: Date;
  sentAt?: Date;
  deliveredAt?: Date;
  readAt?: Date;
  retryCount: number;
  metadata: Record<string, any>;
}

// Preferencias de notificación del usuario
interface UserNotificationPreferences {
  userId: string;
  channels: {
    push: boolean;
    email: boolean;
    sms: boolean;
    inApp: boolean;
  };
  categories: {
    marketing: boolean;
    social: boolean;
    transactional: boolean;  // Usualmente no se puede deshabilitar
    security: boolean;       // Usualmente no se puede deshabilitar
  };
  quietHours?: {
    start: string;  // "22:00"
    end: string;    // "08:00"
    timezone: string;
  };
}

Componentes de Arquitectura

  • Servicio de Notificaciones (API): Acepta solicitudes de notificación de otros servicios, valida y encola
  • Servicio de Preferencias: Verifica preferencias del usuario y filtra notificaciones no deseadas
  • Cola de Prioridad (Kafka/RabbitMQ): Colas separadas para cada nivel de prioridad
  • Limitador de Tasa: Previene enviar demasiadas notificaciones a un solo usuario
  • Motor de Plantillas: Renderiza contenido de notificación desde plantillas y datos de usuario
  • Workers de Canal: Pools de workers separados para cada canal de entrega
  • Rastreador de Entrega: Registra estado de entrega y genera analíticas
class NotificationService {
  private preferenceService: PreferenceService;
  private rateLimiter: RateLimiter;
  private templateEngine: TemplateEngine;
  private queue: MessageQueue;

  async send(request: NotificationRequest): Promise<string> {
    // Paso 1: Validar la solicitud
    this.validateRequest(request);

    // Paso 2: Verificar preferencias del usuario
    const prefs = await this.preferenceService.getPreferences(request.userId);
    if (!this.isAllowed(request, prefs)) {
      return "FILTERED_BY_PREFERENCE";
    }

    // Paso 3: Verificar límites de tasa
    const allowed = await this.rateLimiter.checkLimit(
      request.userId,
      request.type,
      request.category
    );
    if (!allowed) {
      return "RATE_LIMITED";
    }

    // Paso 4: Verificar horas de silencio
    if (request.priority !== "critical" && this.isQuietHours(prefs)) {
      // Programar para después de las horas de silencio
      request.scheduledAt = this.getEndOfQuietHours(prefs);
    }

    // Paso 5: Renderizar plantilla
    const content = await this.templateEngine.render(
      request.templateId,
      request.templateData
    );

    // Paso 6: Crear registro de notificación
    const notification: Notification = {
      id: generateId(),
      userId: request.userId,
      type: request.type,
      category: request.category,
      priority: request.priority,
      templateId: request.templateId,
      templateData: request.templateData,
      status: "queued",
      createdAt: new Date(),
      retryCount: 0,
      metadata: { renderedContent: content },
    };

    // Paso 7: Encolar en cola de prioridad
    const queueName = `notifications.${request.priority}`;
    await this.queue.publish(queueName, notification);

    return notification.id;
  }
}

Paso 4: Colas de Prioridad y Limitación de Tasa

No todas las notificaciones son igualmente urgentes. Una alerta de seguridad debe entregarse inmediatamente, mientras que un email de marketing puede esperar. Usa colas separadas para cada nivel de prioridad, con diferentes configuraciones de concurrencia de consumidores.

// Configuración de cola de prioridad
const queueConfig = {
  critical: { concurrency: 100, maxRetries: 5, retryDelay: 1000 },    // Códigos 2FA, alertas de seguridad
  high:     { concurrency: 50,  maxRetries: 3, retryDelay: 5000 },    // Confirmaciones de pedido
  medium:   { concurrency: 20,  maxRetries: 3, retryDelay: 30000 },   // Notificaciones sociales
  low:      { concurrency: 5,   maxRetries: 2, retryDelay: 60000 },   // Marketing, resúmenes
};

// Implementación del limitador de tasa
class NotificationRateLimiter {
  private redis: RedisClient;

  // Reglas de limitación de tasa
  private rules = {
    push:  { perHour: 10, perDay: 50 },
    email: { perHour: 5,  perDay: 20 },
    sms:   { perHour: 3,  perDay: 10 },
    inApp: { perHour: 30, perDay: 100 },
  };

  async checkLimit(
    userId: string,
    channel: string,
    category: string
  ): Promise<boolean> {
    // Notificaciones transaccionales y de seguridad omiten límites de tasa
    if (category === "transactional" || category === "security") {
      return true;
    }

    const rule = this.rules[channel];
    const hourKey = `ratelimit:${userId}:${channel}:hour:${currentHour()}`;
    const dayKey = `ratelimit:${userId}:${channel}:day:${currentDay()}`;

    const [hourCount, dayCount] = await Promise.all([
      this.redis.incr(hourKey),
      this.redis.incr(dayKey),
    ]);

    // Establecer expiración en el primer incremento
    if (hourCount === 1) await this.redis.expire(hourKey, 3600);
    if (dayCount === 1) await this.redis.expire(dayKey, 86400);

    return hourCount <= rule.perHour && dayCount <= rule.perDay;
  }
}

Paso 5: Gestión de Plantillas

Las notificaciones no deben contener texto hardcodeado. Un sistema de plantillas permite que personas no técnicas modifiquen el contenido sin cambios de código, y soporta localización.

interface NotificationTemplate {
  id: string;
  name: string;
  channels: {
    push?: { title: string; body: string; };
    email?: { subject: string; htmlBody: string; textBody: string; };
    sms?: { body: string; };
    inApp?: { title: string; body: string; actionUrl: string; };
  };
  variables: string[];  // Variables de plantilla requeridas
  locale: string;       // "en", "es", "fr"
}

// Ejemplo de plantilla
const orderShippedTemplate: NotificationTemplate = {
  id: "order_shipped_v2",
  name: "Order Shipped",
  channels: {
    push: {
      title: "Tu pedido está en camino!",
      body: "Pedido #{{orderId}} ha sido enviado. Seguimiento: {{trackingUrl}}",
    },
    email: {
      subject: "Tu pedido #{{orderId}} ha sido enviado",
      htmlBody: "<h1>Buenas noticias, {{userName}}!</h1><p>Tu pedido ha sido enviado...</p>",
      textBody: "Buenas noticias, {{userName}}! Tu pedido ha sido enviado...",
    },
    sms: {
      body: "Tu pedido #{{orderId}} enviado! Seguimiento en {{trackingUrl}}",
    },
  },
  variables: ["orderId", "userName", "trackingUrl"],
  locale: "es",
};

Paso 6: Seguimiento de Entrega y Analíticas

class DeliveryTracker {
  // Rastrear cambios de estado de entrega
  async updateStatus(
    notificationId: string,
    status: Notification["status"],
    metadata?: Record<string, any>
  ): Promise<void> {
    await this.db.notifications.update(notificationId, {
      status,
      [`${status}At`]: new Date(),
      metadata: { ...metadata },
    });

    // Emitir evento para pipeline de analíticas
    await this.eventBus.emit("notification.status_changed", {
      notificationId,
      status,
      timestamp: Date.now(),
    });
  }
}

// Métricas de tasa de entrega a rastrear:
// - Tasa de envío: notificaciones enviadas por segundo
// - Tasa de entrega: % de notificaciones enviadas confirmadas como entregadas
// - Tasa de apertura: % de notificaciones entregadas abiertas/leídas
// - Tasa de clics: % que hicieron clic en un CTA
// - Tasa de rebote: % que fallaron la entrega
// - Tasa de desuscripción: usuarios que se dieron de baja después de recibir

Paso 7: Mecanismos de Reintento

La entrega de notificaciones puede fallar por varias razones: caídas de proveedores, tokens de dispositivo inválidos, límites de tasa de proveedores externos o problemas de red. Una estrategia robusta de reintento es esencial.

class NotificationWorker {
  async processNotification(notification: Notification): Promise<void> {
    try {
      const result = await this.deliverByChannel(notification);

      if (result.success) {
        await this.tracker.updateStatus(notification.id, "sent");
      } else {
        throw new Error(result.error);
      }
    } catch (error) {
      await this.handleFailure(notification, error);
    }
  }

  private async handleFailure(
    notification: Notification,
    error: Error
  ): Promise<void> {
    const config = queueConfig[notification.priority];

    if (notification.retryCount >= config.maxRetries) {
      // Reintentos máximos excedidos - marcar como fallido
      await this.tracker.updateStatus(notification.id, "failed", {
        error: error.message,
        finalRetryAt: new Date(),
      });

      // Mover a cola de mensajes muertos para investigación
      await this.queue.publish("notifications.dead_letter", notification);
      return;
    }

    // Backoff exponencial
    const delay = config.retryDelay * Math.pow(2, notification.retryCount);
    notification.retryCount++;

    await this.queue.publishWithDelay(
      `notifications.${notification.priority}`,
      notification,
      delay
    );
  }

  private async deliverByChannel(notification: Notification): Promise<DeliveryResult> {
    switch (notification.type) {
      case "push":
        return this.pushProvider.send(notification);
      case "email":
        return this.emailProvider.send(notification);
      case "sms":
        return this.smsProvider.send(notification);
      case "in_app":
        return this.inAppDelivery.send(notification);
    }
  }
}

Principios Clave de Diseño

  • Desacopla envío de entrega: Usa colas de mensajes entre la API de notificaciones y los workers de canal
  • Respeta preferencias del usuario: Siempre verifica opt-in/opt-out antes de enviar
  • Idempotencia: Usa IDs de notificación para prevenir envíos duplicados en reintentos
  • Abstracción de proveedor: Usa un patrón adaptador para poder intercambiar proveedores (ej., cambiar de Twilio a Vonage) sin cambiar lógica principal
  • Degradación elegante: Si las notificaciones push fallan, recurre a email o notificaciones in-app
  • Observabilidad: Registra cada transición de estado y rastrea métricas de entrega por canal

Continuar Aprendiendo