Work · 01 of 9

Notify

Multi-tenant notification service in Java and RabbitMQ, in production since May 2026

Role
Designed and built alone
Period
May 2026 to present
Status
production
Stack
  • Java 21
  • Spring Boot 3.5
  • RabbitMQ
  • PostgreSQL
  • Flyway
  • Resend
  • Firebase
  • Docker
  • Render

Measured

MetricValueWindowSource
Delivery success, 1,321 of 1,346 jobs sent98.1%lifetimeSELECT status, count(*) FROM notification_jobs, prod Postgres, 14 Sep 2026
In-app success, 712 of 712100%lifetimesame query, channel = IN_APP
Retries needed, 1,378 attempts for 1,346 jobs32lifetimenotification_delivery_attempts, 14 Sep 2026
Events ingested, since 18 May 2026, about 6 a day712lifetimenotification_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

API keypublishProductPOST /api/v1/eventsPostgresevent QUEUED (commit)RabbitMQnotify.eventsJob servicerules → jobs per channelTemplateper tenant, per channelDelivery workerevery 5 s, batch 50In-appstored in NotifyEmailResendPushFirebase, VAPID
One event in, one job per matched channel out. The database is the source of truth; the broker is a courier.

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

BeforeAfter
Where notifications livedInline in Next.js API routes, one email call per routeOne service, 24 Flyway migrations, templates in Postgres per tenant
A slow emailCould time out the payment webhook that triggered itIs a job with its own retries; the webhook returns in milliseconds
Broker outageNotification lostEvent stays QUEUED, republished by the recovery sweep
VisibilityConsole logsEvery attempt logged with provider, attempt number, error; failed-jobs endpoint

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.

keysKeyboard
j / k
next / previous row
Enter
open the focused row
⌘K or /
search, or ask this site
g then h w a c
go home, work, about, contact
t
toggle light and dark
Esc
close the palette or this map
?
this map