- Contributing guide: CONTRIBUTING.md
- Code of Conduct: CODE_OF_CONDUCT.md
- Security policy: SECURITY.md
A lightweight dependency injection container for Electron's main process that brings modular architecture and clean code organization to your desktop applications.
Building complex Electron apps often leads to tightly coupled code, scattered IPC handlers, and difficulty managing window lifecycles. This package addresses these challenges by providing:
- Organized Code Structure - Split your application into independent, testable modules instead of monolithic files
- Automatic Dependency Management - No more manual service instantiation or passing dependencies through multiple layers
- Centralized IPC Logic - Group related IPC handlers with their business logic instead of scattering them across your codebase
- Window Lifecycle Control - Manage BrowserWindow creation, caching, and event handling in dedicated classes
- Type-Safe Module Boundaries - Share only necessary interfaces between modules using the provider pattern
The package uses TypeScript decorators (@RgModule, @Injectable, @IpcHandler, @WindowManager) to eliminate boilerplate and let you focus on business logic. Services are automatically instantiated with their dependencies, IPC handlers are registered during module initialization, and windows are created with lifecycle hooks that run at the right time.
Instead of wrestling with service initialization order or managing global state, you define modules with clear dependencies and let the container handle the rest.
- Dependency Injection - Automatic service instantiation and injection
- Module System - Organize code into feature modules
- TypeScript Decorators -
@RgModule,@Injectable,@IpcHandler,@WindowManager - Provider Pattern - Share only necessary interfaces between modules
- Type Safety - Full TypeScript support
A small example app demonstrating how to use this package is available at trae-op/quick-start_react_electron-modular. It contains a minimal React + Electron project that shows module registration, IPC handlers and window managers in action — check its README for setup and run instructions.
For a full starter application with complete settings and built-in features, see the boilerplate at trae-op/electron-modular-boilerplate. This repo contains a ready-to-use React + Electron starter demonstrating advanced integrations (OAuth with Google & GitHub, auto-update support, authentication flows), build and dev scripts, and sensible defaults to help you get started quickly — see its README for details and setup instructions.
Install with your package manager:
# npm
npm install @devisfuture/electron-modular
# yarn
yarn add @devisfuture/electron-modular
# pnpm
pnpm add @devisfuture/electron-modularPeer dependency:
This package targets Electron's main process and declares Electron >=36 as a peer dependency. Ensure Electron is installed in your project:
npm install --save-dev electron@^36TypeScript setup:
- Enable decorators and metadata in your
tsconfig.json:
"experimentalDecorators": true,
"emitDecoratorMetadata": trueTip: This package is published as ESM. When importing local modules, use
.jsextensions in runtime imports, e.g.import { UserModule } from "./user/module.js".
The folders option in initSettings tells the framework where your build artifacts live:
distRenderer— the build output folder for your renderer (web) bundle (e.g. Vite/webpack output).distMain— the build output folder for your main process bundle (compiled ESM files).
Initialize the framework and bootstrap all modules:
import { app } from "electron";
import { initSettings, bootstrapModules } from "@devisfuture/electron-modular";
import { UserModule } from "./user/module.js";
import { ResourcesModule } from "./resources/module.js";
initSettings({
cspConnectSources: process.env.BASE_REST_API
? [process.env.BASE_REST_API]
: [],
localhostPort: process.env.LOCALHOST_ELECTRON_SERVER_PORT ?? "",
folders: {
distRenderer: "dist-renderer",
distMain: "dist-main",
},
});
app.on("ready", async () => {
await bootstrapModules([
UserModule,
ResourcesModule,
// ... other modules
]);
});An example of each module's structure, but you can use your own:
user/
├── module.ts # Module definition
├── service.ts # Business logic or several services in the folder
├── ipc.ts # IPC handlers (optional) or several ipc in the folder
├── window.ts # Window manager (optional) or several windows in the folder
├── tokens.ts # DI tokens (optional)
└── types.ts # Type definitions (optional)
Import a module and directly inject its exported service.
import { RgModule } from "@devisfuture/electron-modular";
import { RestApiModule } from "../rest-api/module.js";
import { UserService } from "./service.js";
import { UserIpc } from "./ipc.js";
@RgModule({
imports: [RestApiModule],
ipc: [UserIpc],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}import { Injectable } from "@devisfuture/electron-modular";
import { RestApiService } from "../rest-api/service.js";
@Injectable()
export class UserService {
constructor(private restApiService: RestApiService) {}
async byId<R extends TUser>(id: string): Promise<R | undefined> {
const response = await this.restApiService.get<R>(
`https://example.com/api/users/${id}`,
);
if (response.error !== undefined) {
return;
}
return response.data;
}
}When to use:
- Simple dependencies
- You need the full service functionality
- No circular dependency issues
Use provide and useFactory to expose only necessary types.
export const USER_REST_API_PROVIDER = Symbol("USER_REST_API_PROVIDER");export type TUserRestApiProvider = {
get: <T>(
endpoint: string,
options?: AxiosRequestConfig,
) => Promise<TResponse<T>>;
post: <T>(
endpoint: string,
data: unknown,
options?: AxiosRequestConfig,
) => Promise<TResponse<T>>;
};import { RgModule } from "@devisfuture/electron-modular";
import { RestApiModule } from "../rest-api/module.js";
import { RestApiService } from "../rest-api/service.js";
import { UserService } from "./service.js";
import { UserIpc } from "./ipc.js";
import { USER_REST_API_PROVIDER } from "./tokens.js";
import type { TUserRestApiProvider } from "./types.js";
@RgModule({
imports: [RestApiModule],
ipc: [UserIpc],
providers: [
UserService,
{
provide: USER_REST_API_PROVIDER,
useFactory: (restApiService: RestApiService): TUserRestApiProvider => ({
get: (endpoint, options) => restApiService.get(endpoint, options),
post: (endpoint, data, options) =>
restApiService.post(endpoint, data, options),
}),
inject: [RestApiService],
},
],
exports: [UserService],
})
export class UserModule {}@Injectable()
export class UserService {
constructor(
@Inject(USER_REST_API_PROVIDER)
private restApiProvider: TUserRestApiProvider,
) {}
async byId<R extends TUser>(id: string): Promise<R | undefined> {
const response = await this.restApiProvider.get<R>(
`https://example.com/api/users/${id}`,
);
if (response.error !== undefined) {
return;
}
return response.data;
}
}When to use:
- Need to expose limited interface
- Prevent circular dependencies
- Multiple implementations possible
- Better encapsulation
Handle communication between main and renderer processes.
import { ipcMain, type IpcMainEvent } from "electron";
import {
IpcHandler,
TIpcHandlerInterface,
TParamOnInit,
} from "@devisfuture/electron-modular";
import { UserService } from "./service.js";
@IpcHandler()
export class UserIpc implements TIpcHandlerInterface {
constructor(private userService: UserService) {}
async onInit({ getWindow }: TParamOnInit<TWindows["main"]>) {
const mainWindow = getWindow("window:main");
ipcMain.on("user:fetch", (event: IpcMainEvent, userId: string) => {
const user = await this.userService.byId(userId);
event.reply("user:fetch:response", user);
});
}
}Manage BrowserWindow lifecycle and configuration.
import { WindowManager } from "@devisfuture/electron-modular";
import type { TWindowManager } from "../types.js";
@WindowManager<TWindows["userProfile"]>({
hash: "window:user-profile",
isCache: true,
options: {
width: 600,
height: 400,
resizable: false,
},
})
export class UserWindow implements TWindowManager {
constructor(private userService: UserService) {}
onWebContentsDidFinishLoad(window: BrowserWindow): void {
// Initialize when window content loads
this.loadUserData(window);
}
private async loadUserData(window: BrowserWindow): Promise<void> {
const user = await this.userService.getCurrentUser();
window.webContents.send("user:loaded", user);
}
}By default the framework sets the BrowserWindow's webPreferences.preload to the package preload file located in the distMain folder you configured via initSettings. Concretely this resolves to <app path>/<distMain>/preload.cjs (production), and the development build is resolved appropriately when running in dev mode.
By default the source preload file should be placed at src/main/preload.cts in your project; it will be compiled/bundled to preload.cjs in your configured distMain folder during the build step.
If you need a custom preload for a specific window, set options.webPreferences.preload on the @WindowManager config — this will override the framework default:
@WindowManager<TWindows["userProfile"]>({
hash: "window:user-profile",
isCache: true,
options: {
width: 600,
height: 400,
webPreferences: {
preload: '/absolute/or/relative/path/to/custom-preload.js'
}
},
})Note: the custom
preloadyou provide will override the defaultpreloadthe package injects.
The window manager supports lifecycle hooks by naming methods on your class following a simple convention:
- Use
on<ClassicEvent>for BrowserWindow events (e.g.onFocus,onMaximize). - Use
onWebContents<Thing>for WebContents events (e.g.onWebContentsDidFinishLoad,onWebContentsWillNavigate).
How method names map to Electron events:
- The framework removes the
onoronWebContentsprefix, converts the remaining CamelCase to kebab-case and uses that as the event name.onFocus→focusonMaximize→maximizeonWebContentsDidFinishLoad→did-finish-loadonWebContentsWillNavigate→will-navigate
Handler signatures and parameters 🔧
- If your method declares 0 or 1 parameter (i.e.
handler.length <= 1) it will be called with theBrowserWindowinstance only:
onFocus(window: BrowserWindow): void {
// Called when window receives focus
window.webContents.send("window:focused");
}- If your method declares more than 1 parameter, the original Electron event arguments are forwarded first and the
BrowserWindowis appended as the last argument. This is useful for WebContents or events that include event objects and additional data:
onWebContentsWillNavigate(ev: Electron.Event, url: string, window: BrowserWindow) {
// ev and url come from webContents, window is appended by the framework
console.log("navigating to", url);
}Common BrowserWindow events you can handle:
onFocus,onBlur,onMaximize,onUnmaximize,onMinimize,onRestore,onResize,onMove,onClose,onClosed
Common WebContents events you can handle:
onWebContentsDidFinishLoad,onWebContentsDidFailLoad,onWebContentsDomReady,onWebContentsWillNavigate,onWebContentsDidNavigate,onWebContentsNewWindow,onWebContentsDestroyed
Important implementation notes
- Handlers are attached per BrowserWindow instance and cleaned up automatically when the window is closed, so you don't have to manually remove listeners.
- The same instance and set of handlers are tracked in a WeakMap internally; re-attaching the same
windowInstancewill not duplicate listeners.
If your renderer uses dynamic routes (for example React Router) you can open a window that targets a specific route by passing a hash to create. The hash is merged with the window manager's defaults and can carry route segments or parameters (for example window:items/<id>).
Renderer process
<Route path="/window:items/:id" element={<ItemWindow />} />Renderer (inside ItemWindow)
import { useParams } from "react-router-dom";
const ItemWindow = () => {
const { id } = useParams<{ id?: string }>();
if (!id) return null;
return <span>item id: {id}</span>;
};
export default ItemWindow;Main process
import { ipcMain, type IpcMainEvent } from "electron";
import { IpcHandler, type TParamOnInit } from "@devisfuture/electron-modular";
@IpcHandler()
export class ItemsIpc {
constructor() {}
onInit({ getWindow }: TParamOnInit<TWindows["items"]>): void {
const window = getWindow("window:items");
ipcMain.on(
"itemWindow",
async (_: IpcMainEvent, payload: { id?: string }) => {
if (payload.id === undefined) {
return;
}
await window.create({
hash: `window:items/${payload.id}`,
});
},
);
}
}Default manager example
@WindowManager<TWindows['items']>({
hash: 'window:items',
isCache: true,
options: {
width: 350,
height: 300,
},
})Notes:
await window.create({...})merges the provided options with the manager's defaultoptions.- When
isCache: true,getWindow('window:items')returns the cached manager andcreatewill reuse (or re-create) the BrowserWindow as implemented by the manager. - Use a unique
hashper route instance (for examplewindow:items/<id>), so the renderer can read the route from the window URL and navigate to the correct route when the window loads.
TWindows maps window keys to their unique hash strings. Use TWindows["<key>"] for typing windows in @WindowManager and getWindow.
// types/windows.d.ts
type TWindows = {
main: "window:main";
updateResource: "window/resource/update";
};Examples:
// Using as generic for WindowManager
@WindowManager<TWindows["main"]>({
hash: "window:main",
isCache: true,
options: {},
})
export class AppWindow implements TWindowManager {}
// Using with getWindow()
const mainWindow = getWindow<TWindows["main"]>("window:main");Defines a module with its dependencies and providers.
Parameters:
imports?: Class[]- Modules to importproviders?: Provider[]- Services and factoriesipc?: Class[]- IPC handler classeswindows?: Class[]- Window manager classesexports?: Class[]- Providers to export
Marks a class as injectable into the DI container.
@Injectable()
export class MyService {
constructor(private dependency: OtherService) {}
}Injects a dependency by token (Symbol).
constructor(
@Inject(MY_PROVIDER) private provider: TMyProvider
) {}Marks a class as an IPC communication handler.
@IpcHandler()
export class MyIpc implements TIpcHandlerInterface {
async onInit({ getWindow }: TParamOnInit) {
// Setup IPC listeners
}
}Defines a BrowserWindow manager.
Parameters:
hash: string- Unique window identifierisCache?: boolean- Cache window instanceoptions: BrowserWindowConstructorOptions- Electron window options
@WindowManager<TWindows["myWindow"]>({
hash: "window:my-window",
isCache: true,
options: { width: 800, height: 600 },
})
export class MyWindow implements TWindowManager {
onWebContentsDidFinishLoad(window: BrowserWindow): void {
// Lifecycle hook
}
}Initializes framework configuration.
Parameters:
cspConnectSources?: string[]- Optional array of origins to include in theconnect-srcdirective of the generated Content-Security-Policy header.localhostPort: string- Development server portfolders: { distRenderer: string; distMain: string }- Build output folders
initSettings({
cspConnectSources: [
"https://api.example.com",
"https://cdn.example.com",
"wss://websocket.example.com",
],
localhostPort: process.env.LOCALHOST_ELECTRON_SERVER_PORT ?? "",
folders: {
distRenderer: "dist-renderer",
distMain: "dist-main",
},
});Note: When a cached window is created the framework will set a Content-Security-Policy header for renderer responses. The
connect-srcdirective will include'self'plus any entries fromcspConnectSources. For example:
connect-src 'self' https://api.example.com https://cdn.example.com wss://websocket.example.com;
Bootstraps all modules and initializes the DI container.
await bootstrapModules([AppModule, AuthModule, ResourcesModule]);Retrieves a window instance by its hash identifier.
const mainWindow = getWindow<TWindows["main"]>("window:main");
const window = await mainWindow.create();Destroys all cached windows.
...
import { destroyWindows } from "@devisfuture/electron-modular";
...
app.on("before-quit", () => {
destroyWindows();
});Interface for IPC handlers.
export interface TIpcHandlerInterface {
onInit?(params: TParamOnInit): void | Promise<void>;
}Interface for window managers.
export interface TWindowManager {
onWebContentsDidFinishLoad?(window: BrowserWindow): void;
}Recommended file organization for a feature module:
my-feature/
├── module.ts # Module definition with @RgModule
├── service.ts # Main business logic service
├── ipc.ts # IPC communication handlers
├── window.ts # BrowserWindow manager
├── tokens.ts # Dependency injection tokens
├── types.ts # TypeScript type definitions
└── services/ # Additional services (optional)
├── helper.ts
└── validator.ts
{
provide: AUTH_PROVIDER,
useFactory: (authService: AuthService): TAuthProvider => ({
checkAuthenticated: (window) => authService.checkAuthenticated(window),
logout: (window) => authService.logout(window),
}),
inject: [AuthService],
}Each service should have a single responsibility.
@Injectable()
export class ResourcesService {
// Only handles resource data operations
}
@Injectable()
export class CacheWindowsService {
// Only handles window caching
}export const RESOURCES_REST_API_PROVIDER = Symbol("RESOURCES_REST_API_PROVIDER");
constructor(
@Inject(RESOURCES_REST_API_PROVIDER) private restApiProvider
) {}Use lifecycle hooks for initialization logic.
@IpcHandler()
export class MyIpc implements TIpcHandlerInterface {
async onInit({ getWindow }: TParamOnInit) {
// Initialize IPC listeners
}
}
@WindowManager(config)
export class MyWindow implements TWindowManager {
onWebContentsDidFinishLoad(window: BrowserWindow): void {
// Initialize when content loads
}
}Use TypeScript for all services, providers, and interfaces. Decorators Reference
Defines a module.
imports?: Class[]- Modules to importproviders?: Provider[]- Services and factoriesipc?: Class[]- IPC handler classeswindows?: Class[]- Window manager classesexports?: Class[]- Providers to export
Makes a class injectable.
@Injectable()
export class MyService {}Injects a dependency by token.
constructor(@Inject(MY_PROVIDER) private provider: TMyProvider) {}Marks a class as IPC handler.
@IpcHandler()
export class MyIpc implements TIpcHandlerInterface {}Defines a window manager.
@WindowManager<TWindows["myWindow"]>({
hash: "window:my-window",
isCache: true,
options: { width: 800, height: 600 },
})
export class MyWindow implements TWindowManager {}initSettings({
cspConnectSources: process.env.BASE_REST_API
? [process.env.BASE_REST_API]
: [],
localhostPort: process.env.LOCALHOST_ELECTRON_SERVER_PORT ?? "",
folders: { distRenderer: "dist-renderer", distMain: "dist-main" },
});await bootstrapModules([AppModule, UserModule]);const mainWindow = getWindow<TWindows["main"]>("window:main");
const window = await mainWindow.create();app.on("before-quit", () => destroyWindows());