# Real-Time Notifications Architecture Decision **Status:** POSTPONED **Created:** 2026-02-08 **Last Updated:** 2026-02-08 --- ## Context The Legal Consent Hub requires real-time notification delivery to users. When events occur (e.g., form submissions, approvals, comments), affected users should receive notifications immediately without refreshing the page. ### Constraint: Stateless Backend The backend **must be stateless** for horizontal scaling. Multiple backend instances run behind a load balancer, and any instance must be able to handle any request. --- ## Current Implementation **Location:** `src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/` ### Files | File | Purpose | |------|---------| | `SseNotificationEmitterRegistry.kt` | In-memory registry of SSE connections | | `NotificationEventListener.kt` | Listens for `NotificationCreatedEvent` and triggers SSE delivery | | `NotificationCreatedEvent.kt` | Spring application event for new notifications | | `NotificationService.kt` | Creates notifications and publishes events | | `NotificationController.kt` | REST endpoints including SSE stream endpoint | ### How It Works 1. Frontend opens SSE connection to `/api/notifications/stream` 2. Backend stores `SseEmitter` in `SseNotificationEmitterRegistry` (in-memory `CopyOnWriteArrayList`) 3. When notification created, `NotificationService` publishes `NotificationCreatedEvent` 4. `NotificationEventListener` receives event, uses registry to send SSE to matching users 5. Registry filters by `userId`, `organizationId`, and `roles` to route notifications ### Current Code (SseNotificationEmitterRegistry.kt) ```kotlin @Service class SseNotificationEmitterRegistry { // IN-MEMORY STORAGE - THIS IS THE PROBLEM private val connections = CopyOnWriteArrayList() fun registerEmitter(userId: String, organizationId: String, userRoles: List): SseEmitter { val emitter = SseEmitter(TIMEOUT_MS) val context = SseConnectionContext(emitter, userId, organizationId, userRoles) connections.add(context) // ... cleanup handlers return emitter } fun sendToUser(userId: String, organizationId: String, event: SseEmitter.SseEventBuilder) { connections.filter { it.userId == userId && it.organizationId == organizationId } .forEach { it.emitter.send(event) } } // Also: sendToRoles(), sendToOrganization() } ``` --- ## The Problem **In-memory storage breaks with multiple instances.** ``` ┌─────────────────────────────────────────────────────────────┐ │ Load Balancer │ └─────────────────────────────────────────────────────────────┘ │ │ ▼ ▼ ┌─────────────────────────┐ ┌─────────────────────────┐ │ Instance A │ │ Instance B │ │ │ │ │ │ connections = [ │ │ connections = [ │ │ User1-Emitter, │ │ User2-Emitter, │ │ User3-Emitter │ │ User4-Emitter │ │ ] │ │ ] │ └─────────────────────────┘ └─────────────────────────┘ ``` **Scenario:** 1. User1 connects via SSE → routed to Instance A → emitter stored in A's memory 2. User2 connects via SSE → routed to Instance B → emitter stored in B's memory 3. User1 creates notification for User2 4. Instance A handles the request, tries to send SSE to User2 5. **FAILURE:** User2's emitter is in Instance B, not Instance A 6. User2 never receives the notification --- ## Options ### Option 1: Redis Pub/Sub **How it works:** - When notification created, publish event to Redis channel - All backend instances subscribe to this channel - Each instance forwards matching events to its local SSE connections ``` ┌──────────────┐ publish ┌─────────────┐ │ Instance A │ ───────────────► │ Redis │ │ (creates │ │ Pub/Sub │ │ notification) └─────────────┘ └──────────────┘ │ │ subscribe (all instances) ┌───────────────────┼───────────────────┐ ▼ ▼ ▼ ┌──────────┐ ┌──────────┐ ┌──────────┐ │Instance A│ │Instance B│ │Instance C│ │(send to │ │(send to │ │(send to │ │local SSE)│ │local SSE)│ │local SSE)│ └──────────┘ └──────────┘ └──────────┘ ``` | Pros | Cons | |------|------| | Low latency (~1-5ms) | Requires Redis infrastructure | | Battle-tested pattern | Additional operational complexity | | Spring has good support (`spring-boot-starter-data-redis`) | Another service to monitor/maintain | | Can reuse Redis for caching later | | **Implementation effort:** ~2-4 hours **Dependencies to add:** ```kotlin implementation("org.springframework.boot:spring-boot-starter-data-redis") ``` --- ### Option 2: PostgreSQL LISTEN/NOTIFY **How it works:** - Use Postgres native pub/sub mechanism - On notification insert, trigger sends NOTIFY - All backend instances LISTEN on the channel - Each instance forwards to its local SSE connections ```sql -- Trigger function CREATE FUNCTION notify_new_notification() RETURNS trigger AS $$ BEGIN PERFORM pg_notify('new_notification', row_to_json(NEW)::text); RETURN NEW; END; $$ LANGUAGE plpgsql; -- Trigger on insert CREATE TRIGGER notification_insert_trigger AFTER INSERT ON notifications FOR EACH ROW EXECUTE FUNCTION notify_new_notification(); ``` | Pros | Cons | |------|------| | No new infrastructure (already have Postgres) | Slightly higher latency (~5-20ms) | | Simple conceptually | Requires manual JDBC listener setup | | Database is source of truth | Less common pattern, fewer examples | | Works even if notification created by SQL directly | Connection pool considerations | **Implementation effort:** ~3-5 hours **No additional dependencies** (uses existing JDBC driver) --- ### Option 3: Message Queue (RabbitMQ / Kafka) **How it works:** - Publish notification events to queue/topic - All instances consume from the queue - Each instance forwards to local SSE connections | Pros | Cons | |------|------| | Very reliable, durable messages | Significant new infrastructure | | Can handle high throughput | Overkill for notification use case | | Good for future event-driven architecture | Higher operational complexity | | Supports complex routing | Longer setup time | **Implementation effort:** ~4-8 hours **Dependencies:** ```kotlin // RabbitMQ implementation("org.springframework.boot:spring-boot-starter-amqp") // OR Kafka implementation("org.springframework.kafka:spring-kafka") ``` --- ### Option 4: Client-Side Polling (Remove SSE) **How it works:** - Remove SSE entirely - Frontend polls `/api/notifications?since={timestamp}` every N seconds - Completely stateless, no server-side connection tracking ```typescript // Frontend polling setInterval(async () => { const notifications = await fetch(`/api/notifications?since=${lastCheck}`) lastCheck = Date.now() // Update UI with new notifications }, 10000) // Poll every 10 seconds ``` | Pros | Cons | |------|------| | Simplest solution | Not real-time (10-30s delay) | | Truly stateless | Higher database load | | No new infrastructure | More API requests | | Easy to implement | Worse UX for time-sensitive notifications | | Works through all proxies/firewalls | | **Implementation effort:** ~1-2 hours (mostly removing SSE code) --- ### Option 5: WebSocket with External STOMP Broker **How it works:** - Use Spring WebSocket with STOMP protocol - External message broker (RabbitMQ/ActiveMQ) handles message routing - Clients subscribe to user-specific topics | Pros | Cons | |------|------| | Full-duplex communication | Requires message broker | | Rich protocol (STOMP) | More complex than SSE | | Spring has excellent support | WebSocket connection management | | Can do request-response patterns | Some proxies don't support WebSocket well | **Implementation effort:** ~4-6 hours --- ## Comparison Matrix | Criteria | Redis Pub/Sub | Postgres NOTIFY | Message Queue | Polling | WebSocket+STOMP | |----------|---------------|-----------------|---------------|---------|-----------------| | Latency | ~1-5ms | ~5-20ms | ~5-50ms | 10-30s | ~1-5ms | | New Infrastructure | Redis | None | RabbitMQ/Kafka | None | RabbitMQ/ActiveMQ | | Implementation Effort | Low | Medium | High | Very Low | Medium | | Operational Complexity | Low | Low | High | None | Medium | | Scalability | Excellent | Good | Excellent | Limited | Excellent | | Spring Support | Excellent | Manual | Excellent | N/A | Excellent | --- ## Recommendation **For this project, the top candidates are:** 1. **PostgreSQL LISTEN/NOTIFY** - Already have Postgres, no new infra, acceptable latency 2. **Redis Pub/Sub** - If Redis is added for caching anyway, use it for this too 3. **Polling** - If real-time isn't critical, simplest solution **Avoid:** Message Queue (overkill), WebSocket+STOMP (unnecessary complexity) --- ## Why Postponed 1. **Current state works for single instance** - Development and testing can proceed 2. **No immediate production multi-instance deployment** - Decision can wait 3. **Infrastructure decisions pending** - Unknown if Redis will be used for other purposes 4. **Need to evaluate real-time requirements** - How critical is sub-second notification delivery? --- ## Questions to Answer Before Deciding 1. Will Redis be used for caching or session storage? 2. How critical is real-time delivery? (Is 10-30s polling acceptable?) 3. What is the expected notification volume? 4. What is the deployment target? (Kubernetes, Docker Swarm, VMs?) --- ## How to Resume This Work When ready to implement multi-instance support: 1. Answer the questions above 2. Choose an option based on infrastructure and requirements 3. Implementation steps: - Keep `SseNotificationEmitterRegistry` for local emitter management - Add cross-instance event distribution layer - Modify `NotificationEventListener` to publish to chosen channel - Add subscriber that receives events and calls registry's send methods **Key insight:** The local SSE connection management stays the same. Only the event distribution mechanism changes. --- ## Related Files - `SseNotificationEmitterRegistry.kt` - Current in-memory implementation - `NotificationEventListener.kt` - Event handling logic - `NotificationCreatedEvent.kt` - Event payload - `NotificationService.kt` - Notification creation - `NotificationController.kt` - SSE endpoint --- ## References - [Spring SSE Documentation](https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-ann-async.html#mvc-ann-async-sse) - [Redis Pub/Sub with Spring](https://docs.spring.io/spring-data/redis/reference/redis/pubsub.html) - [PostgreSQL LISTEN/NOTIFY](https://www.postgresql.org/docs/current/sql-notify.html)