| name | adding-service-modules |
| description | Creates business logic and background service modules in the Electron main process. Covers the service class pattern, dependency injection, lifecycle management, renderer event pushing, and co-located testing. Use when adding non-UI, non-store logic such as background workers, API servers, or processing engines. |
Adding Service Modules
Services live in src/main/services/ and contain business logic, background processes, or coordination between stores. They run in the Electron main process.
Service class template
import type { WidgetStore } from '../stores/widget-store'
export class SyncEngine {
private widgetStore: WidgetStore
private sendToRenderer: (channel: string, payload: unknown) => void
private timer: ReturnType<typeof setInterval> | null = null
constructor(
widgetStore: WidgetStore,
sendToRenderer: (channel: string, payload: unknown) => void,
) {
this.widgetStore = widgetStore
this.sendToRenderer = sendToRenderer
}
start(): void {
this.timer = setInterval(() => this.tick(), 30_000)
}
stop(): void {
if (this.timer) {
clearInterval(this.timer)
this.timer = null
}
}
private notify(status: string): void {
this.sendToRenderer('latch:sync-status', { status })
}
private tick(): void {
try {
const { widgets } = this.widgetStore.listWidgets()
this.notify('synced')
} catch {
}
}
}
Key patterns
Dependency injection via constructor
Services receive their dependencies (stores, sendToRenderer) as constructor arguments. This keeps them testable — tests can pass mocks.
Lifecycle: start() / stop()
Services with timers or servers implement start() and stop(). Both are called from src/main/index.ts.
Wire with setters for optional deps
When services have circular or optional dependencies, use setters:
authzServer.setRadar(radar)
authzServer.setFeedStore(feedStore)
Push events to renderer
Use sendToRenderer (passed via constructor) to emit events:
this.sendToRenderer('latch:myservice-event', { data })
The renderer subscribes via a preload listener (see adding-ipc-handlers skill).
Never crash the main process
Wrap all logic in try/catch. A service failure must not bring down the app:
private tick(): void {
try {
} catch {
}
}
Import paths
From src/main/services/, imports look like:
import type { WidgetStore } from '../stores/widget-store'
import type { SomeType } from '../../types'
Wiring into the app
In src/main/index.ts:
import { SyncEngine } from './services/sync-engine'
let syncEngine: SyncEngine | null = null
try {
syncEngine = new SyncEngine(widgetStore, sendToRenderer)
syncEngine.start()
} catch (err: any) {
console.error('SyncEngine start failed:', err?.message)
syncEngine = null
}
syncEngine?.stop()
Testing
Tests are co-located: src/main/services/sync-engine.test.ts.
import { describe, it, expect } from 'vitest'
import { SyncEngine } from './sync-engine'
function makeStore(overrides?: Partial<WidgetStore>) {
return {
listWidgets: () => ({ ok: true, widgets: [] }),
...overrides,
} as any
}
describe('SyncEngine', () => {
it('calls listWidgets on tick', () => {
let called = false
const store = makeStore({
listWidgets: () => { called = true; return { ok: true, widgets: [] } },
})
const engine = new SyncEngine(store, () => {})
;(engine as any).tick()
expect(called).toBe(true)
})
})
Run tests: npx vitest run src/main/services/sync-engine.test.ts
Test conventions
- Use
describe() / it() / expect() from vitest
- Create factory helpers (
makeStore, makePolicy, makeEvent) for test data
- Group tests by feature in nested
describe blocks
- Mock dependencies as plain objects with
as any
- Test exported pure functions directly; test class methods via public API
Checklist
- Create
src/main/services/<name>.ts with JSDoc module header
- Accept dependencies via constructor (stores,
sendToRenderer)
- Implement
start() / stop() for lifecycle services
- Wrap all work in try/catch — never crash the main process
- Wire in
src/main/index.ts: instantiate after stores, call .start(), call .stop() in before-quit
- Add co-located
.test.ts file with factory helpers