Skip to main content

Development Patterns Guide

This guide documents the established architectural patterns used throughout the Uptime-Watcher application. Following these patterns ensures consistency, maintainability, predictable behavior, and production-grade quality across the codebase.

Table of Contentsโ€‹

  1. Repository Pattern
  2. Event-Driven Communication
  3. Error Handling Patterns
  4. Frontend State Management
  5. IPC Communication
  6. Standardized Cache Configuration
  7. Memory Management
  8. Race Condition Prevention
  9. Testing Patterns

Repository Patternโ€‹

Repository Pattern Overviewโ€‹

All database access uses the Repository Pattern with comprehensive transaction handling, race condition prevention, and production-grade reliability.

Key Characteristicsโ€‹

  • Dual methods: Public async methods create transactions, internal sync methods work within existing transactions
  • Enhanced transaction safety: All mutations wrapped in executeTransaction() with automatic rollback
  • Advanced operational hooks: Use withDatabaseOperation() for retry logic, event emission, and monitoring
  • Dependency injection: Constructor-based dependency injection for testability
  • Race condition immunity: Synchronous database operations (node-sqlite3-wasm) eliminate race conditions
  • Memory management: Proper resource cleanup and connection management

Repository Implementation Templateโ€‹

export interface ExampleRepositoryDependencies {
databaseService: DatabaseService;
}

const EXAMPLE_QUERIES = {
SELECT_ALL: "SELECT * FROM example_table",
INSERT: "INSERT INTO example_table (field) VALUES (?)",
DELETE_ALL: "DELETE FROM example_table",
} as const;

export class ExampleRepository {
private readonly databaseService: DatabaseService;

constructor(dependencies: ExampleRepositoryDependencies) {
this.databaseService = dependencies.databaseService;
}

// Public async method with transaction
public async create(data: ExampleData): Promise<Example> {
return withDatabaseOperation(async () => {
return this.databaseService.executeTransaction((db) => {
this.createInternal(db, data);
return Promise.resolve(createdExample);
});
}, "ExampleRepository.create");
}

// Internal sync method for transaction contexts
public createInternal(db: Database, data: ExampleData): void {
db.run(EXAMPLE_QUERIES.INSERT, [data.field]);
logger.debug("[ExampleRepository] Example created (internal)");
}

// Read operations (no transaction needed)
public async findAll(): Promise<Example[]> {
const db = this.getDb();
return db.all(EXAMPLE_QUERIES.SELECT_ALL);
}

private getDb(): Database {
return this.databaseService.getDatabase();
}
}

Repository Usage Guidelinesโ€‹

  • โœ… Use dual methods for operations that might be called within transactions
  • โœ… Wrap all mutations in withDatabaseOperation()
  • โœ… Include descriptive operation names for debugging
  • โœ… Use query constants to prevent SQL duplication
  • โŒ Don't bypass the repository pattern for direct database access
  • โŒ Don't create transactions within internal methods

Event-Driven Communicationโ€‹

Event-Driven Communication Overviewโ€‹

The application uses a TypedEventBus for decoupled communication between components with compile-time type safety.

Event Naming Conventionโ€‹

  • Format: domain:action (e.g., sites:added, monitor:status-changed)
  • Domain: Major category (sites, monitors, database, system)
  • Action: Specific action in past tense for completed events
  • Consistency: Use kebab-case for multi-word actions

Event-Driven Implementation Templateโ€‹

// 1. Define event interfaces
interface DomainEvents extends Record<string, unknown> {
"domain:action-completed": {
entityId: string;
timestamp: number;
// ... other event-specific data
};
"domain:action-failed": {
entityId: string;
error: string;
timestamp: number;
};
}

// 2. Emit events in services
export class ExampleService {
constructor(private eventBus: TypedEventBus<UptimeEvents>) {}

async performAction(id: string): Promise<void> {
try {
// Perform operation
const result = await this.doSomething(id);

// Emit success event
await this.eventBus.emitTyped("domain:action-completed", {
entityId: id,
result,
timestamp: Date.now(),
});
} catch (error) {
// Emit failure event
await this.eventBus.emitTyped("domain:action-failed", {
entityId: id,
error: error instanceof Error ? error.message : "Unknown error",
timestamp: Date.now(),
});
throw error;
}
}
}

// 3. Listen to events
eventBus.onTyped("domain:action-completed", (data) => {
// data is properly typed and includes _meta
console.log(`Action completed for ${data.entityId} at ${data.timestamp}`);
console.log(`Correlation ID: ${data._meta.correlationId}`);
});

Event-Driven Usage Guidelinesโ€‹

  • โœ… Use typed events with proper interfaces
  • โœ… Include timestamps in all events
  • โœ… Emit both success and failure events for operations
  • โœ… Use correlation IDs for request tracing
  • โŒ Don't emit events for every minor operation
  • โŒ Don't include sensitive data in event payloads

Error Handling Patternsโ€‹

Error Handling Overviewโ€‹

Multi-layered error handling ensures system stability and provides consistent error reporting across all application layers.

Shared Error Handling Utilitiesโ€‹

Frontend with Store Integrationโ€‹

// For operations that need store state management
const handleUserAction = async () => {
await withErrorHandling(async () => {
const result = await window.electronAPI.sites.addSite(siteData);
// Handle success
return result;
}, errorStore); // Automatically manages loading/error state
};

Backend with Logger Integrationโ€‹

// For backend operations that need logging
const performBackendOperation = async () => {
return withErrorHandling(
async () => {
const result = await this.repository.performOperation();
return result;
},
{ logger, operationName: "performOperation" }
);
};

Database Operationsโ€‹

// For database operations (includes retry logic)
public async databaseOperation(): Promise<Result> {
return withDatabaseOperation(async () => {
return this.databaseService.executeTransaction((db) => {
// Database operations
return result;
});
}, "Service.databaseOperation");
}

Utility Functionsโ€‹

// For utility functions with fallback values
export async function utilityFunction(): Promise<string> {
return withUtilityErrorHandling(
async () => {
// Perform operation
return result;
},
"utilityFunction",
"fallback-value"
);
}

Error Preservation Patternโ€‹

// โœ… Good - preserves original error
try {
return await operation();
} catch (error) {
logger.error("Operation failed", error);
// Emit failure event
await eventBus.emitTyped("operation:failed", {
error: error instanceof Error ? error.message : String(error),
});
throw error; // Re-throw original
}

// โŒ Bad - loses error context
try {
return await operation();
} catch (error) {
throw new Error("Operation failed"); // Original error lost
}

Error Handling Usage Guidelinesโ€‹

  • โœ… Always re-throw errors after handling (logging/state management)
  • โœ… Use appropriate error handling utility for the context
  • โœ… Include correlation IDs for request tracing
  • โœ… Emit failure events for monitoring
  • โŒ Don't swallow errors without proper handling
  • โŒ Don't lose original error context and stack traces

Frontend State Managementโ€‹

State Management Overviewโ€‹

Zustand-based state management with modular composition for complex stores and type safety throughout.

Simple Store Patternโ€‹

interface SimpleStore {
// State
value: string;
isLoading: boolean;

// Actions
setValue: (value: string) => void;
setLoading: (loading: boolean) => void;
performAsyncAction: () => Promise<void>;
}

export const useSimpleStore = create<SimpleStore>()((set, get) => ({
// Initial state
value: "",
isLoading: false,

// Actions
setValue: (value: string) => {
logStoreAction("SimpleStore", "setValue", { value });
set({ value });
},

setLoading: (isLoading: boolean) => {
logStoreAction("SimpleStore", "setLoading", { isLoading });
set({ isLoading });
},

performAsyncAction: async () => {
await withErrorHandling(async () => {
get().setLoading(true);
// Perform async operation
const result = await window.electronAPI.someOperation();
get().setValue(result);
}, errorStore);
get().setLoading(false);
},
}));

Complex Store with Modulesโ€‹

// 1. Define module interfaces
interface StateModule {
sites: Site[];
selectedSiteId?: string;
setSites: (sites: Site[]) => void;
setSelectedSite: (site: Site | undefined) => void;
}

interface OperationsModule {
createSite: (data: SiteCreationData) => Promise<Site>;
deleteSite: (id: string) => Promise<void>;
}

// 2. Create module implementations
export function createStateModule(
set: SetFunction,
get: GetFunction
): StateModule {
return {
sites: [],
selectedSiteId: undefined,

setSites: (sites: Site[]) => {
logStoreAction("SitesStore", "setSites", { count: sites.length });
set({ sites });
},

setSelectedSite: (site: Site | undefined) => {
logStoreAction("SitesStore", "setSelectedSite", {
siteId: site?.identifier,
});
set({ selectedSiteId: site?.identifier });
},
};
}

// 3. Compose in main store
export interface SitesStore extends StateModule, OperationsModule {}

export const useSitesStore = create<SitesStore>()((set, get) => {
const stateModule = createStateModule(set, get);
const operationsModule = createOperationsModule(set, get);

return {
...stateModule,
...operationsModule,
};
});

Persistence Patternโ€‹

export const useUIStore = create<UIStore>()(
persist(
(set) => ({
// Store implementation
}),
{
name: "uptime-watcher-ui",
partialize: (state) => ({
// Only persist user preferences
theme: state.theme,
showAdvancedMetrics: state.showAdvancedMetrics,
// Exclude transient state
// showModal: false,
// isLoading: false,
}),
}
)
);

State Management Usage Guidelinesโ€‹

  • โœ… Use modular composition for complex stores
  • โœ… Include action logging for debugging
  • โœ… Integrate with error handling utilities
  • โœ… Use selective persistence for user preferences
  • โŒ Don't mutate state directly
  • โŒ Don't persist transient UI state

IPC Communicationโ€‹

IPC Communication Overviewโ€‹

Standardized IPC protocol using contextBridge with type safety, validation, and consistent error handling.

Handler Registration Patternโ€‹

// 1. Define validation functions
export function isExampleParams(data: unknown): data is ExampleParams {
return typeof data === 'object' &&
data !== null &&
'field' in data &&
typeof (data as any).field === 'string';
}

// 2. Register handlers by domain
private registerExampleHandlers(deps: IpcServiceDependencies): void {
this.registerStandardizedIpcHandler(
'example:create',
async (params: ExampleParams) => {
const result = await deps.exampleManager.create(params);
return result;
},
isExampleParams
);

this.registerStandardizedIpcHandler(
'example:get-all',
async () => {
const results = await deps.exampleManager.getAll();
return results;
}
// No validation needed for parameterless operations
);
}

Preload API Patternโ€‹

// preload.ts
const electronAPI = {
example: {
create: (params: ExampleParams): Promise<Example> =>
ipcRenderer.invoke("example:create", params),
getAll: (): Promise<Example[]> => ipcRenderer.invoke("example:get-all"),
},
events: {
onExampleEvent: (callback: (data: ExampleEventData) => void) => {
const wrappedCallback = (_event: any, data: ExampleEventData) =>
callback(data);
ipcRenderer.on("example:event", wrappedCallback);
return () => ipcRenderer.off("example:event", wrappedCallback);
},
},
} as const;

contextBridge.exposeInMainWorld("electronAPI", electronAPI);

// types.d.ts
declare global {
interface Window {
electronAPI: typeof electronAPI;
}
}

IPC Communication Usage Guidelinesโ€‹

  • โœ… Use domain-specific grouping for handlers
  • โœ… Include validation for all parameterized operations
  • โœ… Return cleanup functions for event listeners
  • โœ… Use consistent channel naming (domain:action)
  • โŒ Don't expose Node.js APIs directly to renderer
  • โŒ Don't bypass validation for any parameters

Testing Patternsโ€‹

Store Testingโ€‹

describe("useExampleStore", () => {
beforeEach(() => {
// Reset store state
const store = useExampleStore.getState();
act(() => {
store.reset();
});
vi.clearAllMocks();
});

it("should update state correctly", () => {
const { result } = renderHook(() => useExampleStore());

act(() => {
result.current.setValue("test");
});

expect(result.current.value).toBe("test");
});

it("should handle async operations", async () => {
const { result } = renderHook(() => useExampleStore());

await act(async () => {
await result.current.performAsyncAction();
});

expect(mockElectronAPI.example.getAll).toHaveBeenCalled();
});
});

Repository Testingโ€‹

describe("ExampleRepository", () => {
let repository: ExampleRepository;
let mockDatabaseService: any;
let mockDatabase: any;

beforeEach(() => {
mockDatabase = {
run: vi.fn().mockReturnValue({ changes: 1 }),
all: vi.fn().mockReturnValue([]),
get: vi.fn().mockReturnValue(undefined),
};

mockDatabaseService = {
getDatabase: vi.fn().mockReturnValue(mockDatabase),
executeTransaction: vi.fn().mockImplementation(async (callback) => {
return callback(mockDatabase);
}),
};

repository = new ExampleRepository({
databaseService: mockDatabaseService,
});
});

it("should create example successfully", async () => {
await repository.create(mockData);

expect(mockDatabaseService.executeTransaction).toHaveBeenCalled();
expect(mockDatabase.run).toHaveBeenCalledWith(
expect.stringContaining("INSERT"),
expect.any(Array)
);
});
});

Testing Usage Guidelinesโ€‹

  • โœ… Reset state in beforeEach for store tests
  • โœ… Use act() for state updates in React tests
  • โœ… Mock dependencies consistently
  • โœ… Test both success and error paths
  • โŒ Don't test implementation details
  • โŒ Don't share state between test cases

Standardized Cache Configurationโ€‹

Cache Configuration Overviewโ€‹

All caching in the application uses standardized configurations to ensure consistent performance, predictable behavior, and maintainable cache management across all managers and services.

Cache Configuration Key Characteristicsโ€‹

  • Centralized configuration: All cache settings defined in shared/constants/cacheConfig.ts
  • Domain-specific configs: Separate configurations for SITES, MONITORS, SETTINGS, VALIDATION, and TEMPORARY caches
  • Consistent TTL values: Standardized expiration times based on data freshness requirements
  • Standardized naming: Consistent cache naming patterns with helper functions
  • Type safety: Full TypeScript support with proper interfaces

Configuration Structureโ€‹

import { CACHE_CONFIG, CACHE_NAMES } from "@shared/constants/cacheConfig";

// Standard cache creation
const sitesCache = new StandardizedCache<Site>({
...CACHE_CONFIG.SITES,
eventEmitter: this.eventEmitter,
});

// Temporary cache with custom naming
const tempCache = new StandardizedCache<Site>({
...CACHE_CONFIG.TEMPORARY,
name: CACHE_NAMES.temporary("import"),
eventEmitter: this.eventEmitter,
});

Cache Type Guidelinesโ€‹

Cache TypeTTLMax SizeUse CaseStats Enabled
SITES10 min500Site management operationsโœ“
MONITORS5 min1000Real-time monitoring dataโœ“
SETTINGS30 min100Application configurationโœ“
VALIDATION5 min200Validation result cachingโœ“
TEMPORARY5 min1000Short-term operationsโœ—

Cache Configuration Implementation Templateโ€‹

import { StandardizedCache } from "@electron/utils/cache/StandardizedCache";
import { CACHE_CONFIG, CACHE_NAMES } from "@shared/constants/cacheConfig";

export class ExampleManager {
private readonly dataCache: StandardizedCache<DataType>;

constructor(dependencies: Dependencies) {
// Standard domain cache
this.dataCache = new StandardizedCache<DataType>({
...CACHE_CONFIG.SITES, // Choose appropriate config
eventEmitter: dependencies.eventEmitter,
});
}

private createTemporaryCache(
operation: string
): StandardizedCache<DataType> {
return new StandardizedCache<DataType>({
...CACHE_CONFIG.TEMPORARY,
name: CACHE_NAMES.temporary(operation),
eventEmitter: this.eventEmitter,
});
}
}

Cache Configuration Usage Guidelinesโ€‹

  • โœ… Always use CACHE_CONFIG constants instead of hardcoded values
  • โœ… Use CACHE_NAMES functions for consistent naming
  • โœ… Choose the appropriate cache type based on data characteristics
  • โœ… Include eventEmitter for cache event integration
  • โœ… Use temporary caches for short-lived operations
  • โŒ Don't hardcode TTL values or cache sizes
  • โŒ Don't create caches without following naming conventions
  • โŒ Don't bypass standardized configurations for production code

Cache Selection Guideโ€‹

Use SITES config when:

  • Caching site-related data that changes moderately
  • Need balance between freshness and performance
  • Expected volume is moderate (< 500 items)

Use MONITORS config when:

  • Caching real-time monitoring data
  • Need shorter expiration for accuracy
  • Expected high volume (< 1000 items)

Use SETTINGS config when:

  • Caching configuration values
  • Data changes infrequently
  • Small dataset size (< 100 items)

Use VALIDATION config when:

  • Caching validation results
  • Need to balance accuracy with performance
  • Moderate dataset size (< 200 items)

Use TEMPORARY config when:

  • Short-lived operations (import, export, sync)
  • Performance is critical (stats disabled)
  • Large buffer needed (< 1000 items)

Memory Managementโ€‹

Memory Management Overviewโ€‹

Comprehensive memory management patterns to prevent leaks and ensure optimal performance in long-running Electron applications.

Event Listener Cleanup Patternโ€‹

// Frontend component cleanup
useEffect(() => {
const cleanup = window.electronAPI.events.onMonitorStatusChanged((data) => {
handleStatusChange(data);
});

// Cleanup function prevents memory leaks
return cleanup;
}, []);

// Service event listener management
class StatusUpdateManager {
private cleanupFunctions: Array<() => void> = [];

public subscribe(): void {
// Clean up existing subscriptions first
this.unsubscribe();

const cleanup1 =
window.electronAPI.events.onMonitorStatusChanged(handler);
const cleanup2 = window.electronAPI.events.onMonitoringStarted(handler);

this.cleanupFunctions.push(cleanup1, cleanup2);
}

public unsubscribe(): void {
for (const cleanup of this.cleanupFunctions) {
cleanup();
}
this.cleanupFunctions = [];
}
}

Resource Disposal Patternโ€‹

// Timeout management with cleanup
const timeouts = new Map<string, NodeJS.Timeout>();

function scheduleTimeout(
id: string,
callback: () => void,
delay: number
): void {
// Clear existing timeout if any
clearManagedTimeout(id);

const timeout = setTimeout(() => {
callback();
timeouts.delete(id);
}, delay);

timeouts.set(id, timeout);
}

function clearManagedTimeout(id: string): void {
const timeout = timeouts.get(id);
if (timeout) {
clearTimeout(timeout);
timeouts.delete(id);
}
}

// Service cleanup on shutdown
class ServiceManager {
async shutdown(): Promise<void> {
// Clear all managed timeouts
for (const [id] of timeouts) {
clearManagedTimeout(id);
}

// Close database connections
this.databaseService.close();

// Cleanup caches
this.cache.clear();
}
}

Cache Management Patternโ€‹

// Standardized cache with proper cleanup
class StandardizedCache<T> {
private readonly invalidationCallbacks = new Set<(key?: string) => void>();

public onInvalidation(callback: (key?: string) => void): () => void {
this.invalidationCallbacks.add(callback);

// Return cleanup function
return () => {
this.invalidationCallbacks.delete(callback);
};
}

public clear(): void {
this.cache.clear();

// Notify listeners with error isolation
for (const callback of this.invalidationCallbacks) {
try {
callback();
} catch (error) {
logger.warn("Cache invalidation callback failed", error);
}
}
}
}

Memory Management Usage Guidelinesโ€‹

  • โœ… Always provide cleanup functions for event listeners
  • โœ… Clear timeouts and intervals on component unmount
  • โœ… Implement proper service shutdown procedures
  • โœ… Use Map/Set for complex cleanup tracking
  • โŒ Don't rely on garbage collection for critical resources
  • โŒ Don't forget to call cleanup functions in error paths

Race Condition Preventionโ€‹

Race Condition Prevention Overviewโ€‹

Comprehensive patterns to prevent race conditions in async operations, particularly in monitoring and database operations.

Operation Correlation Patternโ€‹

// Enhanced monitoring with operation tracking
class MonitorOperationRegistry {
private activeOperations = new Map<string, MonitorCheckOperation>();

initiateCheck(monitorId: string): string {
// Generate unique operation ID with collision detection
let operationId: string;
let attempts = 0;
do {
operationId = crypto.randomUUID();
attempts++;
} while (this.activeOperations.has(operationId) && attempts < 5);

if (this.activeOperations.has(operationId)) {
throw new Error("Failed to generate unique operation ID");
}

const operation = {
id: operationId,
monitorId,
initiatedAt: new Date(),
cancelled: false,
};

this.activeOperations.set(operationId, operation);
return operationId;
}

validateOperation(operationId: string): boolean {
const operation = this.activeOperations.get(operationId);
return operation !== undefined && !operation.cancelled;
}

completeOperation(operationId: string): void {
this.activeOperations.delete(operationId);
}
}

// Usage in monitor checks
async function performMonitorCheck(monitorId: string): Promise<void> {
const operationId = operationRegistry.initiateCheck(monitorId);

try {
const result = await doActualCheck();

// Validate operation still active before updating state
if (operationRegistry.validateOperation(operationId)) {
await updateMonitorStatus(monitorId, result);
} else {
logger.debug(
`Operation ${operationId} cancelled, skipping status update`
);
}
} finally {
operationRegistry.completeOperation(operationId);
}
}

Atomic State Updates Patternโ€‹

// Database cache with atomic replacement
class DatabaseManager {
private async loadSites(): Promise<void> {
// Load into temporary cache first
const tempCache = new Map<string, Site>();
const sites = await this.siteRepository.getAll();

for (const site of sites) {
tempCache.set(site.identifier, site);
}

// Atomically replace the main cache (prevents race conditions)
this.siteCache.clear();
for (const [key, site] of tempCache.entries()) {
this.siteCache.set(key, site);
}
}
}

// State updates with validation
class StatusUpdateHandler {
async handleStatusUpdate(update: StatusUpdate): Promise<void> {
const currentSites = this.getSites();
const updatedSites = [...currentSites];

// Find and validate site exists
const siteIndex = updatedSites.findIndex(
(s) => s.identifier === update.siteIdentifier
);
if (siteIndex === -1) {
logger.warn(
`Status update for unknown site: ${update.siteIdentifier}`
);
return;
}

// Atomic update with validation
const updatedSite = this.applyStatusUpdate(
updatedSites[siteIndex],
update
);
updatedSites[siteIndex] = updatedSite;

// Apply all changes atomically
this.setSites(updatedSites);
}
}

Concurrency Control Patternโ€‹

// Operation queuing for critical sections
class OperationQueue {
private readonly queue: Array<() => Promise<void>> = [];
private isProcessing = false;

async enqueue<T>(operation: () => Promise<T>): Promise<T> {
return new Promise((resolve, reject) => {
this.queue.push(async () => {
try {
const result = await operation();
resolve(result);
} catch (error) {
reject(error);
}
});

void this.processQueue();
});
}

private async processQueue(): Promise<void> {
if (this.isProcessing || this.queue.length === 0) {
return;
}

this.isProcessing = true;

while (this.queue.length > 0) {
const operation = this.queue.shift()!;
try {
await operation();
} catch (error) {
logger.error("Queued operation failed", error);
}
}

this.isProcessing = false;
}
}

Race Condition Prevention Usage Guidelinesโ€‹

  • โœ… Use operation correlation for async operations that can be cancelled
  • โœ… Implement atomic state updates for cache management
  • โœ… Validate operation state before applying changes
  • โœ… Use operation queues for critical sections
  • โŒ Don't rely on timing for synchronization
  • โŒ Don't ignore cancellation flags in long-running operations

Best Practices Summaryโ€‹

  1. Consistency: Follow established patterns rather than creating new ones
  2. Type Safety: Use TypeScript interfaces for all data structures
  3. Error Handling: Always preserve original errors and provide appropriate fallbacks
  4. Testing: Write comprehensive tests following established patterns
  5. Documentation: Include TSDoc comments with examples
  6. Logging: Use consistent logging with correlation IDs
  7. Events: Emit events for significant state changes
  8. Validation: Validate all external inputs and IPC parameters