Skip to content

Dependency Injection ​

Electro's DI system is synchronous, hierarchical, and token-based. All dependencies are resolved through a single function: inject().


inject() ​

inject() resolves a provider from the current injection context and returns its instance.

ts
import { inject } from "@electrojs/runtime";
import { AuthService } from "./auth.service";

@Injectable()
export class WorkspaceService {
    private readonly auth = inject(AuthService);
    private readonly config = inject(ConfigService);
    private readonly logger = inject(Logger);
}

Where inject() is valid ​

inject() is only callable inside an injection context — this is any class that the framework itself constructs. These include:

  • @Injectable() class property initializers
  • @Module() class property initializers
  • @Window() class property initializers
  • @View() class property initializers
ts
// ✅ Valid injection contexts
@Injectable()
export class MyService {
    private dep = inject(OtherService); // ✅
}

@Module({ id: "app" })
export class AppModule {
    private auth = inject(AuthService); // ✅
}

@Window({ id: "main" })
export class MainWindow {
    private auth = inject(AuthService); // ✅
}

// ❌ Invalid — no injection context
function handleClick() {
    const auth = inject(AuthService); // ❌ throws: no injection context
}

setTimeout(() => {
    const auth = inject(AuthService); // ❌ throws: context lost in callback
}, 100);

Injection Tokens ​

A token is the key used to look up a provider in the injector.

Class Tokens (most common) ​

When you pass a class to inject(), the class itself is the token. This is the standard pattern for all @Injectable classes.

ts
const auth = inject(AuthService); // AuthService class is the token
const http = inject(HttpService); // HttpService class is the token

Symbol Tokens (for values and interfaces) ​

When you need to inject a primitive value, a plain object, or an interface (which has no runtime class), use createInjectionToken.

ts
import { createInjectionToken } from "@electrojs/runtime";

// Define tokens with their TypeScript type
export const API_BASE_URL = createInjectionToken<string>("API_BASE_URL");
export const APP_CONFIG = createInjectionToken<AppConfig>("APP_CONFIG");

// Register their values in a module
@Module({
    providers: [
        { provide: API_BASE_URL, useValue: process.env.API_URL ?? "http://localhost:3000" },
        { provide: APP_CONFIG, useValue: loadConfig() },
    ],
    exports: [API_BASE_URL, APP_CONFIG],
})
export class ConfigModule {}

// Inject them in any service that imports ConfigModule
@Injectable()
export class HttpService {
    private readonly baseUrl = inject(API_BASE_URL);
}

Never use raw strings as tokens. inject("authService" as any) loses all type information and breaks at runtime in non-obvious ways. Use class tokens or typed symbol tokens.


Provider Scopes ​

The scope controls how many instances of a provider exist.

singleton (default) ​

A single instance is created once and reused everywhere. This is the correct choice for stateless services, HTTP clients, and loggers.

ts
@Injectable() // singleton is the default
export class HttpService {}

@Injectable({ scope: "singleton" }) // explicit — same effect
export class Logger {}

transient ​

A new instance is created every time the provider is injected. Use this for stateful objects that must not be shared.

ts
@Injectable({ scope: "transient" })
export class FormValidationContext {
    readonly errors: ValidationError[] = [];
    addError(error: ValidationError) {
        this.errors.push(error);
    }
}

Warning: Injecting a transient provider in multiple places within the same class gives you multiple independent instances. This is usually what you want, but be aware of it.


Injector Hierarchy ​

The DI container is hierarchical. Each module gets its own injector, with the root injector as its ancestor. When inject() cannot find a token locally, it walks up the hierarchy.

Root Injector
  └── AppModule Injector
        ├── AuthModule Injector       (has AuthService, TokenCache)
        │     └── HttpModule Injector (has HttpService)
        └── WorkspaceModule Injector  (has WorkspaceService)

Resolution order for inject(HttpService) called from AuthModule:

  1. Check AuthModule injector → not found
  2. Check HttpModule injector → found ✅

You never need to manage this hierarchy manually — it is built from your module imports graph.


RuntimeInjector (Advanced) ​

For framework-level code or advanced testing scenarios, you can work with RuntimeInjector directly.

ts
import { RuntimeInjector } from "@electrojs/runtime";

// Create a standalone injector (e.g., for testing)
const injector = new RuntimeInjector();
injector.provide(ConfigService, new ConfigService());
injector.provide(API_BASE_URL, "http://localhost:3000");

// Create a child injector that inherits from parent
const childInjector = new RuntimeInjector(injector);
childInjector.provide(AuthService, new AuthService());

// Resolve
const auth = childInjector.get(AuthService); // from child
const config = childInjector.get(ConfigService); // inherited from parent

Custom resolvers ​

ts
injector.registerResolver(AuthService, () => {
    const config = injector.get(ConfigService);
    return new AuthService(config.getApiUrl());
});

Troubleshooting ​

Token not found in injector ​

The provider is not registered in any module that is imported.

ts
// Fix: add to providers and ensure the module is imported
@Module({
    providers: [AuthService], // ← must be here
    exports: [AuthService], // ← must be exported if used by other modules
})
export class AuthModule {}

inject() called outside injection context ​

You are calling inject() inside a plain function, callback, or event handler — not inside a framework-managed class.

ts
// ❌ Wrong
app.on("open-url", () => {
    const auth = inject(AuthService); // throws
});

// ✅ Correct: capture the dependency before the callback
@Module({ id: "auth" })
export class AuthModule {
    private readonly auth = inject(AuthService); // ← injected here

    async onInit() {
        app.on("open-url", (event, url) => {
            this.auth.handleDeepLink(url); // ← used here via captured reference
        });
    }
}

Circular dependency ​

Error: Circular dependency detected: AuthService → UserService → AuthService

Extract shared logic into a third provider that neither depends on the other.

ts
// ❌ Circular
class AuthService {
    user = inject(UserService);
}
class UserService {
    auth = inject(AuthService);
}

// ✅ Extract shared logic
class SessionStore {
    /* shared state, depends on nothing */
}
class AuthService {
    store = inject(SessionStore);
}
class UserService {
    store = inject(SessionStore);
}