Files
CherryHQ-cherry-studio/docs/references/lifecycle/lifecycle-migration-guide.md
fullex 16f2e1c7a8 feat(lifecycle): add BaseService.registerInterval helper
Wraps setInterval with auto-unref, exception isolation, and cleanup
via the existing registerDisposable channel. Returns a Disposable.

Replaces 5 ad-hoc setInterval patterns (CacheService, PreferenceService,
WindowManager, ProxyManager, FileProcessingTaskService) — unifies
three pre-existing cleanup styles and fixes a missing unref() in
ProxyManager.

WindowManager: warmupGcTimer cleanup moves from onDestroy to onStop
(via registerDisposable) — acceptable for this singleton.

Skipped (YAGNI): registerTimeout, immediate/unref options,
activation-scoped variant. QuickAssistantService keeps bare
setInterval — its lifecycle is finer than activation.
2026-05-08 20:25:04 -07:00

12 KiB

Lifecycle Migration Guide

This guide walks you through converting existing infrastructure services to the lifecycle system. Services that manage resources, require ordered initialization, or need cleanup belong here. Stateless business-logic services (repositories, data-access layers) should remain as simple singletons — see Decision Guide.

Old Patterns You'll Encounter

Pattern A: Manual Singleton

// OLD — manual singleton + exported instance
class MainWindowService {
  private static instance: MainWindowService | null = null

  public static getInstance(): MainWindowService {
    if (!MainWindowService.instance) {
      MainWindowService.instance = new MainWindowService()
    }
    return MainWindowService.instance
  }

  init() { /* ... */ }
  destroy() { /* ... */ }
}

export const windowService = MainWindowService.getInstance()

Pattern B: Raw new Export

// OLD — instantiated on import, init called manually
class ThemeService {
  init() { /* ... */ }
}

export const themeService = new ThemeService()

Pattern C: Free Functions

// OLD — module-scoped state + exported function
let accelerator: string | null = null

export function registerShortcuts(mainWindow: BrowserWindow) { /* ... */ }

Step-by-Step Migration

Step 1: Extend BaseService and add decorators

Replace the class definition. Remove static instance, getInstance(), and init()/destroy() — the lifecycle system handles all of these.

// NEW
import { BaseService, Injectable, ServicePhase, DependsOn, Phase } from '@main/core/lifecycle'

@Injectable('MainWindowService')
@ServicePhase(Phase.WhenReady)          // needs Electron API → WhenReady
@DependsOn(['PreferenceService'])       // reads preferences on startup
export class MainWindowService extends BaseService {
  protected async onInit() {
    // ← what was in init() / constructor logic
  }

  protected async onStop() {
    // ← what was in destroy() / cleanup
  }
}

Choosing the right phase: See Phase Selection Guide.

Choosing error strategy:

Strategy When to use
graceful App can function without this service (default)
fail-fast App cannot function (database, core config)

Step 2: Remove singleton boilerplate

Delete all of these:

// DELETE all of the following
private static instance: MainWindowService | null = null

public static getInstance(): MainWindowService { ... }

// DELETE the exported instance
export const windowService = MainWindowService.getInstance()
// or
export const windowService = new MainWindowService()

The lifecycle container creates and manages the singleton automatically.

Step 3: Register in serviceRegistry.ts

// src/main/core/application/serviceRegistry.ts
import { MainWindowService } from '@main/services/MainWindowService'

export const services = {
  // ...existing
  MainWindowService,      // ← one line
} as const

Step 4: Replace all import sites

Find every file that imports the old singleton and update:

// OLD
import { windowService } from '@main/services/MainWindowService'
windowService.createMainWindow()

// NEW
import { application } from '@application'
const windowService = application.get('MainWindowService')
windowService.createMainWindow()

Conditional services: If the migrated service uses @Conditional, replace application.get() calls at import sites with application.getOptional():

const menuService = application.getOptional('AppMenuService')
menuService?.buildMenu()

Step 5: Replace dependencies with @DependsOn

If the old service imported other service singletons at the top level, convert those to @DependsOn and access them via application.get() inside methods:

// OLD — tight coupling via top-level import
import { windowService } from './MainWindowService'

class TrayService {
  init() {
    windowService.show()
  }
}

// NEW — loose coupling via lifecycle
@Injectable('TrayService')
@DependsOn(['MainWindowService'])
export class TrayService extends BaseService {
  protected async onInit() {
    const windowService = application.get('MainWindowService')
    windowService.show()
  }
}

Step 6: Remove manual init/destroy calls from index.ts

After migration, delete the manual calls in src/main/index.ts:

// DELETE from index.ts
themeService.init()
windowService.createMainWindow()
new TrayService()
nodeTraceService.init()
analyticsService.init()

The lifecycle system calls onInit() automatically in the correct order during application.bootstrap().

Step 7: Migrate free functions to a service class

For Pattern C (free functions with module state), wrap them in a service:

// OLD
let accelerator: string | null = null
export function registerShortcuts(mainWindow: BrowserWindow) { ... }

// NEW
@Injectable('ShortcutService')
@ServicePhase(Phase.WhenReady)
@DependsOn(['MainWindowService', 'PreferenceService'])
export class ShortcutService extends BaseService {
  private accelerator: string | null = null

  protected async onInit() {
    this.registerShortcuts()
  }

  protected async onStop() {
    globalShortcut.unregisterAll()
  }

  private registerShortcuts() { /* ... */ }
}

Step 8: Migrate IPC handlers to BaseService tracking

If your service registers ipcMain.handle() or ipcMain.on() calls, replace them with this.ipcHandle() / this.ipcOn() and remove the manual unregister method:

// OLD — channel appears twice (register + unregister)
private registerIpcHandlers(): void {
  ipcMain.handle(IpcChannel.MyService_Action, (_, arg) => this.doAction(arg))
}
private unregisterIpcHandlers(): void {
  ipcMain.removeHandler(IpcChannel.MyService_Action)
}

// NEW — auto-tracked, cleanup is automatic
private registerIpcHandlers(): void {
  this.ipcHandle(IpcChannel.MyService_Action, (_, arg) => this.doAction(arg))
}
// DELETE unregisterIpcHandlers() entirely

Remove the unregisterIpcHandlers() method and its call from onStop(). BaseService cleans up all tracked handlers automatically after onStop() returns.

Tip

: ipcHandle() and ipcOn() now return a Disposable, allowing manual early unregistration if needed (e.g., const d = this.ipcHandle(...); d.dispose()). For most services, automatic cleanup on stop is sufficient.

Migration caveat: Services using ipcMain.removeAllListeners(channel) (e.g., CacheService) need careful review — this.ipcOn() tracks specific listeners and uses removeListener(), not removeAllListeners(). If other code also listens on the same channel, this is the correct behavior; if the intent was to remove all listeners, verify the migration won't leave orphans.

Step 8b: Migrate recurring timers to registerInterval

Replace lifecycle-scoped setInterval() with this.registerInterval() — handles unref(), exception isolation, and cleanup via the disposable channel.

// OLD
private gcInterval: NodeJS.Timeout | null = null
protected async onStop() {
  if (this.gcInterval) { clearInterval(this.gcInterval); this.gcInterval = null }
}
private startGc() {
  if (this.gcInterval) return
  this.gcInterval = setInterval(() => this.gc(), 60_000)
  this.gcInterval.unref()
}

// NEW
private gcInterval: Disposable | null = null
protected async onStop() {
  this.gcInterval = null // auto-disposed; null'd so restart re-arms
}
private startGc() {
  if (this.gcInterval) return
  this.gcInterval = this.registerInterval(() => this.gc(), 60_000)
}

If the field is never read (e.g., fire-and-forget from onInit), drop it entirely.

Do not migrate: one-shot setTimeout (debounces), connection-scoped heartbeats (Discord/Slack/QQ adapters), activation-scoped timers in Activatable services.

Before/After Summary

Aspect Before After
Singleton private static instance + getInstance() @Injectable('Name') — container manages it
Init Manual init() called from index.ts onInit() — called automatically
Cleanup Manual cleanup in will-quit / before-quit handler onStop() / onDestroy() — automatic
Dependencies import { otherService } from '...' @DependsOn([...]) + application.get()
Access import { myService } from '...' application.get('MyService')
Ordering Manual call order in index.ts @ServicePhase + @DependsOn + @Priority
Error handling try/catch in index.ts @ErrorHandling('fail-fast' | 'graceful')
IPC handlers Manual ipcMain.handle() + removeHandler() this.ipcHandle() — auto-cleanup on stop
Recurring timers Manual setInterval() + clearInterval() + unref() this.registerInterval() — auto-cleanup, auto-unref, exception-isolated

Step 9: Migrate ad-hoc event communication to Emitter/Event

If the old service used app.emit() / app.on() or custom EventEmitter patterns for inter-service communication, replace them with typed Emitter<T> / Event<T>:

// OLD — ad-hoc event on Electron's app object
// Producer:
app.emit('main-window-created', this.mainWindow)
// Consumer:
;(app as NodeJS.EventEmitter).on('main-window-created', (event, window) => { ... })
// Manual cleanup in onStop():
;(app as NodeJS.EventEmitter).off('main-window-created', this.handler)

// NEW — typed Emitter/Event
// Producer:
private readonly _onMainWindowCreated = new Emitter<BrowserWindow>()
public readonly onMainWindowCreated: Event<BrowserWindow> = this._onMainWindowCreated.event
// Fire:
this._onMainWindowCreated.fire(this.mainWindow)

// Consumer:
this.registerDisposable(
  windowService.onMainWindowCreated((window) => { ... })
)
// No manual cleanup needed — registerDisposable handles it

See Service Events for full patterns.

Common Pitfalls

  1. Constructor side effects — Old services often do work in the constructor (event listeners, timers). Move all side effects to onInit(). The constructor should only assign default values.

  2. Top-level application.get() callsapplication.get() only works after the service is registered and bootstrapping has started. Never call it at module scope:

    // ✗ BAD — runs at import time, before bootstrap
    const preferenceService = application.get('PreferenceService')
    
    @Injectable('MyService')
    export class MyService extends BaseService {
      // ✓ GOOD — runs during bootstrap, dependencies are ready
      protected async onInit() {
        const preferenceService = application.get('PreferenceService')
      }
    }
    
  3. Circular dependencies — If ServiceA depends on ServiceB and vice versa, refactor so that the non-critical direction uses onAllReady() instead of @DependsOn:

    @Injectable('ServiceA')
    @DependsOn(['ServiceB'])          // ← hard dependency
    export class ServiceA extends BaseService { ... }
    
    @Injectable('ServiceB')
    // No @DependsOn on ServiceA — would be circular
    export class ServiceB extends BaseService {
      protected onAllReady() {
        // Safe to access ServiceA here — all services are ready
        const a = application.get('ServiceA')
      }
    }
    
  4. Forgetting to remove old exports — After migration, grep for the old export name (e.g., windowService) across the codebase. Any remaining imports will break at runtime.