Notify
Multi-tenant notification service in Java and RabbitMQ, in production since May 2026
Measured
| Metric | Value | Window | Source |
|---|---|---|---|
| Delivery success, 1,321 of 1,346 jobs sent | 98.1% | lifetime | SELECT status, count(*) FROM notification_jobs, prod Postgres, 14 Sep 2026 |
| In-app success, 712 of 712 | 100% | lifetime | same query, channel = IN_APP |
| Retries needed, 1,378 attempts for 1,346 jobs | 32 | lifetime | notification_delivery_attempts, 14 Sep 2026 |
| Events ingested, since 18 May 2026, about 6 a day | 712 | lifetime | notification_events, 14 Sep 2026 |
Problem
CampusCritique needed a dozen kinds of notification the week Connect launched: booking confirmed, reminder two hours before, session rescheduled, refund processed, review ready, payout sent. Each had to reach a student and a mentor, in-app, by email and by push, without a slow email ever failing a payment webhook. Building it into the Next.js app would have meant every route knowing about SMTP, retries and templates. So I built it as a separate service that any product could post events to.
How it works
A product authenticates with a tenant-scoped API key (stored as a SHA-256 hash; the raw key is printed once by an internal CLI). The event is written to Postgres and committed before it is published to RabbitMQ. A consumer turns it into one job per channel, using the tenant’s notification rules. A scheduled worker claims due jobs, renders the tenant’s template for that event and channel, and hands the result to a channel handler. Every attempt is recorded with the provider, the attempt number and the error, if any. Retryable failures back off and try again up to three times; the rest fail immediately and visibly.
The decisions that mattered
- Commit, then publish, then sweep. If the database commit succeeds and the broker publish fails, a recovery scheduler republishes any event still QUEUED after two minutes. No distributed transaction, no lost events.
- Claim and finalise one job at a time. A batch of fifty emails is never one transaction, so one failure cannot roll back forty-nine deliveries that already happened.
- Idempotent job creation. The consumer creates the initial job only if it is missing, so a redelivered broker message does not double-send.
- Off by default. Email and push are disabled until their credentials exist; enabling email without SMTP settings fails startup with a clear message instead of failing silently at 2am.
- Small surface. Body-size guard, charset validation, header-size cap, CORS allow-list, no stack traces in responses.
Before and after
What broke, and what is next
Twenty-five of 1,346 jobs failed, all on email and push, none in-app. The likely causes are expired push subscriptions and bounced addresses; a fix for those failure classes is next, with the before and after published here. The second tenant now exists: the contact form on this site is tenant portfolio, with its own API key, rules and templates, and GET /api/v1/metrics (totals, per channel, per day, median ingest to delivered) feeds the live reliability ledger. For the first message through it, the endpoint reports a median ingest-to-delivered time of 7.1 seconds across the two channels (14 Sep 2026).
Learned: Commit before you publish, then sweep for the gap.