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โ
- Repository Pattern
- Event-Driven Communication
- Error Handling Patterns
- Frontend State Management
- IPC Communication
- Standardized Cache Configuration
- Memory Management
- Race Condition Prevention
- 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 Type | TTL | Max Size | Use Case | Stats Enabled |
---|---|---|---|---|
SITES | 10 min | 500 | Site management operations | โ |
MONITORS | 5 min | 1000 | Real-time monitoring data | โ |
SETTINGS | 30 min | 100 | Application configuration | โ |
VALIDATION | 5 min | 200 | Validation result caching | โ |
TEMPORARY | 5 min | 1000 | Short-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โ
- Consistency: Follow established patterns rather than creating new ones
- Type Safety: Use TypeScript interfaces for all data structures
- Error Handling: Always preserve original errors and provide appropriate fallbacks
- Testing: Write comprehensive tests following established patterns
- Documentation: Include TSDoc comments with examples
- Logging: Use consistent logging with correlation IDs
- Events: Emit events for significant state changes
- Validation: Validate all external inputs and IPC parameters