Build a Notification Service from Scratch — Part 1: Architecture and Database Design
Series Overview
We’re building a notification service that sends messages across multiple channels (email, SMS, push). It handles templating, queuing, retries, and rate limiting — designed to be a standalone microservice.
Technology stack: FastAPI, PostgreSQL, Redis, Celery.
Architecture
┌──────────┐ ┌──────────────┐ ┌──────────┐ ┌──────────┐
│ Your │────▶│ Notification │────▶│ Redis │────▶│ Celery │
│ App │◀────│ Service │◀────│ Queue │◀────│ Workers │
└──────────┘ └──────┬───────┘ └──────────┘ └─────┬────┘
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│PostgreSQL│ │ Email │
│ (DB) │ │ SMS │
└──────────┘ │ Push │
└──────────┘
Database Schema
# app/models.py
from sqlalchemy import Column, BigInteger, String, Text, DateTime, JSON, ForeignKey, Enum
from sqlalchemy.orm import declarative_base
import enum
Base = declarative_base()
class NotificationStatus(str, enum.Enum):
PENDING = "pending"
QUEUED = "queued"
SENT = "sent"
FAILED = "failed"
RETRYING = "retrying"
class NotificationChannel(str, enum.Enum):
EMAIL = "email"
SMS = "sms"
PUSH = "push"
class NotificationTemplate(Base):
__tablename__ = "notification_templates"
id = Column(BigInteger, primary_key=True)
name = Column(String(100), unique=True, nullable=False)
channel = Column(Enum(NotificationChannel), nullable=False)
subject_template = Column(Text, nullable=True) # For email
body_template = Column(Text, nullable=False)
variables = Column(JSON, default=list) # ["user_name", "order_id"]
class Notification(Base):
__tablename__ = "notifications"
id = Column(BigInteger, primary_key=True)
template_id = Column(BigInteger, ForeignKey("notification_templates.id"))
channel = Column(Enum(NotificationChannel), nullable=False)
recipient = Column(String(255), nullable=False) # email/phone/device_token
subject = Column(Text, nullable=True)
body = Column(Text, nullable=False)
context = Column(JSON, default=dict) # {"user_name": "John"}
status = Column(Enum(NotificationStatus), default=NotificationStatus.PENDING)
retry_count = Column(Integer, default=0)
max_retries = Column(Integer, default=3)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
sent_at = Column(DateTime, nullable=True)
Template Engine
# app/template_engine.py
from jinja2 import Template
def render_template(template_str: str, context: dict) -> str:
"""Render a Jinja2 template with variables."""
return Template(template_str).render(**context)
# Example usage:
body_template = "Hello {{ user_name }}, your order #{{ order_id }} is confirmed."
context = {"user_name": "John", "order_id": "12345"}
result = render_template(body_template, context)
# "Hello John, your order #12345 is confirmed."
Creating Templates and Notifications
# app/services/notification_service.py
class NotificationService:
def __init__(self, db: Session):
self.db = db
def send(self, template_name: str, recipient: str, context: dict):
template = self.db.query(NotificationTemplate).filter_by(name=template_name).first()
if not template:
raise ValueError(f"Template '{template_name}' not found")
body = render_template(template.body_template, context)
subject = render_template(template.subject_template, context) if template.subject_template else None
notification = Notification(
template_id=template.id,
channel=template.channel,
recipient=recipient,
subject=subject,
body=body,
context=context
)
self.db.add(notification)
self.db.commit()
# Push to queue
enqueue_notification(notification.id)
return notification
Summary
We’ve designed the database schema for multi-channel notifications with Jinja2 templating and a service layer that creates notifications and enqueues them for delivery.
Advertisement