Modules
Modules are the primary organizational unit in Electro. Every piece of functionality belongs to a module. Modules define explicit dependency boundaries, control provider visibility, and participate in the application lifecycle.
What a Module Does
A module is responsible for:
- Grouping related services and providers into a cohesive domain (e.g.,
AuthModule,HttpModule) - Declaring dependencies — which other modules it needs
- Controlling visibility — which of its providers are public vs. private
- Lifecycle hooks — running code at the correct phase of startup and shutdown
Modules do not have direct access to SignalBus or JobRegistry. Signal subscriptions and job management belong in services declared inside the module.
Defining a Module
import { Module } from "@electrojs/common";
import { inject } from "@electrojs/runtime";
import { AuthService } from "./auth.service";
import { HttpModule } from "../http/http.module";
import { ConfigModule } from "../config/config.module";
@Module({
id: "auth", // Optional — inferred from class name if omitted
imports: [HttpModule, ConfigModule], // Modules whose exports this module can use
providers: [AuthService], // Services owned by this module (private by default)
exports: [AuthService], // Services other modules are allowed to inject
})
export class AuthModule {
async onInit() {
/* ... */
}
async onReady() {
/* ... */
}
async onShutdown() {
/* ... */
}
async onDispose() {
/* ... */
}
}@Module Options
| Option | Type | Description |
|---|---|---|
id | string | Unique identifier. Defaults to the class name lowercased. |
imports | Module[] | Other modules whose exported providers become available via inject(). |
providers | Injectable[] | All services owned by this module. Private unless exported. |
exports | Injectable[] | Subset of providers visible to importing modules. |
Imports and Exports
The imports/exports mechanism enforces encapsulation between domains.
HttpModule
exports: [HttpService]
AuthModule
imports: [HttpModule] → gains access to HttpService
providers: [AuthService, TokenCache]
exports: [AuthService] → only AuthService is public
TokenCache stays private
AppModule
imports: [AuthModule]
→ can inject AuthService ✅
→ cannot inject TokenCache ❌ (not exported)
→ cannot inject HttpService ❌ (not re-exported by AuthModule)@Module({ id: "app", imports: [AuthModule] })
export class AppModule {
async onReady() {
const auth = inject(AuthService); // ✅ exported by AuthModule
const http = inject(HttpService); // ❌ Error: not in scope
}
}Lifecycle Hooks in Modules
See Application Lifecycle for the full phase reference.
@Module({ id: "auth", imports: [HttpModule], providers: [AuthService] })
export class AuthModule {
async onInit() {
// One-time setup only. No network calls, no window operations.
// Registering Electron-level listeners (app.on(...)) is acceptable here.
}
async onReady() {
// All dependencies are ready. Call into services to start work.
const session = await inject(AuthService).restoreSession();
if (session) {
// Signal emission must go through a service — not the module itself
inject(AuthService).notifySessionRestored(session);
}
}
async onShutdown() {
await inject(AuthService).persistSession();
}
async onDispose() {
// Final cleanup
}
}Cross-Module Communication
Modules do not call each other directly. There are two sanctioned patterns:
1. Dependency Injection (direct calls)
When ModuleA imports ModuleB, it can call exported services directly.
@Module({ id: "projects", imports: [AuthModule], providers: [ProjectService] })
export class ProjectsModule {
async onReady() {
// AuthModule is imported → AuthService is in scope
const user = await inject(AuthService).getMe();
await inject(ProjectService).loadForUser(user?.id);
}
}Use this for synchronous or request-response style communication.
2. Signals (event-driven communication)
Services emit signals; other services react via @signal handlers. The module boundary is transparent to both sides.
// In AuthService (auth module) — publishes
@Injectable()
export class AuthService {
private readonly signals = inject(SignalBus);
@command()
async login(email: string, password: string): Promise<void> {
const { user, isNew } = await inject(HttpService).post("/api/auth/login", { email, password });
inject(AuthState).setSession(user);
this.signals.publish("auth:user-logged-in", { user, isNew });
}
}
// In WorkspaceService (workspace module) — reacts
@Injectable()
export class WorkspaceService {
@signal({ id: "auth:user-logged-in" })
async onUserLoggedIn(ctx: SignalContext, payload: { user: User }): Promise<void> {
await this.loadForUser(payload.user.id);
}
}WorkspaceModule does not need to import AuthModule to react to "auth:user-logged-in". Signals are global across the runtime.
See Signals for the full signal API.
Module File Structure
modules/
├── auth/
│ ├── auth.module.ts ← @Module declaration
│ ├── auth.service.ts ← @Injectable with @query / @command / @signal
│ ├── auth.state.ts ← @Injectable state holder
│ └── auth.types.ts ← Domain types and interfaces
│
├── http/
│ ├── http.module.ts
│ └── http.service.ts
│
└── config/
├── config.module.ts
└── config.service.tsRoot Module
The root module is the entry point of the module graph. It is passed to AppKernel.create() and typically contains no providers of its own — its purpose is composition.
// runtime/modules/app.module.ts
@Module({
imports: [ConfigModule, HttpModule, AuthModule, WorkspaceModule, UpdaterModule],
})
export class AppModule {}Rules
No circular imports. If AuthModule imports UserModule and UserModule imports AuthModule, the framework will throw. Extract shared logic into a third module.
Import modules, not classes directly. Access to HttpService comes from importing HttpModule, not from importing the class across a directory boundary.
Keep providers private unless they must be shared. Every export is a public API commitment. Fewer exports mean fewer breaking changes.
Never use SignalBus or JobRegistry directly in a @Module class. These are only injectable in @Injectable, @View, and @Window. Put the logic in a service.