Skip to main content

ADR-002: Event-Driven Architecture with TypedEventBus

Table of Contents​

  1. Status
  2. Context
  3. Decision
  4. Architecture Flow
  5. Event Categories
  6. Consequences
  7. Quality Assurance
  8. Implementation Requirements
  9. Compliance
  10. Related ADRs

Status​

Accepted - Core communication mechanism using TypedEventBus (main) plus a dedicated renderer event bridge over IPC.

Context​

The application needed a way to decouple components and enable reactive communication between:

  • Backend services and frontend UI
  • Different services within the backend
  • Multiple UI components reacting to state changes
  • Cross-cutting concerns like logging, monitoring, and error handling

Traditional direct method calls would create tight coupling, make the system difficult to extend and test, and provide no mechanism for monitoring or debugging inter-component communication.

Decision​

We will use an Event-Driven Architecture based on a custom TypedEventBus with the following characteristics:

Event propagation overview​

Delivery guarantees (what we provide)​

  • In-process (Electron main): best-effort, at-most-once delivery to each listener.
    • Listener errors are caught and logged, but a throwing listener can prevent later listeners from running (Node.js EventEmitter semantics).
    • Middleware errors abort emission (the event is not emitted).
  • Cross-process (main -> renderer): best-effort broadcast via BrowserWindow.webContents.send.
    • No acknowledgement / retry protocol exists at the IPC level.
    • If a window is destroyed, not yet created, or temporarily unresponsive, the event may be dropped.

This architecture is optimized for desktop UI responsiveness and local consistency (SQLite as the source of truth), not for durable distributed messaging guarantees.

1. Enhanced Type Safety​

interface UptimeEvents extends Record<string, unknown> {
"site:added": {
site: Site;
source: SiteAddedSource;
timestamp: number;
};
"monitor:status-changed": StatusUpdate;
"database:transaction-completed": {
duration: number;
operation: string;
success: boolean;
timestamp: number;
};
"system:error": {
error: Error;
context: string;
severity: "low" | "medium" | "high" | "critical";
recovery?: string;
timestamp: number;
};
}

// Usage with compile-time type checking
await eventBus.emitTyped("site:added", {
site: newSite,
source: "user",
timestamp: Date.now(),
});

2. Advanced Metadata and Correlation​

  • Unique correlation IDs for request tracing across system boundaries
  • Automatic timestamps for event ordering and debugging
  • Bus identification for multi-bus architectures
  • Event metadata enrichment for comprehensive monitoring

3. Consistent Event Naming​

  • Domain-based naming: domain:action (e.g., site:added, monitor:status-changed)
  • Hierarchical structure: Major category followed by specific action
  • Past tense verbs for completed actions

4. Production-Ready Middleware Support​

// Logging middleware with correlation tracking
eventBus.use(async (eventName, data, next) => {
const correlationId = data._meta?.correlationId;
logger.debug(`[Event] ${eventName} [${correlationId}]`, data);
await next();
logger.debug(`[Event] ${eventName} completed [${correlationId}]`);
});

// Rate limiting middleware
eventBus.use(
createRateLimitMiddleware({
maxEventsPerSecond: 100,
burstLimit: 10,
onRateLimit: ({ event, reason }) => {
logger.warn(`Rate limit exceeded for ${event} (${reason})`);
},
})
);

// Validation middleware
eventBus.use(
createValidationMiddleware({
"monitor:status-changed": (data) => validateMonitorStatusData(data),
"site:added": (data) => validateSiteData(data),
})
);

5. Memory-Safe IPC Event Forwarding​

Backend events are forwarded from the Electron main process to renderer windows via the renderer event bridge (RendererEventBridge) and exposed to the renderer via the preload events domain API (electron/preload/domains/eventsApi.ts). Renderer code subscribes through src/services/EventsService.ts and receives explicit cleanup functions.

// Backend emits event with automatic IPC forwarding
await this.eventBus.emitTyped("monitor:status-changed", eventData);

// Frontend receives with automatic cleanup functions
import { EventsService } from "@app/services/EventsService";

const registerMonitorUpdates = async () =>
await EventsService.onMonitorStatusChanged((data) => {
monitorStore.applyStatusUpdate(data);
});

// Later in component cleanup
useEffect(() => {
let cleanup: (() => void) | undefined;

void registerMonitorUpdates().then((unsubscribe) => {
cleanup = unsubscribe;
});

return () => {
cleanup?.();
};
}, []);

> **Important:** IPC event delivery is best-effort. Consumers must tolerate dropped events and be idempotent when duplicates occur.

6. Advanced Memory Management​

  • Max listeners: Configurable limit (default: 50) prevents memory leaks
  • Automatic cleanup: All event listeners provide cleanup functions
  • Middleware limits: Configurable middleware chain size (default: 20)
  • Event validation: Type-safe event structures prevent runtime errors

7. Idempotency and Ordering Requirements​

Because delivery is at-most-once in-process (and best-effort across IPC), consumers must be safe under drops, duplicates, and reordering.

  • Prefer source-of-truth reads (SQLite) over reconstructing state solely from event streams.
  • For renderer state synchronization (state:sync), use the existing revision field to ignore stale payloads and enforce monotonic ordering.
  • For UI updates driven by events, handlers must be idempotent (applying the same logical update twice yields the same result) and should fall back to refresh/read pathways when expected intermediate events are missing.

8. Validation Boundary and β€œDead-Letter” Handling​

This app does not use a traditional message-broker DLQ.

Instead, we rely on boundary validation + observable drops:

  • Preload payload guard failures are dropped and logged with diagnostics (see reportPreloadGuardFailure in electron/preload/domains/eventsApi.ts).
  • Renderer broadcast failures (webContents.send throwing) are caught and logged by electron/services/events/RendererEventBridge.ts.

Recommended future enhancement: maintain a bounded in-memory dead-letter ring buffer in the main process (optionally persisted into diagnostics bundles) with:

  • channel name
  • correlation id / metadata
  • payload preview (redacted)
  • failure reason
  • timestamp

9. Schema Evolution and Versioning​

  • Prefer additive changes (adding optional fields).
  • For breaking changes, introduce a new event channel or add an explicit version discriminant and support both versions during migration.
  • For large payload channels (notably state:sync), enforce payload size budgets and compaction (implemented in RendererEventBridge.sendStateSyncEvent).

Architecture Flow​

Event Categories​

1. Site Events​

Public Events:

  • site:added - When a site is successfully added
  • site:updated - When site properties are modified
  • site:removed - When a site is deleted
  • sites:state-synchronized - Main-process state sync event (forwarded to the renderer as state-sync-event)

Historical note: the former site:cache-updated and site:cache-miss topics were retired in favor of the internal namespace. Cache telemetry now flows exclusively through those internal channels and the orchestrator converts them into the canonical cache:invalidated broadcast when the renderer must react.

Internal Events:

  • internal:site:added - Internal site creation events
  • internal:site:updated - Internal site modification events
  • internal:site:removed - Internal site deletion events
  • internal:site:cache-updated - Internal cache management
  • internal:site:cache-miss - Internal cache lookup miss telemetry
  • internal:site:start-monitoring-requested - Internal monitoring control
  • internal:site:stop-monitoring-requested - Internal monitoring control
  • internal:site:restart-monitoring-requested - Internal monitoring control
  • internal:site:restart-monitoring-response - Internal monitoring responses
  • internal:site:is-monitoring-active-requested - Internal status queries
  • internal:site:is-monitoring-active-response - Internal status responses

Emission flow: SiteManager emits only internal:site:* topics. The UptimeOrchestrator enriches those payloads and rebroadcasts any renderer-facing site:* events, translating cache telemetry into the canonical cache:invalidated pipeline when appropriate.

2. Monitor Events​

Public Events:

  • monitor:added - When a monitor is created
  • monitor:removed - When a monitor is deleted
  • monitor:status-changed - When monitor status changes
  • monitor:up - When monitor detects service is online
  • monitor:down - When monitor detects service is offline
  • monitor:check-completed - When a health check finishes

Internal Events:

  • internal:monitor:started - Internal monitor activation
  • internal:monitor:stopped - Internal monitor deactivation
  • internal:monitor:all-started - When all monitors are activated
  • internal:monitor:all-stopped - When all monitors are deactivated
  • internal:monitor:manual-check-completed - Manual check results

Emission flow: MonitorManager raises internal:monitor:* events for lifecycle transitions and continues to emit high-frequency telemetry such as monitor:status-changed directly. The UptimeOrchestrator translates the lifecycle events into monitoring:* plus the canonical cache:invalidated broadcasts (using { type: "all" } for global transitions).

  • internal:monitor:site-setup-completed - Site monitor setup completion

3. Database Events​

  • database:transaction-completed - When database transactions finish
  • database:error - When database operations fail
  • database:success - When database operations succeed
  • database:retry - When database operations are retried
  • database:backup-created - When database backups are created

Internal Database Events:

  • internal:database:initialized - Database initialization completion
  • internal:database:data-exported - Data export completion
  • internal:database:data-imported - Data import completion
  • internal:database:backup-downloaded - Backup download completion
  • internal:database:history-limit-updated - History retention changes
  • internal:database:sites-refreshed - Site data refresh
  • internal:database:get-sites-from-cache-requested - Cache requests
  • internal:database:get-sites-from-cache-response - Cache responses
  • internal:database:update-sites-cache-requested - Cache update requests

4. System Events​

  • monitoring:started - When monitoring system starts
  • monitoring:stopped - When monitoring system stops
  • system:startup - Application startup
  • system:shutdown - Application shutdown
  • system:error - System-level errors

5. Performance and Configuration Events​

  • performance:metric - Performance measurements
  • performance:warning - Performance threshold alerts
  • config:changed - Configuration changes
  • cache:invalidated - Cache invalidation events

Event Summary Table (Public Events)​

The following table summarizes the primary public events by domain. Internal events (internal:*) are documented inline in the sections above and are omitted here for brevity.

DomainEvent nameDescription
Sitesite:addedWhen a site is successfully added.
Sitesite:updatedWhen site properties are modified.
Sitesite:removedWhen a site is deleted.
Site / State Syncsites:state-synchronizedMain-process state sync event (forwarded to renderer as state-sync-event)
Monitormonitor:addedWhen a monitor is created.
Monitormonitor:removedWhen a monitor is deleted.
Monitormonitor:status-changedWhen monitor status changes.
Monitormonitor:upWhen a monitor detects service is online.
Monitormonitor:downWhen a monitor detects service is offline.
Monitormonitor:check-completedWhen a health check finishes.
Databasedatabase:transaction-completedWhen database transactions finish.
Databasedatabase:errorWhen database operations fail.
Databasedatabase:successWhen database operations succeed.
Databasedatabase:retryWhen database operations are retried.
Databasedatabase:backup-createdWhen database backups are created.
System / Monitoringmonitoring:startedWhen the monitoring system starts.
System / Monitoringmonitoring:stoppedWhen the monitoring system stops.
Systemsystem:startupApplication startup.
Systemsystem:shutdownApplication shutdown.
Systemsystem:errorSystem-level errors.
Performanceperformance:metricPerformance measurements.
Performanceperformance:warningPerformance threshold alerts.
Configurationconfig:changedConfiguration changes.
Cachecache:invalidatedCache invalidation events.

Event catalog note: The table above intentionally focuses on the externally relevant public events. The authoritative event payload types live in shared/types/events.ts and the Electron event bus definitions. Long term, a generated event catalog (similar to docs/Architecture/generated/IPC_CHANNEL_INVENTORY.md for IPC channels) may be introduced to keep this table and the code-level contracts perfectly synchronized.

Consequences​

Positive​

  • Decoupled architecture - Components don't need direct references
  • Enhanced type safety - Compile-time checking prevents runtime errors
  • Extensibility - Easy to add new event listeners without modifying emitters
  • Advanced debugging - Correlation IDs and metadata enable comprehensive request tracing
  • Superior testability - Easy to mock and verify event emissions
  • Memory safety - Automatic cleanup and configurable limits prevent leaks
  • Production monitoring - Middleware enables comprehensive observability
  • Cross-cutting concerns - Logging, validation, and rate limiting handled declaratively

Negative​

  • Initial complexity - Indirect flow can be harder to follow initially
  • Minimal performance overhead - Event processing adds negligible latency
  • Learning curve - Developers need to understand event-driven patterns
  • Debugging complexity - Async event flows require correlation tracking
  • Delivery semantics - At-most-once / best-effort delivery requires consumers to tolerate drops/duplicates/reordering
  • No durable DLQ - Failures are observable and diagnosable, but not automatically retried

Quality Assurance​

Memory Management​

  • Automatic cleanup: All event listeners return cleanup functions
  • Configurable limits: Max listeners and middleware prevent resource exhaustion
  • Leak prevention: Proper cleanup in component unmount lifecycle

Error Handling​

  • Middleware semantics: Middleware errors abort emission. Middleware must be conservative and avoid throwing on non-fatal conditions.
  • Event validation: Type-safe structures prevent runtime errors
  • Listener robustness: Listener errors are caught/logged, but listeners should still catch locally to avoid preventing later listeners from running.

Performance​

  • Rate limiting: Middleware prevents event flooding
  • Efficient forwarding: IPC events use optimized serialization
  • Minimal overhead: Event processing designed for production use

Implementation Requirements​

Event Emission​

// In services/managers
await this.eventBus.emitTyped("domain:action", {
// Event-specific data
timestamp: Date.now(),
// ... other properties
});

Event Listening​

// Type-safe event listening
eventBus.onTyped("domain:action", (data) => {
// data is properly typed
// _meta is automatically available
});

IPC Integration​

import { RENDERER_EVENT_CHANNELS } from "@shared/ipc/rendererEvents";
import type { RendererEventPayload } from "@shared/ipc/rendererEvents";
import type { RendererEventBridge } from "@electron/services/events/RendererEventBridge";

declare const rendererEventBridge: RendererEventBridge;
declare const payload: RendererEventPayload<
typeof RENDERER_EVENT_CHANNELS.TEST_EVENT
>;

// Best-effort broadcast from Electron main to all renderer windows
rendererEventBridge.sendToRenderers(
RENDERER_EVENT_CHANNELS.TEST_EVENT,
payload
);

Compliance​

All communication follows this pattern:

  • Service layer emits domain events
  • UI components listen to events via IPC
  • Database operations emit lifecycle events
  • Error handling emits failure events

Current Implementation Audit (2025-11-04)​

  • Inspected electron/events/TypedEventBus.ts to confirm middleware, correlation metadata, and type-safe emit/on helpers remain the single event backbone.
  • Verified renderer broadcasts are centralized in electron/services/events/RendererEventBridge.ts (window iteration, error handling, and size budgeting for state:sync).
  • Checked electron/preload/domains/eventsApi.ts and src/services/events/EventsService.ts to ensure renderer subscriptions still traverse the preload bridge with validated cleanup handlers.