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.

The first version lived inside the Next.js app: an email call at the end of each API route. It had the obvious problems. A Resend timeout inside the Cashfree webhook handler meant the gateway saw a 500 and retried the payment callback. Nobody could say whether a given reminder had gone out, because the only record was a console line on Vercel. And every new notification meant another route learning about SMTP, retry loops and HTML templates.

So I pulled it out into its own service, built as a product rather than a helper: any application posts an event, Notify decides who gets what on which channel, and every attempt is a row you can query. It has run in production since 18 May 2026 and now serves two tenants, CampusCritique and this site’s contact form.

From the repository

45Commitsall mine
7,076Lines of code132 source files
11Test filesin the tree
25MigrationsSQL, versioned
Counted from notify on 2026-09-15, commits 2026-05-17 to 2026-09-14. Source lines only (no vendored, generated or built files). One author for the whole history.

Package com.npaas.notify, nine tables (tenants, tenant_api_keys, notification_events, notification_rules, notification_templates, notification_jobs, notification_delivery_attempts, in_app_notifications, push_subscriptions), six controllers under /api/v1 (events, jobs, templates, in-app notifications, push subscriptions, metrics), eleven test classes covering the renderer, job creation, delivery, recovery, the request-size filter and the API-key filter.

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.

Ingest. A product authenticates with a tenant-scoped API key sent as X-Notify-Api-Key. The key is stored as a SHA-256 hash; the raw key is printed exactly once by an internal CLI (admin:create-api-key) and never again. The request carries an event type, a recipient, a payload and an idempotency key. If the same tenant sends the same idempotency key twice, the second call returns the first event with duplicate: true and nothing else happens.

Commit, then publish. The event row is written with status RECEIVED, marked QUEUED, and the transaction commits. Only then, in an afterCommit hook, is the event published to the notify.events exchange. If the broker is down the publish fails quietly, the client still gets its 202, and a recovery scheduler republishes any event still QUEUED after 120 seconds. There is no distributed transaction anywhere in the service.

Fan out. A RabbitMQ consumer looks up the tenant’s enabled rules for that event type (a rule is an event type paired with a channel) and creates one job per channel, rendering the tenant’s template for that event and channel at creation time so the job carries its final subject and body. Templates use {{placeholder}} substitution from the payload; most of the 25 migrations are rules and templates for the CampusCritique tenant.

Deliver. A scheduled worker runs every five seconds. It first returns any job stuck in PROCESSING for more than two minutes to PENDING, then claims up to fifty due jobs one at a time with SELECT ... FOR UPDATE. Each claim, each success and each failure is its own transaction (REQUIRES_NEW), so a batch of fifty emails is never one transaction and one failure cannot roll back forty-nine deliveries that already happened. The handler runs on a separate thread with a 20-second timeout. Every attempt is recorded with the provider, the attempt number and the error.

Retry with judgement. Failures are classified at the source. A Resend 5xx, a timeout or a network error is retryable and schedules the next attempt sixty seconds later, up to three attempts. A 422 for a bad address, a missing recipient field or a disabled channel fails the job immediately, because retrying cannot change the answer. Failed jobs are listed at GET /api/v1/jobs/failed with their last error.

Try it

The simulator below runs one connect.session_booked event through the same states the service uses, with the production settings. Flip the switches to take the broker down, redeliver a message, or make the email provider misbehave, then step through what each loop does.

Delivery simulatorstate machine from the Java source, production settings
step 1 of 10 · simulated clock +0 s · ends at +5.9 s
EventRECEIVED
type
connect.session_booked
tenant
campuscritique
idempotency
booking-8f3a
recipient
student u_2041
In-appno job yet
created by the consumer
Email (Resend)no job yet
created by the consumer
Push (Firebase)no job yet
created by the consumer
  1. +0 sPOST /api/v1/events with X-Notify-Api-Key (tenant campuscritique), type connect.session_booked, idempotency key booking-8f3a

0 delivery attempts recorded in notification_delivery_attempts so far. Settings mirror application.yaml: batch 50, max-attempts 3, retry-backoff 60 s, handler-timeout 20 s, recovery stale-after 120 s every 60 s. The consumer, the worker and the sweep are three independent loops; nothing here is a distributed transaction.

The decisions that mattered

  • Commit, then publish, then sweep. The outbox pattern without an outbox table: the event row itself is the outbox, and QUEUED older than two minutes is the signal to republish. No distributed transaction, no lost events, and a broker outage costs at most a couple of minutes of latency.
  • Claim and finalise one job at a time. A batch of fifty emails is never one transaction. The first version (17 May) delivered a whole batch inside one @Transactional method, which meant one failing push could roll back the rows for emails Resend had already accepted, and the next tick would send them again. The hardening commit the next morning split it into per-job claim and finalise, before the first production event.
  • Idempotent at every hop. Idempotency key at ingest, existsByEventIdAndChannel plus a unique constraint at fan-out, FOR UPDATE at claim. Each hop can be retried by the layer above it without double-sending.
  • Classify failures where they happen. The email and push handlers throw DeliveryException(message, retryable). The delivery service does not guess; it retries exactly what the handler says can change on a retry.
  • Off by default. Email and push are disabled until their credentials exist. Enabling email without SMTP or Resend settings fails startup with a clear message (EmailConfigurationValidator) instead of failing silently at 2am.
  • Small surface, checked early. A request-size filter caps event bodies (64 KB default, 1 MB hard ceiling), validates the charset and caches the body so it can be read once. No stack traces in responses. CORS is an allow-list. The API-key filter runs before Spring Security’s own chain and only the health endpoints are anonymous.
  • Templates in the database, per tenant, versioned by migration. Product teams change copy by shipping a migration, which is reviewed like code. The render-test endpoint lets them see the output for a sample payload before it goes live.

Timeline

  1. First commit to tenant-scoped API keys in one day ingest, RabbitMQ publish, jobs from rules, template rendering, in-app storage, API-key security, admin CLI.
  2. Delivery workers, email channel, templates API, Docker plus the first reliability hardening and the failed-jobs endpoint.
  3. Live on Render for CampusCritique first production events the same day.
  4. Push channel (Firebase and VAPID), deep links in in-app notifications
  5. Four fixes in one day for hung handlers handler timeouts, bounded web-push sends, ingest kept alive through broker hiccups.
  6. Connect launch templates booked, cancelled, rescheduled, refund processed and failed, review ready, payout.
  7. V24: one reminder at 2 hours replaces 24 h and 10 min
  8. Second tenant, metrics endpoint, per-event sender overrides this site's contact form; GET /api/v1/metrics feeds the public ledger.
From git log; dates are commit dates.

Before and after

BeforeAfter
Where notifications livedInline in Next.js API routes, one email call per routeOne service, 25 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 and metrics endpoints
A second productCopy the email code againOne migration: tenant row, API key, rules, templates

What broke, and what is next

Twenty-five of 1,346 jobs failed, all on email and push, none in-app: 10 of 284 emails (96.6%) and 15 of 325 pushes (95.6%). The likely causes, to be confirmed from the attempt log before the fix ships: bounced or unverified addresses on email, and expired browser subscriptions on push. The push case is the interesting one, because the handler classes every Firebase error as retryable, so an expired token burns three attempts sixty seconds apart before the job fails. If all 15 push failures went that route, they alone account for 30 of the 32 retries in the table above.

The fix is small and is the next change: treat Firebase’s UNREGISTERED and INVALID_ARGUMENT as non-retryable, delete the subscription row on the first one, and expose a per-recipient last failure so a product can prompt the user to re-subscribe. The before and after will be published here from the same query that produced the numbers above.

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 reported a median ingest-to-delivered time of 7.1 seconds across the two channels (14 Sep 2026): one worker tick plus one Resend round-trip.

Further out: a dead-letter queue for events whose rules produce no jobs, per-tenant rate limits, an SMS or WhatsApp channel, and Micrometer metrics into Grafana so the ledger does not have to be a bespoke endpoint.

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