Files
llink/go/docs/realtime-architecture.md
2026-02-21 08:48:34 -08:00

4.6 KiB

Real-Time Architecture

Overview

The system layers real-time delivery on top of the REST API without coupling domain logic to UI concerns. The REST API remains the source of truth; real-time events are hints that trigger client-side cache invalidation or refetches.

Architecture

┌─────────────────┐
│  Service Layer  │  Domain logic, publishes events
└────────┬────────┘
         │ publishes
         ▼
┌─────────────────┐
│   Event Bus     │  In-process or external queue
└────────┬────────┘
         │ subscribes
         ▼
┌─────────────────┐
│ RealtimeService │  Routes events to connected clients
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│ Pusher/Ably     │  Delivery to clients
└─────────────────┘

Key principle: Services emit what happened in the domain. Consumers decide what to do with it.

Why decouple? Domain events have multiple consumers beyond real-time delivery—analytics, audit logs, webhooks, background workers. The service layer doesn't know or care who's listening.

Naming: We call it RealtimeService (not "NotificationService") because it handles real-time message delivery to connected clients, not human-facing notifications like alerts or emails.

Why not broadcast from handlers? If handlers called Pusher directly after calling domain services, every handler that mutates state must remember to broadcast. This leads to duplication, inconsistency, and missed broadcasts when mutations happen outside handlers (background jobs, CLI). Events from the service layer ensure broadcasts happen automatically for any code path.

Channel Strategy

Channels map to visibility boundaries:

Channel Audience Use Case
network:{id} All network members Particles with network_all visibility
particle:{id} Particle members Particles with custom visibility
human:{email} Single human Private state (seen counts, mentions)

Event Payloads

Keep websocket messages minimal—just enough to identify what changed:

{
  "type": "particle.created",
  "particle_id": "particle_abc",
  "parent_id": "stream_xyz",
  "network_id": "network_123"
}

The client decides relevance ("Am I viewing this stream?") and fetches full data via REST if needed. This avoids payload duplication and staleness.

Client Behavior

On receiving an event:

  1. Viewing affected context → Fetch the new/updated particle, merge into local state
  2. Not viewing → Update counts (e.g., increment unseen), no fetch needed

This is simpler than full client-side sync and keeps REST as the authority.

Event Types

Event Trigger Broadcast To
particle.created Particle created Network or particle members
particle.updated Data changed Network or particle members
particle.deleted Particle removed Network or particle members
particle.acked Human acknowledged Network or particle members
stream.opened Stream reopened Network or particle members
stream.closed Stream closed Network or particle members

Note: particle.seen is private—no broadcast needed, client updates local state on API success.

Decoupling Benefits

  • Service layer stays focused on domain logic, testable without Pusher mocks
  • Realtime service owns routing policy, testable in isolation
  • Adding events doesn't require core domain changes if the action already exists
  • Additional listeners (analytics, audit, webhooks) attach without modifying services

Adding New Real-Time Experiences

When UI needs a new real-time message:

  1. Check if the domain event already exists
  2. If yes → update realtime routing only
  3. If no → add the domain event (often reveals a missing abstraction)

The UI informs what events matter, but implementation remains decoupled.

Migration Path

Start in-process with a simple event bus:

func (s *serviceImpl) Create(...) (*Particle, error) {
    created, err := s.repo.create(ctx, p)
    if err != nil {
        return nil, err
    }
    s.events.Publish(ctx, ParticleCreated{Particle: created})
    return created, nil
}

The realtime service subscribes to ParticleCreated events. Other consumers (analytics, audit) subscribe independently. Later, swap the in-process event bus for a durable queue—subscribers don't change.