Engineering

The architecture behind DevNotify, written from the repository README. Source files were not independently inspected, so treat file paths as pointers to check.

One event, six stages

The asynchronous path from an external system to a delivered notification, as described in the README. Ingestion does not wait on email delivery, so the sender gets its 202 Accepted early.

  1. Ingest

    An external system sends POST /webhooks/{tenantSlug}/ingest. A tenant filter resolves the team from the slug before the request reaches the controller.

    Spring MVC filter chain (TenantFilter, JwtAuthFilter, SecurityConfig)

  2. Verify

    The sender signs the payload with HMAC-SHA256 using the tenant's API key and puts the hex digest in X-Webhook-Signature. The platform recomputes it and compares in constant time. The secret itself never crosses the network.

    HmacUtil with MessageDigest.isEqual

  3. Persist

    The raw payload is stored as JSONB in webhook_events with status RECEIVED. This runs in its own bean so the transaction commits before anything is published.

    PostgreSQL 16, Flyway (WebhookPersistenceService)

  4. Queue

    The event is published to RabbitMQ on devnotify.events. Messages that fail processing go to a dead letter queue, where they stay available for inspection instead of being retried forever.

    RabbitMQ 3.13 (NotificationProducer, dead letter queue)

  5. Match rules

    A consumer hands the event to NotificationProcessingService. Rules match on optional event type and source, where an empty field means catch-all. Matching rules produce notification records. Active rules are cached in Redis and evicted whenever a rule changes.

    NotificationConsumer (thin adapter)
    Redis 7, CachedRule DTO

  6. Deliver

    Each notification fans out to its rule's channels. Email is sent asynchronously so SMTP latency never blocks the queue. WebSocket push goes to the individual member and to a tenant-wide topic. Notifications are also indexed for full-text search, and every delivery attempt is recorded as PENDING, SENT or FAILED.

    EmailService (Mailtrap sandbox in the documented setup)
    Spring STOMP and SockJS
    Elasticsearch 8.15

The synchronous door into stage 5

A gRPC server on port 9090 exposes NotificationProcessor.ProcessWebhookEvent. It calls the same NotificationProcessingService as the queue consumer, so there is one definition of what processing an event means.

It exists for cases that need an immediate result, such as testing a rule now. Today everything runs in one process, so the contract and client/server pair work, but independent scaling of a split service does not exist yet.

PostgreSQL
Tenants, members, webhook events, rules, notifications and delivery records. Schema lives in five Flyway migrations.
RabbitMQ
Decouples ingestion from processing and holds failed messages in a dead letter queue.
Redis
Cache-aside for tenants, invalidate-on-write for notification rules.
Elasticsearch
Full-text search over notification history.

What is implemented

Seven behaviours from the README, each with the reasoning the repository gives for it.

Commit first, then publish

WebhookPersistenceService is a separate bean from the code that publishes to RabbitMQ. The database transaction commits before the message goes out.

Spring applies @Transactional through a bean's proxy, so a call within the same class bypasses it. Splitting the bean means a queued message cannot point at a row that was never committed.

Signed ingestion

Webhooks are authenticated with HMAC-SHA256 over the payload, keyed by the tenant's API key, and checked with a constant-time comparison.

The shared secret is never sent, and the comparison takes the same time wherever the signatures differ.

Rules per tenant

A rule names an optional event type, an optional source, and a list of channels, EMAIL or WEBSOCKET. Leaving type and source empty creates a catch-all.

A team changes what it hears about through the REST API instead of redeploying.

Deliveries you can audit

Every notification has one delivery row per channel, with status, attempt time, delivery time and a failure reason.

When an email does not arrive, the record shows whether it was attempted and why it failed.

Two caching strategies, chosen on purpose

Tenants use cache-aside. Notification rules use invalidate-on-write. Rules are cached as a flat CachedRule DTO rather than as JPA entities.

A stale tenant corrects itself after one slow read. A stale rule would silently misroute notifications for minutes. Caching entities directly hit a real serialization bug with lazy-loaded proxies, which the DTO avoids.

Searchable history

GET /api/v1/tenants/{slug}/notifications/search?q= runs a full-text query against Elasticsearch.

Past notifications can be found by their content, from the same API that creates them.

A synchronous path beside the async one

gRPC and Protocol Buffers define a ProcessWebhookEvent call that reuses the queue consumer's business logic.

Ingestion stays asynchronous so senders never wait on email. gRPC serves callers that want the result immediately.

Engineering details

Stack

Language
Java 21 target. The README also notes JDK 25 builds.
Framework
Spring Boot 3.4.5, modular monolith, package by feature
Persistence
Hibernate 6.6.13, hypersistence-utils 3.9.0 for JSONB, PostgreSQL 16, Flyway 10.20.1
Messaging
RabbitMQ 3.13.7
Cache and search
Redis 7, Elasticsearch 8.15.0
RPC
gRPC and Protocol Buffers 1.68.1
Real time
Spring STOMP and SockJS, JWT passed on the handshake
Auth
JJWT 0.12.5 with 24 hour tokens, BCrypt at cost 10
Build
Gradle 8 with Kotlin DSL, Docker for infrastructure

Where to read the code

Paths follow the project structure listed in the README.

Flyway, not Hibernate DDL

The schema is version-controlled SQL. Hibernate validates it at startup and never generates it.

JSONB for payloads

Webhook bodies are stored as received and interpreted downstream according to their source.

Tokens and sockets

JWT validation is signature-only, with no database round trip. Browsers cannot set headers on WebSocket connections, so the token travels as a query parameter and is checked in the handshake interceptor.

API reference and local setup

Endpoints

Webhooks
POST /webhooks/{tenantSlug}/ingest (HMAC signature, plus X-Event-Type and X-Event-Source headers). GET /webhooks/{tenantSlug}/events
Auth
POST /api/v1/auth/register, POST /api/v1/auth/login (returns a JWT)
Tenants
POST and GET /api/v1/tenants, GET /api/v1/tenants/{slug} (cached), PATCH /api/v1/tenants/{id}/suspend (evicts cache)
Rules
POST and GET /api/v1/tenants/{slug}/rules, DELETE .../rules/{ruleId} deactivates
Search
GET /api/v1/tenants/{slug}/notifications/search?q=
WebSocket
ws://localhost:8080/ws?token=<jwt>, subscribe to /user/queue/notifications or /topic/tenant/{tenantSlug}
gRPC
NotificationProcessor.ProcessWebhookEvent on port 9090. Manual test: POST /api/v1/grpc-test/process/{webhookEventId}

Run it locally

Requires Java 21+, Docker and Postman. Start the four services, fill in the gitignored application.yml (JWT secret, Mailtrap credentials, Redis, Elasticsearch and gRPC settings), then build and run.

docker run --name devnotify-postgres -e POSTGRES_DB=devnotify -e POSTGRES_USER=devnotify -e POSTGRES_PASSWORD=devnotify -p 5432:5432 -d postgres:16
docker run --name devnotify-rabbitmq -e RABBITMQ_DEFAULT_USER=guest -e RABBITMQ_DEFAULT_PASS=guest -p 5672:5672 -p 15672:15672 -d rabbitmq:3-management
docker run --name devnotify-redis -p 6379:6379 -d redis:7-alpine
docker run --name devnotify-elasticsearch -e discovery.type=single-node -e xpack.security.enabled=false -p 9200:9200 -d docker.elastic.co/elasticsearch/elasticsearch:8.15.0

./gradlew clean build -x test
./gradlew bootRun

The README then walks through a ten step end-to-end test: create a tenant, register a member, create a rule, connect a WebSocket, sign and send a webhook, and check the browser, Mailtrap inbox, search and gRPC re-processing. A local Redis on port 6379 can shadow the container, so check docker ps.

Full setup in the README

Status: built in five phases, still a single-process project

The README marks all five build phases complete. It also lists gaps, and a few more follow from its API reference. There is no hosted service, no published benchmark and no commercial offering.

  1. FoundationMulti-tenancy, JWT auth, HMAC ingestion
  2. ProcessingRabbitMQ consumer, rules, email
  3. Real timeSTOMP WebSocket push and broadcast
  4. ObservabilityRedis caching, Elasticsearch search
  5. gRPCSynchronous processing contract

Known gaps

  • Cache invalidation windowIf a write succeeds and the app crashes before eviction, stale data is served until the TTL expires, 5 to 10 minutes depending on the cache.
  • In-memory WebSocket sessionsThe session registry works for one instance. Multiple instances behind a load balancer would need a shared store such as Redis.
  • gRPC is rehearsal for nowEverything is one deployable, so independent scaling and deployment of a split service are not realized.
  • No JWT revocationA suspended member's token stays valid until it expires, up to 24 hours.
  • Unauthenticated management routesThe README's API reference lists tenant, rule, search and event-listing endpoints with no authentication. Only webhook ingestion (HMAC) and the WebSocket (JWT) are shown as protected.