12 KiB
Lifecycle Decision Guide
Lifecycle manages resources, not logic. Being named "Service" does not mean it belongs here. The question is: does it own resources or side effects that outlive a single method call and need cleanup on shutdown?
Use Lifecycle if (either condition)
1. Owns long-lived resources — created at init, survive across calls, need explicit cleanup:
| Category | Examples |
|---|---|
| DB connections | SQLite / better-sqlite3, Drizzle ORM |
| Network services | HTTP server, mDNS browser, WebSocket server |
| Native / OS resources | SelectionHook (system thread), Tray, BrowserWindow |
| File system | chokidar watcher, Winston DailyRotateFile transport |
| Timers | setInterval (GC, polling) |
| Child processes | Long-running gateway / worker (not one-shot scripts) |
| Stateful stores | In-memory caches needing flush on shutdown |
2. Registers persistent side effects — modifies global state at init, persists for lifetime, needs undo:
| Category | Examples |
|---|---|
| Event listeners | nativeTheme.on(), powerMonitor.on(), autoUpdater.on() |
| Global shortcuts | globalShortcut.register() |
| Subscriptions | preferenceService.subscribeChange(), configManager.subscribe() |
| Session interceptors | session.webRequest.onHeadersReceived() |
| IPC handlers | ipcMain.handle() registration (see below) |
| Global API mutations | Monkey-patching global APIs |
When should IPC handlers live inside a service?
Placement, not promotion. This table assumes the service is already lifecycle (it owns resources or stateful handlers) and only decides whether a handler lives inside it. None of these rows promotes a class into lifecycle on its own — least of all row 3: "belongs to the domain" means co-locate into the existing domain service, never create a lifecycle service just to host IPC registration.
A lifecycle service should self-contain its IPC handlers when any of the following is true:
| Condition | Why |
|---|---|
Handler accesses service instance state (this.xxx) |
Handler is coupled to the service's lifecycle — if the service stops, the handler must stop too |
Service needs stop() / start() / restart() support |
Orphaned handlers would reference stale state after restart |
| Handler semantically belongs to the service's domain | Co-location improves maintainability and discoverability |
If the handler is purely stateless (e.g., returns app.getVersion()), it does not require lifecycle management — a class whose only job is registering stateless IPC is not a lifecycle service. Fold the handler into its domain service, or register it from a direct-import singleton.
BaseService provides built-in IPC tracking for self-contained handlers — see IPC Handler Management.
Do NOT Use Lifecycle if
- Stateless orchestration — calls other services, combines results, owns nothing.
- DataApi business-logic services — repositories / data-access wrappers that query
DbService(e.g.MessageRepository,TopicService). The DB connection is managed byDbService; these just encapsulate queries. Use a direct-import singleton. - Request-scoped resources — resources created and released within a single method call (e.g. S3 connections in
BackupManager.backup()). - No init, no cleanup — would inherit
BaseServicebut never overrideonInit()/onStop(). - Pure utility — functions or SDK wrappers with no runtime state.
Decision Flowchart
┌───────────────────────────────────┐
│ Owns long-lived resources? │
│ (connections, timers, native │
│ modules, servers, processes) │
└─────┬────────────────┬────────────┘
yes │ │ no
▼ ▼
┌───────────┐ ┌──────────────────────────┐
│ Lifecycle │ │ Registers persistent │
└───────────┘ │ side effects? │
│ (listeners, shortcuts, │
│ subscriptions, etc.) │
└─────┬───────────┬────────┘
yes │ │ no
▼ ▼
┌───────────┐ ┌────────────────┐
│ Lifecycle │ │ Direct-import │
└───────────┘ │ singleton │
└────────────────┘
Quick Reference
| Lifecycle | Direct-import singleton | |
|---|---|---|
| Examples | DbService, CacheService, MainWindowService |
ExportService, BackupManager |
| Long-lived resources | Yes | No (or request-scoped) |
| Persistent side effects | Yes | No |
onInit / onStop |
Meaningful | Would be empty |
| Pattern | @Injectable + application.get() |
export const x = new X() |
Examples
Belongs in lifecycle — owns timer, needs cleanup:
@Injectable('CacheService')
export class CacheService extends BaseService {
private gcTimer: NodeJS.Timeout | null = null
protected onInit() {
this.gcTimer = setInterval(() => this.gc(), 600_000)
}
protected onStop() {
clearInterval(this.gcTimer!)
this.cache.clear()
}
}
Does NOT belong — all work inside methods, nothing to clean up:
export class ExportService {
private md = new MarkdownIt()
async exportToDocx(messages: Message[]) {
const doc = new Document({ sections: this.buildSections(messages) })
const buffer = await Packer.toBuffer(doc)
await dialog.showSaveDialog(/* ... */)
}
}
export const exportService = new ExportService()
Choosing Between @Conditional, Pausable, and Activatable
Once a service belongs in lifecycle, it may need optional behaviors:
| Scenario | Use | Reason |
|---|---|---|
| Service only runs on specific platform/arch | @Conditional |
Excluded at boot, zero overhead |
| Service needs temporary suspend/resume (e.g., window inactive) | Pausable |
Keeps instance and resources, just pauses execution |
| Service always needs IPC, but heavy resources load on demand | Activatable |
IPC always available, resources allocated only when needed |
| Service has a runtime toggle (preference, feature flag) controlling on/off | Activatable |
Unified activate/deactivate pattern, even for lightweight resources |
| Service runs unconditionally with all resources | None | Default behavior |
Decision Flow
Does the service need to be entirely excluded on some platforms?
├─ Yes, condition is known at boot and immutable
│ → @Conditional (platform, arch, env var, etc.)
└─ No
Does the service have heavy resources OR a runtime toggle controlling on/off?
├─ Yes → Activatable
│ IPC registered in onInit() (always available)
│ Resources in onActivate()/onDeactivate()
│ Service decides trigger (preference, event, IPC, etc.)
└─ No
Does the service need temporary pause/resume?
├─ Yes → Pausable
└─ No → No extra interface needed
Activatable vs Pausable
| Activatable | Pausable | |
|---|---|---|
| Purpose | On-demand resource loading/release | Temporary execution suspension |
| State dimension | Orthogonal to LifecycleState | Changes LifecycleState |
| IPC handlers | Always available (registered in onInit) | Retained while paused (removed on stop) |
| Resources | Not allocated when inactive | Retained while paused |
| Trigger | Service decides (self or external via application.activate) |
LifecycleManager with cascade |
| Cascade | No cascade | Cascades to dependents |
| Cycles | Supports repeated activate/deactivate | Supports repeated pause/resume |
When Activatable is NOT appropriate
- Lightweight resources with no runtime toggle (Map, simple state that is always needed) — not worth the split, load in
onInit() - No IPC needed when inactive — consider
@Conditionalto exclude entirely - Resources need coordinated release across services — consider
Pausable(supports cascade)
Common Mistakes
-
Empty hooks —
extends BaseServicebut noonInit()/onStop()override. If both would be empty, don't use lifecycle. -
Request-scoped ≠ long-lived —
BackupManagercreates S3 connections insidebackup()and releases on return. That's request-scoped. No lifecycle needed. -
"Depends on PreferenceService" — not a lifecycle concern. Any code can call
application.get('PreferenceService'). Only register if the service itself owns resources. -
Using
@Conditionalfor runtime conditions —@Conditionalis evaluated once at boot. For conditions that change at runtime (user preferences, events), useActivatableinstead. -
Redundant cross-phase
@DependsOn— WhenReady services do not need@DependsOn('PreferenceService')or@DependsOn('DbService'). Phase ordering is enforced by the container; BeforeReady is always ready before WhenReady starts. Only declare@DependsOnfor same-phase services.// ❌ Redundant — PreferenceService is BeforeReady, guaranteed ready @Injectable('MainWindowService') @ServicePhase(Phase.WhenReady) @DependsOn('PreferenceService') // <-- remove this export class MainWindowService extends BaseService { ... } // ✅ Correct — only declare same-phase deps @Injectable('AgentBootstrapService') @ServicePhase(Phase.WhenReady) @DependsOn('ApiServerService') // ApiServerService is also WhenReady export class AgentBootstrapService extends BaseService { ... } -
Awaiting business work inside
onAllReady—onAllReadyis a post-bootstrap supplement, not part of initialization. The framework invokes every service's hook in parallel and does not await completion (fire-and-forget). Anawait someLongRunning()insideonAllReadybecomes silent background work; bootstrap proceeds without it. If the service truly needs deferred business work (e.g. a quiet window then recovery), schedule it viasetTimeout, track the Promise on the instance, and join it fromonStop. See Lifecycle Usage — onAllReady patterns for the template. -
Treating
ALL_SERVICES_READYas "all side effects done" — the event fires immediately after everyonAllReadyhook has been invoked, not after they complete. A listener that needs to wait on a specific service's deferred work must coordinate with that service directly (e.g. aSignalemitted by the service when its work finishes), not subscribe toALL_SERVICES_READY. -
"Lifecycle service as an IPC bucket" — a class that exists only to register IPC handlers is not lifecycle by default. Registration is a side effect, but a stateless handler needs no shutdown undo, and "belongs to the domain" (the IPC table's row 3) only decides placement inside an already-lifecycle service — it never promotes an IPC-only class. Fold such handlers into the owning domain service, or use a direct-import singleton.