# 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](./lifecycle-decision-guide.md). ## Old Patterns You'll Encounter ### Pattern A: Manual Singleton ```typescript // 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 ```typescript // OLD — instantiated on import, init called manually class ThemeService { init() { /* ... */ } } export const themeService = new ThemeService() ``` ### Pattern C: Free Functions ```typescript // 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. ```typescript // 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](./lifecycle-overview.md#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: ```typescript // 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 ```typescript // 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: ```typescript // 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()`: > ```typescript > 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: ```typescript // 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 main.ts After migration, delete the manual calls in `src/main/main.ts`: ```typescript // 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: ```typescript // 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: ```typescript // 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. ```typescript // 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` / `Event`: ```typescript // 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() public readonly onMainWindowCreated: Event = 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](./lifecycle-usage.md#service-events-emitter--event) 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()` calls** — `application.get()` only works after the service is registered and bootstrapping has started. Never call it at module scope: ```typescript // ✗ 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`: ```typescript @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.