Back to Cherry Studio

Lifecycle Decision Guide

docs/references/lifecycle/lifecycle-decision-guide.md

2.0.012.4 KB
Original Source

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:

CategoryExamples
DB connectionsSQLite / better-sqlite3, Drizzle ORM
Network servicesHTTP server, mDNS browser, WebSocket server
Native / OS resourcesSelectionHook (system thread), Tray, BrowserWindow
File systemchokidar watcher, Winston DailyRotateFile transport
TimerssetInterval (GC, polling)
Child processesLong-running gateway / worker (not one-shot scripts)
Stateful storesIn-memory caches needing flush on shutdown

2. Registers persistent side effects — modifies global state at init, persists for lifetime, needs undo:

CategoryExamples
Event listenersnativeTheme.on(), powerMonitor.on(), autoUpdater.on()
Global shortcutsglobalShortcut.register()
SubscriptionspreferenceService.subscribeChange(), configManager.subscribe()
Session interceptorssession.webRequest.onHeadersReceived()
IPC handlersipcMain.handle() registration (see below)
Global API mutationsMonkey-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:

ConditionWhy
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() supportOrphaned handlers would reference stale state after restart
Handler semantically belongs to the service's domainCo-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 by DbService; 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 BaseService but never override onInit() / 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

LifecycleDirect-import singleton
ExamplesDbService, CacheService, MainWindowServiceExportService, BackupManager
Long-lived resourcesYesNo (or request-scoped)
Persistent side effectsYesNo
onInit / onStopMeaningfulWould be empty
Pattern@Injectable + application.get()export const x = new X()

Examples

Belongs in lifecycle — owns timer, needs cleanup:

typescript
@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:

typescript
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:

ScenarioUseReason
Service only runs on specific platform/arch@ConditionalExcluded at boot, zero overhead
Service needs temporary suspend/resume (e.g., window inactive)PausableKeeps instance and resources, just pauses execution
Service always needs IPC, but heavy resources load on demandActivatableIPC always available, resources allocated only when needed
Service has a runtime toggle (preference, feature flag) controlling on/offActivatableUnified activate/deactivate pattern, even for lightweight resources
Service runs unconditionally with all resourcesNoneDefault 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

ActivatablePausable
PurposeOn-demand resource loading/releaseTemporary execution suspension
State dimensionOrthogonal to LifecycleStateChanges LifecycleState
IPC handlersAlways available (registered in onInit)Retained while paused (removed on stop)
ResourcesNot allocated when inactiveRetained while paused
TriggerService decides (self or external via application.activate)LifecycleManager with cascade
CascadeNo cascadeCascades to dependents
CyclesSupports repeated activate/deactivateSupports 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 @Conditional to exclude entirely
  • Resources need coordinated release across services — consider Pausable (supports cascade)

Common Mistakes

  1. Empty hooksextends BaseService but no onInit() / onStop() override. If both would be empty, don't use lifecycle.

  2. Request-scoped ≠ long-livedBackupManager creates S3 connections inside backup() and releases on return. That's request-scoped. No lifecycle needed.

  3. "Depends on PreferenceService" — not a lifecycle concern. Any code can call application.get('PreferenceService'). Only register if the service itself owns resources.

  4. Using @Conditional for runtime conditions@Conditional is evaluated once at boot. For conditions that change at runtime (user preferences, events), use Activatable instead.

  5. 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 @DependsOn for same-phase services.

    typescript
    // ❌ 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 { ... }
    
  6. Awaiting business work inside onAllReadyonAllReady is 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). An await someLongRunning() inside onAllReady becomes silent background work; bootstrap proceeds without it. If the service truly needs deferred business work (e.g. a quiet window then recovery), schedule it via setTimeout, track the Promise on the instance, and join it from onStop. See Lifecycle Usage — onAllReady patterns for the template.

  7. Treating ALL_SERVICES_READY as "all side effects done" — the event fires immediately after every onAllReady hook 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. a Signal emitted by the service when its work finishes), not subscribe to ALL_SERVICES_READY.

  8. "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.