Files
gremiumhub/docs/real-time-notifications-architecture.md

12 KiB

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)

@Service
class SseNotificationEmitterRegistry {
    // IN-MEMORY STORAGE - THIS IS THE PROBLEM
    private val connections = CopyOnWriteArrayList<SseConnectionContext>()

    fun registerEmitter(userId: String, organizationId: String, userRoles: List<String>): 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:

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
-- 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:

// 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
// 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.


  • SseNotificationEmitterRegistry.kt - Current in-memory implementation
  • NotificationEventListener.kt - Event handling logic
  • NotificationCreatedEvent.kt - Event payload
  • NotificationService.kt - Notification creation
  • NotificationController.kt - SSE endpoint

References