One Notification Service for Every Project in the Portfolio
The Situation
Every backend service I build eventually needs to send an email. A task management system needs "task assigned" emails. A scheduling tool needs "appointment reminder" emails. A monitoring system needs "heartbeat missed" alerts. The notification requirement is universal, but the implementation keeps getting rebuilt from scratch in every project.
The pattern looked like this: set up the Resend SDK, write template files, build an opt-out table, add dedup logic to prevent double-sends, add delivery tracking so you know what actually went out, and wire it all into the application. The first time takes a day. The second time takes half a day because you copy some code. By the third project, the copied code has diverged, each version has slightly different opt-out semantics, and fixing a bug in the template renderer means patching three repositories.
The notification hub replaces all of that. Every project in the portfolio sends a single HTTP POST with an API key and an event payload. The hub matches the event to rules, renders the template, checks user preferences, and delivers across email, Telegram, WebSocket, or SMS. One deployment. One Resend domain. Unlimited consumers.
The Cost of Doing Nothing
Setting up notification infrastructure from scratch in a TypeScript project takes roughly 8-10 hours: email SDK integration, Handlebars or React Email templates, opt-out preferences, deduplication, delivery tracking, and error handling. Across a 10-project portfolio at a conservative rate of ~€75/hour, that's €6,000-7,500 in repeated development work before any maintenance.
Beyond developer time, duplicated notification logic introduces operational risk. Each project has its own definition of "deduplicated." Some check by event ID, some by timestamp, some by both but with different windows. One project's quiet hours implementation uses UTC. Another uses the user's local timezone. A third doesn't have quiet hours at all. The inconsistency is invisible until a user complains about getting woken up at 3 AM by a notification that should have been held.
And when the email provider changes their API, or when you need to add Telegram as a channel, you patch every project separately. Or you accept the drift and each service slowly becomes its own version of the same problem.
What I Built
A multi-tenant notification service in TypeScript, running on Fastify with PostgreSQL and a configurable processing pipeline. The system accepts events from any project, matches them against per-tenant routing rules, renders Handlebars templates with the event payload, enforces per-user notification preferences (opt-outs, quiet hours, digest batching, delivery address resolution), and dispatches to four channels.
Every event goes through an eight-step pipeline before anything gets sent. The pipeline resolves the delivery address (is the recipient a user ID to look up, or a raw email address to use directly?), checks opt-out preferences per channel and event type, runs deduplication against a 60-minute sliding window, checks quiet hours in the user's timezone, routes to the digest queue if the user has batching enabled, renders the template, logs the notification, and dispatches. Each step can bail out with a recorded reason. Nothing disappears silently.
The hardest part was not the pipeline logic itself but making it work identically across two different ingestion modes. The system was designed around Kafka event consumption, but the production VPS doesn't have enough RAM to run a Kafka broker alongside everything else. The solution: a single environment flag that switches between Kafka (for development and future scaling) and direct inline processing (for production). Both paths feed the same pipeline function. The pipeline doesn't know how the event arrived.
System Flow
Data Model
Architecture Layers
The Decision Log
| Decision | Alternative Rejected | Why |
|---|---|---|
| Direct HTTP processing in production, Kafka in development only | Running Kafka everywhere | The production VPS has 1GB RAM shared across services. Redpanda needs 150-200MB just to idle. Events already arrive via HTTP. The Kafka consumer stays in the codebase behind a USE_KAFKA flag for future scaling. |
| Application-level tenant isolation over database-level security | PostgreSQL Row-Level Security | Every database query already filters by tenant_id injected from the auth middleware. RLS would add per-query overhead for a constraint the app layer already enforces. |
| Handlebars over React Email for templates | React Email, MJML | Templates are stored in PostgreSQL and modified at runtime via REST API. Handlebars compiles from a string. React Email and MJML both need a build step. |
| Per-tenant JSONB config over separate config tables | Normalized config tables | One tenants.config.channels column stores email and Telegram credentials. Adding a channel means adding a JSON key, not running a migration. Zod validates the shape at read time. |
| One Resend domain, per-tenant sender address | Separate verified domains per tenant | Resend validates at the domain level. Different sender addresses on the same domain work with zero DNS changes per onboarded project. |
| Digest queue marked sent on failure | Retry on next cycle | A failed digest that retries next hour sends the same batch again. The user gets duplicates. Marking as sent loses one email but prevents pile-up. |
Ecosystem Integration
The notification hub is the messaging backbone for the broader portfolio. Rather than each project building its own email plumbing, they connect to the Hub with one HTTP call and an API key. The Healthcare Appointment Slot Optimizer, currently in development, sends appointment.booked, appointment.cancelled, and appointment.no_show events to the Hub for patient notifications. The Client Management Portal sends client.onboarded and task.submitted events for admin alerts. Both integrate behind a feature flag so they work standalone when the Hub is down.
The integration pattern is consistent: register as a tenant, create rules and templates via the admin API, fire events from application code. The Hub handles rendering, preference checks, dedup, and delivery. No consuming project carries notification logic in its own codebase.
Results
The Hub runs in production at notify.kingsleyonoh.com on 128MB of RAM. The codebase has 269 tests across 42 files covering rule matching, template rendering, pipeline processing (opt-out, quiet hours, dedup, digest routing), channel dispatch, admin CRUD, tenant isolation, and WebSocket push. Statement coverage sits at 86.5%.
Before the Hub, integrating notifications into a new project meant 8-10 hours of setup per project. After the Hub, the integration is one HTTP call and three admin API requests to register a tenant, create a template, and create a rule. The first real consumer, the Client Management Portal, was integrated in under 30 minutes.
The system currently handles low event volume on a single process. The Kafka consumer path is tested, working, and waiting behind one environment variable for the day throughput demands async processing. Until then, the direct HTTP path is simpler to operate and debug, and it fits within the 128MB budget.