docs/references/lifecycle/lifecycle-overview.md
IoC container + service lifecycle management with phased bootstrap and parallel initialization.
For the user-facing API (registration, bootstrap, service access, runtime control), see Application Overview. Application delegates to lifecycle internally — you should rarely need to use
ServiceContainerorLifecycleManagerdirectly.
Services are initialized in three phases:
| Phase | Description | Timing | Await |
|---|---|---|---|
BeforeReady | Services not requiring Electron API | Before app.whenReady() | Yes |
Background | Independent services, fire-and-forget | Immediately | No |
WhenReady | Services requiring Electron API (default) | After app.whenReady() | Yes |
|--Background (fire-and-forget)------------|
|--BeforeReady--------| |
|--app.whenReady()--------| |
|--WhenReady--| |
isBootstrapped = true
|--await Background--|
allReady (fire-and-forget) → ALL_SERVICES_READY
After all three phases complete (including Background), LifecycleManager.allReady() invokes onAllReady() on every initialized service in parallel and immediately emits ALL_SERVICES_READY — it does not await the hooks. onAllReady is a post-bootstrap supplement (see Hook Descriptions), so bootstrap does not block on services running deferred work inside it.
1 Background starts (fire-and-forget) ──────────────────────────────────┐
2 BeforeReady starts ──────────┐ │
2 app.whenReady() ─────────────┤ │
├─ both complete │
▼ │
3 WhenReady starts ────────────┐ │
├─ complete → isBootstrapped = true │
▼ │
4 await Background ◄────────────────────────────────────────────────────┘
5 onAllReady() called on ALL services
→ ALL_SERVICES_READY emitted
Key points:
app.whenReady()), providing "free time" — work here doesn't add to startup latency as long as it finishes before Electron is ready. ┌──────────────────────┐
│ Does it use Electron │
│ APIs directly? │
└──────┬─────────┬─────┘
yes │ │ no
▼ ▼
┌───────────┐ ┌───────────────────────────┐
│ WhenReady │ │ Is it on the critical │
└───────────┘ │ startup path? (other │
│ services depend on it) │
└─────┬──────────┬──────────┘
yes │ │ no
▼ ▼
┌─────────────┐ ┌────────────┐
│ BeforeReady │ │ Background │
└─────────────┘ └────────────┘
BeforeReady — Maximize parallelism with Electron init
app.whenReady(), so initialization here is essentially "free" if it completes before Electron is ready.WhenReady — The safe default
app.whenReady() have completed.BrowserWindow, Tray, screen, nativeTheme, dialog, globalShortcut, etc.).@ServicePhase, the service is placed here.⚠️ Cross-phase dependencies are automatic. BeforeReady services (
PreferenceService,DbService,CacheService,DataApiService) are guaranteed to finish before WhenReady starts. Do not declare@DependsOn('PreferenceService')(or similar) on a WhenReady service — it is redundant and misleading. Only use@DependsOnfor same-phase coupling.
Background — Fire-and-forget
onAllReady() if a Background service needs to interact with services from other phases after bootstrap.| Phase | Can Depend On | Cannot Depend On |
|---|---|---|
| BeforeReady | BeforeReady | Background, WhenReady |
| Background | Background | BeforeReady, WhenReady |
| WhenReady | BeforeReady, WhenReady | Background |
Cross-phase dependencies are implicit — the "Can Depend On" column means those services are guaranteed to be ready, not that you should declare them via @DependsOn. Reserve @DependsOn for same-phase ordering; cross-phase readiness is enforced automatically by LifecycleManager.startPhase().
Invalid dependencies are auto-corrected with a warning log:
[WARN] Service 'X' declared as Background but depends on BeforeReady service 'Y', adjusted to BeforeReady
Services within the same phase that have no inter-dependencies are initialized in parallel:
Phase: WhenReady
Layer 1: [DbService, ConfigService] <- parallel (no inter-dependency)
Layer 2: [PreferenceService] <- sequential (depends on layer 1)
Layer 3: [MainWindowService] <- sequential (depends on layer 2)
Created → Initializing → Ready ⇄ Paused
↓ ↓ ↓
onInit() onReady() onPause()/onResume()
↑ ↓
│ Stopping → Stopped → Destroyed
│ ↓ ↓ ↓
│ onStop() [restart] onDestroy()
└───────────────────────┘
After all phases complete:
Ready ──── onAllReady() (called once, no state change)
| Hook | When Called | Can Override |
|---|---|---|
onInit() | During initialization (and re-initialization on restart) | Yes |
onReady() | Immediately after onInit() completes | Yes |
onAllReady() | Once after ALL services across ALL phases are ready | Yes |
onStop() | When the service is being stopped | Yes |
onDestroy() | Final cleanup, service cannot be reused | Yes |
onPause() | When the service is being paused (requires Pausable) | Yes |
onResume() | When the service is being resumed (requires Pausable) | Yes |
BaseService uses a single unified Disposable tracking mechanism. All resources — IPC handlers, event subscriptions, recurring timers, signals, cleanup functions — are tracked as Disposables and cleaned up together during the stop lifecycle.
registerDisposable() accepts both Disposable objects and plain () => void cleanup functions:
this.registerDisposable(someEmitter.on('event', handler)) // Disposable object
this.registerDisposable(() => externalBus.off('topic', fn)) // Cleanup function
ipcHandle(), ipcOn(), and registerInterval() all return a Disposable registered through this same channel — IPC handlers and recurring timers are not separate cleanup categories.
Cleanup flow:
onStop() → all disposables disposed → state = Stopped
_doDestroy is idempotent — calling it on an already-destroyed service is a safe no-op.
See IPC Handler Management and Service Events for usage details.
Called once after all services across all bootstrap phases have completed initialization. Unlike onReady() (which fires when the individual service is ready), onAllReady() fires when the entire system is ready — safe to access any service regardless of @DependsOn declarations.
@Injectable('BackgroundReporterService')
class BackgroundReporterService extends BaseService {
protected onAllReady() {
// Safe to access any service — the entire system is ready
const preferenceService = application.get('PreferenceService')
}
}
Key behaviors:
onAllReady is a post-bootstrap supplement, not part of initialization. It does not change LifecycleState — the service stays in Ready throughout.LifecycleManager.allReady() invokes every service's hook in parallel and does not await completion (fire-and-forget). Bootstrap proceeds as soon as every hook has been invoked.ALL_SERVICES_READY is emitted immediately after all hooks have been invoked, not after they finish. Listeners MUST NOT assume onAllReady side effects have completed when this event fires.restart() does not re-trigger it (guarded by _allReadyCalled)..catch in the framework, logged, and emitted as SERVICE_ERROR (in a microtask) — they never propagate to bootstrap.await long-running business work directly in onAllReady. Because the framework no longer awaits the hook, in-hook awaits become silent background work. If a service needs deferred business work (e.g. a quiet window then recovery), schedule it via setTimeout, track the resulting Promise on the instance, and join it from onStop. See Lifecycle Usage — onAllReady patterns.onAllReady Hook vs ALL_SERVICES_READY EventSame readiness moment, two delivery channels. Both fire from one synchronous LifecycleManager.allReady() call — the framework first invokes every service's onAllReady, then emits ALL_SERVICES_READY. They are microseconds apart on the same JS tick.
onAllReady hook | ALL_SERVICES_READY event | |
|---|---|---|
| Mechanism | Push — framework calls every service once | Pub/sub — only .on(...) subscribers receive |
| Audience | The service itself, via method override | Anyone with a LifecycleManager reference |
| Failure handling | Caught by framework, re-emitted as SERVICE_ERROR | Standard EventEmitter behaviour |
Rule of thumb: a service reacts via its own onAllReady; non-service code (diagnostics, telemetry, ad-hoc listeners) subscribes to the event. Neither signals when a specific service's deferred work finishes — for that, expose a per-service Signal.
| State | Description |
|---|---|
Created | Instance created, not initialized |
Initializing | Currently running onInit() |
Ready | Fully initialized and operational |
Pausing | Currently running onPause() |
Paused | Temporarily suspended |
Resuming | Currently running onResume() |
Stopping | Currently running onStop() |
Stopped | Stopped, can be restarted via start() |
Destroyed | Released, cannot be reused |
For most use cases, prefer the
onAllReady()hook orapplication.get()over raw event listening. These events are primarily for infrastructure code (e.g., diagnostics, logging). For the hook vs event tradeoff, seeonAllReadyHook vsALL_SERVICES_READYEvent.
Listen to lifecycle events via the LifecycleManager (extends EventEmitter):
import { LifecycleEvents, LifecycleManager } from '@main/core/lifecycle'
const manager = LifecycleManager.getInstance()
manager.on(LifecycleEvents.SERVICE_READY, (payload) => {
logger.info(`${payload.name} is ready`)
})
manager.on(LifecycleEvents.ALL_SERVICES_READY, () => {
logger.info('All services ready')
})
| Event | Payload | Description |
|---|---|---|
SERVICE_INITIALIZING | { name, state } | Service is starting initialization |
SERVICE_READY | { name, state } | Service completed initialization |
SERVICE_PAUSING | { name, state } | Service is being paused |
SERVICE_PAUSED | { name, state } | Service is paused |
SERVICE_RESUMING | { name, state } | Service is being resumed |
SERVICE_RESUMED | { name, state } | Service is resumed |
SERVICE_STOPPING | { name, state } | Service is being stopped |
SERVICE_STOPPED | { name, state } | Service is stopped |
SERVICE_DESTROYED | { name, state } | Service is destroyed |
SERVICE_ERROR | { name, state, error } | Service encountered an error |
ALL_SERVICES_READY | (none) | All onAllReady hooks have been invoked (NOT necessarily completed — see onAllReady) |
@DependsOn guarantees initialization order, but some services need to react to work completed by other services at runtime (after onInit()). For example, ShortcutService needs to know when MainWindowService creates the main window — which happens after all services have initialized.
The lifecycle system provides two typed primitives for this, avoiding ad-hoc EventEmitter patterns (no type safety, magic strings, manual cleanup):
| Communication Pattern | Mechanism | Example |
|---|---|---|
| "Service B must init after Service A" | @DependsOn | PreferenceService depends on DbService |
| "Service A completed runtime work, others react" (repeatable) | Emitter<T> / Event<T> | MainWindowService fires onMainWindowCreated |
| "Service A completed runtime work, others react" (one-shot) | Signal<T> | DbService signals migrationComplete |
| "Tell a specific service to do something" | Direct method call via application.get() | windowService.showMainWindow() |
A producer service owns an Emitter<T> (private) and exposes its Event<T> (public). Consumers subscribe and get a Disposable for automatic cleanup via registerDisposable().
A Signal<T> resolves exactly once. It implements PromiseLike<T> so consumers can await it directly. Late subscribers receive the resolved value immediately.
For full usage patterns and code examples, see Service Events and Signal.
lifecycle/
├── types.ts # Phase, LifecycleState, ServiceMetadata, Pausable, errors
├── decorators.ts # @Injectable, @ServicePhase, @DependsOn, @Priority, etc.
├── BaseService.ts # Abstract base class with lifecycle hooks
├── event.ts # Emitter<T>, Event<T>, Disposable — typed inter-service events
├── signal.ts # Signal<T> — one-shot deferred value (PromiseLike)
├── ServiceContainer.ts # IoC container with DI and conditional activation
├── DependencyResolver.ts # Topological sort, layered parallel resolution
├── LifecycleManager.ts # Phased bootstrap, shutdown, pause/resume/stop/start
├── index.ts # Barrel export
└── __tests__/ # Unit tests for all components