118 lines
4.6 KiB
Markdown
118 lines
4.6 KiB
Markdown
# 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:
|
|
|
|
```json
|
|
{
|
|
"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:
|
|
|
|
```go
|
|
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.
|