Skip to content

Dependency Injection

Asena provides a powerful IoC (Inversion of Control) container with field-based dependency injection. This allows you to inject services, repositories, and other components into your classes automatically.

What is Dependency Injection?

Dependency Injection (DI) is a design pattern where dependencies are provided to a class rather than the class creating them itself. This promotes:

  • Loose Coupling: Classes don't need to know how to create their dependencies
  • Testability: Easy to mock dependencies in tests
  • Maintainability: Change implementations without modifying dependent code
  • Reusability: Share instances across your application

Basic Injection with @Inject

The @Inject decorator is used to inject dependencies into your classes.

Simple Class Injection

typescript
import { Controller } from '@asenajs/asena/decorators';
import { Inject } from '@asenajs/asena/decorators/ioc';
import { Delete, Get, Post, Put } from '@asenajs/asena/decorators/http';
import type { Context } from '@asenajs/ergenecore';
import { UserService } from '../services/UserService';

@Controller('/users')
export class UserController {
  @Inject(UserService)
  private userService: UserService;

  @Get('/')
  async list(context: Context) {
    const users = await this.userService.getAllUsers();
    return context.send({ users });
  }
}

String-Based Injection

You can also inject services using their registered name as a string. This is useful when you want to decouple your code from concrete implementations or when working with dynamically registered services.

First, register your service with a custom name:

Name Your Components for String-Based Injection

A component is registered under its name if you give one, and under its class name otherwise - so @Inject('UserService') already resolves an unnamed @Service() class called UserService.

Why give an explicit name anyway? The Bun bundler may rename classes during a production build, and the container key would change with it. An explicit @Service('UserService') pins the key so string injection keeps working after minification.

Inject services by their registered name:

typescript
import { Service } from '@asenajs/asena/decorators';

@Service('UserService')
export class UserService {
  getAllUsers() {
    return [{ id: 1, name: 'John' }];
  }
}

// Inject by string name
@Controller('/users')
export class UserController {
  @Inject('UserService')
  private userService: UserService;

  @Get('/')
  async list(context: Context) {
    const users = this.userService.getAllUsers();
    return context.send({ users });
  }
}

Class vs String Injection

  • Class-based - Type-safe, refactor-friendly (recommended)
  • String-based - Loose coupling, dynamic resolution

A missing name names its caller

When nothing is registered under the key, the container reports which component asked for it:

'UserService' is not registered (injected into UserController.userService)

The original 'UserService' is not registered is attached as cause. In a test, createTestApp catches the same mistake earlier still — before the boot rather than during it — because a name-injected dependency is the one edge its component walk cannot follow.

Injection with Expressions

Expressions allow you to transform the injected dependency or extract specific properties.

Extract Property from Service

typescript
import { Inject } from '@asenajs/asena/decorators/ioc';
import { DatabaseService } from '../services/DatabaseService';
import type { BunSQLDatabase } from 'drizzle-orm/bun-sql';

@Service()
export class UserRepository {
  // Inject the 'connection' property from DatabaseService
  @Inject(DatabaseService, (service: DatabaseService) => service.connection)
  protected db: BunSQLDatabase;

  async findAll() {
    return await this.db.select().from(users);
  }
}

Extract Method Result

typescript
@Service()
export class ProductController {
  // Inject the result of calling getItems()
  @Inject(ItemService, (service: ItemService) => service.getItems())
  private items: string[];

  @Get('/items')
  getItems(context: Context) {
    return context.send({ items: this.items });
  }
}

Complex Transformations

typescript
@Service()
export class ConfigService {
  getConfig() {
    return {
      apiUrl: 'https://api.example.com',
      timeout: 5000,
      retries: 3
    };
  }
}

@Service()
export class ApiService {
  // Extract only the apiUrl from config
  @Inject(ConfigService, (service: ConfigService) => service.getConfig().apiUrl)
  private apiUrl: string;

  async fetchData() {
    const response = await fetch(`${this.apiUrl}/data`);
    return response.json();
  }
}

Expression Signature

typescript
@Inject(ServiceClass, (service: ServiceType) => any)
ParameterTypeDescription
ServiceClassClass or stringService to inject
expression(service) => anyOptional transformation function

@Value: configuration injection

@Inject wires collaborators. @Value wires configuration: it reads a field's value from process.env when the container builds the component, so a service does not have to reach for process.env itself — which is what makes the value hard to see and hard to replace in a test.

typescript
import { Service } from '@asenajs/asena/decorators';
import { Inject, Value } from '@asenajs/asena/decorators/ioc';

@Service()
export class PoolService {
  @Inject(DataSource)
  private dataSource: DataSource;

  @Value('DB_POOL_MAX', { parse: Number, default: 10 })
  private poolMax: number;

  @Value('JWT_SECRET')          // required: no default
  private jwtSecret: string;
}

Options

typescript
@Value(key: string, options?: {
  default?: unknown;               // used when the variable is not set
  parse?: (raw: string) => unknown; // converts the raw string
})
OptionBehaviour
parseRuns on the raw environment string only. parse: Number, parse: (v) => v === 'true', parse: JSON.parse — anything that takes a string. A default is used as given and never goes through parse.
defaultIts presence is what counts, not its truthiness — 0, '' and null are honoured as defaults.

Required values fail loudly

A field with no default whose variable is unset fails the component's construction with an error naming the class, the field and the key:

@Value('JWT_SECRET') on PoolService.jwtSecret: environment variable is not set and no default was given

For a singleton that happens at registration, so a misconfigured deployment cannot boot; for a transient it happens on the first resolve. Either way the application never runs with the value quietly undefined.

Precedence

Field initializer > environment. An initializer that produced a value is left alone, exactly as it is for @Inject:

typescript
@Value('REGION', { default: 'eu-central-1' })
private region: string = 'local';   // stays 'local' - the environment is not consulted

In a test, mockComponent adds one more level in front: override > initializer > environment.

@Value fields are writable

Unlike @Inject and @Strategy, which install accessors with no setter, @Value lands as a plain writable property. There is no resolved service behind it to protect, so a test can assign to it directly.

Inheritance follows the same rule as @Inject: a field redeclared in a subclass wins over the base class's declaration. See Inheritance.

Strategy Pattern with @Strategy

The @Strategy decorator injects all implementations of an interface, enabling the Strategy design pattern.

1. Define Interface

typescript
// src/services/NotificationService.ts
export interface NotificationService {
  send(userId: string, message: string): Promise<void>;
}

2. Implement Multiple Strategies

Mark implementations with @Implements:

typescript
// src/services/EmailNotificationService.ts
import { Service } from '@asenajs/asena/decorators';
import { Implements } from '@asenajs/asena/decorators/ioc';
import type { NotificationService } from './NotificationService';

@Service()
@Implements('NotificationService')
export class EmailNotificationService implements NotificationService {
  async send(userId: string, message: string): Promise<void> {
    console.log(`Sending email to user ${userId}: ${message}`);
    // Email sending logic
  }
}
typescript
// src/services/SmsNotificationService.ts
import { Service } from '@asenajs/asena/decorators';
import { Implements } from '@asenajs/asena/decorators/ioc';
import type { NotificationService } from './NotificationService';

@Service()
@Implements('NotificationService')
export class SmsNotificationService implements NotificationService {
  async send(userId: string, message: string): Promise<void> {
    console.log(`Sending SMS to user ${userId}: ${message}`);
    // SMS sending logic
  }
}
typescript
// src/services/PushNotificationService.ts
import { Service } from '@asenajs/asena/decorators';
import { Implements } from '@asenajs/asena/decorators/ioc';
import type { NotificationService } from './NotificationService';

@Service()
@Implements('NotificationService')
export class PushNotificationService implements NotificationService {
  async send(userId: string, message: string): Promise<void> {
    console.log(`Sending push notification to user ${userId}: ${message}`);
    // Push notification logic
  }
}

3. Inject All Implementations

typescript
import { Strategy } from '@asenajs/asena/decorators/ioc';
import type { NotificationService } from '../services/NotificationService';

@Service()
export class NotificationManager {
  @Strategy('NotificationService')
  private notificationServices: NotificationService[];

  async notifyUser(userId: string, message: string) {
    // Send notification via all channels
    for (const service of this.notificationServices) {
      await service.send(userId, message);
    }
  }
}

Strategy with Expressions

Extract specific properties from all implementations:

typescript
interface PaymentProvider {
  name: string;
  process(amount: number): Promise<void>;
}

@Service()
@Implements('PaymentProvider')
export class StripeProvider implements PaymentProvider {
  name = 'Stripe';
  async process(amount: number) { /* ... */ }
}

@Service()
@Implements('PaymentProvider')
export class PayPalProvider implements PaymentProvider {
  name = 'PayPal';
  async process(amount: number) { /* ... */ }
}

// Inject only the names
@Service()
export class PaymentService {
  @Strategy('PaymentProvider', (provider: PaymentProvider) => provider.name)
  private providerNames: string[]; // ['Stripe', 'PayPal']

  getAvailableProviders() {
    return this.providerNames;
  }
}

How many implementations?

A @Strategy field is always an array, whatever the number of implementations:

@Implements('Key') componentsinjected value
0[]
1[implementation]
2+every implementation

Zero is a normal state, not a misconfiguration. @Strategy is the consumer side of a plugin point, and a plugin point with no plugins is how one starts — you write the consumer first and the implementations arrive per feature:

typescript
@Service()
export class NotificationManager {
  @Strategy('NotificationService')
  private notificationServices: NotificationService[]; // [] until the first @Implements exists

  async notifyUser(userId: string, message: string) {
    // Works from day one - iterates nothing until a channel is added
    for (const service of this.notificationServices) {
      await service.send(userId, message);
    }
  }
}

Because an empty key is legitimate, a mistyped interface name cannot fail at boot either — it would just stay empty forever. To keep that diagnosable, the container logs one line per injection site at startup, at debug level:

[IocEngine] Strategy key 'NotificationService' has no implementations - NotificationManager.notificationServices will be injected as []

Enable debug on your logger when a strategy collection is unexpectedly empty. Keys supplied through a test's overrides are not reported.

Upgrading from 0.9.0

Up to and including 0.9.0, a strategy key with no implementations aborted the boot with <key> is not registered, and a key with exactly one implementation injected a bare instance instead of an array — so .length, for...of and .map() all failed on it at runtime. If you worked around either of those, the workaround can go.

Lifecycle Hooks

@OnStart marks a method to be called when the server starts — after every dependency is injected and every component is constructed, but before the application is set up — so a @Config that builds something out of an injected service finds it already started — and long before the HTTP socket binds. @OnStop is its counterpart, called during server.stop().

Full reference

This section covers the injection side. Ordering, failure policies, signal handling and the headless worker pattern live on Component Lifecycle.

Upgrading from 0.9.x

@PostConstruct was renamed to @OnStart. It remains a deprecated alias writing the same metadata, so existing code keeps working and renaming the import is the whole migration.

The timing changed, and that part is breaking: the hook used to run inside Container.register(), mid-scan, while the rest of the graph was still being built. It now runs from server.start(). A component resolved from a server that was created but never started is therefore no longer initialised, and a throwing hook no longer calls process.exit(1) — it throws and server.start() rejects. See Upgrading from 0.9.x.

Basic Usage

typescript
import { Service } from '@asenajs/asena/decorators';
import { Inject } from '@asenajs/asena/decorators/ioc';
import { OnStart, OnStop } from '@asenajs/asena/decorators/ioc';

@Service()
export class UserService {
  @Inject(DatabaseService)
  private db: DatabaseService;

  private cache: Map<string, any>;

  @OnStart()
  async initialize() {
    // Called after all @Inject dependencies are resolved
    console.log('UserService initializing...');

    // Initialize cache
    this.cache = new Map();

    // Preload data
    const users = await this.db.getAllUsers();
    users.forEach(user => this.cache.set(user.id, user));

    console.log(`UserService initialized with ${users.length} cached users`);
  }

  getUser(id: string) {
    return this.cache.get(id);
  }

  @OnStop()
  async release() {
    this.cache.clear();
  }
}

Use Cases for @OnStart

1. Initialization Logic

typescript
@Service()
export class CacheService {
  private redis: RedisClient;

  @OnStart()
  async connect() {
    this.redis = await createRedisClient();
    console.log('Redis connection established');
  }

  @OnStop()
  async disconnect() {
    await this.redis?.close();
  }
}

2. Validation

typescript
@Service()
export class ApiKeyService {
  private apiKey = process.env.API_KEY;

  @OnStart()
  validate() {
    if (!this.apiKey || this.apiKey.length < 32) {
      throw new Error('Invalid API key configuration');
    }
  }
}

@Inject resolves components, not values

A string token names a registered component. There is no value/constant registry, so @Inject('ENV_API_KEY') throws ENV_API_KEY is not registered at startup. Read plain configuration values from process.env (or a @Service that wraps it) as above.

3. Setup with Injected Dependencies

typescript
@Service()
export class EventSubscriberService {
  @Inject(EventBus)
  private eventBus: EventBus;

  @OnStart()
  subscribeToEvents() {
    // Subscribe to events after EventBus is injected
    this.eventBus.on('user.created', this.handleUserCreated.bind(this));
    this.eventBus.on('user.updated', this.handleUserUpdated.bind(this));
  }

  private handleUserCreated(user: any) {
    console.log('User created:', user);
  }

  private handleUserUpdated(user: any) {
    console.log('User updated:', user);
  }
}

4. Data Preloading

typescript
@Service()
export class CountryService {
  @Inject(DatabaseService)
  private db: DatabaseService;

  private countries: Map<string, Country>;

  @OnStart()
  async preloadCountries() {
    const data = await this.db.query('SELECT * FROM countries');
    this.countries = new Map(data.map(c => [c.code, c]));
    console.log(`Preloaded ${this.countries.size} countries`);
  }

  getCountry(code: string): Country | undefined {
    return this.countries.get(code);
  }
}

Execution Order

Construction, per component:

  1. Class constructor runs
  2. All @Value fields are read from the environment
  3. All @Inject dependencies are resolved
  4. All @Strategy arrays are resolved (a separate pass)
  5. Registered @PostProcessors run

Then, once every component exists, from server.start():

  1. All @OnStart methods are called, in registration order — dependencies before dependents

And from server.stop(), in the reverse of that order:

  1. All @OnStop methods are called

Configuration lands before collaborators on purpose: a component missing a required @Value fails before the container starts resolving the graph underneath it.

A throwing @OnStart aborts the boot

The error is not swallowed. The components that already started are rolled back and server.start() rejects with an error naming the hook, carrying the original error as cause. Use @OnStart for setup that must succeed at boot (opening a connection pool, subscribing to a topic) and validate recoverable input elsewhere.

Up to 0.9.x the container caught the error, logged it, and called process.exit(1).

typescript
@Service()
export class ExampleService {
  @Inject(LoggerService)
  private logger: LoggerService;

  constructor() {
    console.log('1. Constructor called');
    // this.logger is undefined here!
  }

  @OnStart()
  initialize() {
    console.log('2. OnStart called');
    // this.logger is available here!
    this.logger.info('Service initialized');
  }
}

Async hooks

@OnStart and @OnStop methods can be async. Asena awaits @OnStart before the HTTP socket is bound, and awaits each @OnStop under a per-hook timeout (default 5s) during shutdown.

An @OnStart that never resolves is a start() that never resolves — a component with a run loop should start it and return, not await it. See Component Lifecycle.

Injected fields are read-only

@Inject and @Strategy install accessors with no setter, so assigning to one throws. The error names the field and the class and points at the overrides option or mockComponent() — reach for those instead of Object.assign(instance, { dep: fake }) in a test.

Service Scopes

Control the lifecycle of injected services with scopes.

Singleton Scope (Default)

One instance shared across the entire application:

typescript
import { Service } from '@asenajs/asena/decorators';

@Service() // Default: Scope.SINGLETON
export class ConfigService {
  private config = { apiUrl: 'https://api.example.com' };

  getConfig() {
    return this.config;
  }
}

Prototype Scope

New instance created for every injection:

typescript
import { Service } from '@asenajs/asena/decorators';
import { Scope } from '@asenajs/asena/decorators/ioc';

@Service({ scope: Scope.PROTOTYPE })
export class RequestLogger {
  private requestId = crypto.randomUUID();
  private logs: string[] = [];

  log(message: string) {
    this.logs.push(`[${this.requestId}] ${message}`);
  }

  getLogs() {
    return this.logs;
  }
}

When to Use PROTOTYPE

Use Scope.PROTOTYPE for:

  • Per-request state
  • Isolated instances
  • Testing scenarios

Injecting into Different Components

Controllers

typescript
@Controller('/products')
export class ProductController {
  @Inject(ProductService)
  private productService: ProductService;

  @Get('/')
  async list(context: Context) {
    const products = await this.productService.getAll();
    return context.send({ products });
  }
}

Services

typescript
@Service()
export class OrderService {
  @Inject(ProductService)
  private productService: ProductService;

  @Inject(PaymentService)
  private paymentService: PaymentService;

  async createOrder(items: any[]) {
    // Use injected services
    const products = await this.productService.validateItems(items);
    await this.paymentService.charge(products.total);
  }
}

Middleware

typescript
@Middleware()
export class AuthMiddleware extends MiddlewareService {
  @Inject(JwtService)
  private jwtService: JwtService;

  @Inject(UserService)
  private userService: UserService;

  async handle(context: Context, next: () => Promise<void>) {
    const token = context.req.headers['authorization'];
    const payload = this.jwtService.verify(token);
    const user = await this.userService.findById(payload.id);

    context.setValue('user', user);
    await next();
  }
}

Validators

typescript
@Middleware({ validator: true })
export class UniqueEmailValidator extends ValidationService {
  @Inject(UserRepository)
  private userRepo: UserRepository;

  async json() {
    return z.object({
      email: z.string().email().refine(async (email) => {
        const exists = await this.userRepo.findByEmail(email);
        return !exists;
      }, 'Email already in use')
    });
  }
}

WebSocket Services

typescript
@WebSocket({ path: '/chat', name: 'ChatSocket' })
export class ChatSocket extends AsenaWebSocketService<void> {
  @Inject(MessageService)
  private messageService: MessageService;

  protected async onMessage(ws: Socket, message: string) {
    await this.messageService.saveMessage(message);
    // Broadcast to all clients
  }
}

Registering components from packages

The component scan walks your sourceFolder. It never walks node_modules, so a component that ships inside a package — a shared platform library, OtelService, a database service your team publishes — is invisible to it. The imports option is how a package hands its components in:

typescript
import { AsenaServerFactory } from '@asenajs/asena';
import { platformComponents } from '@acme/asena-platform';
import { OtelService } from '@asenajs/asena-otel';

await AsenaServerFactory.create({
  adapter,
  logger,
  imports: [...platformComponents, OtelService],
});

Four rules define it:

  • imports adds, it never replaces. Whatever the scan, an explicit components list or the build found is registered as well. There is no configuration in which imports is the reason a component went missing.
  • Every entry must carry its own component decorator@Service, @Controller, @Middleware, … An undecorated class throws imports entry <Name> carries no component decorator, because a silently dropped import is exactly the failure this option exists to prevent.
  • The list is flattened one level, so a package can export an array of its components and you can spread or nest it.
  • Name collisions still fail. An import whose registered name clashes with a scanned class raises the usual Duplicate component name detected. Give the package component an explicit @Service('name') if that happens.

imports on its own is a valid component source: an application made only of packages — no sourceFolder to scan, no components list — boots instead of failing with No components or configuration found.

The same option exists on createTestApp.

Which source wins

imports sits beside the primary component source, which is picked in this order:

OrderSourceWhen it applies
1components: [...]You passed a non-empty list to AsenaServerFactory.create
2The build component listThe bundle was produced by asena build, which publishes its scanned classes on globalThis[Symbol.for('asena.buildComponents')] before your entry module evaluates
3The sourceFolder scanNeither of the above — the classic development path driven by asena-config.ts

Whichever wins, imports is registered on top of it.

A hand-written components: list is yours

Because an explicit list outranks the build list, a components: [...] array you wrote by hand survives asena build and wins. Earlier CLI versions rewrote that array during the build; they no longer touch your entry file at all. See asena build.

Reaching the container from outside

@Inject only works inside a component, and the entry file is not one. A migration runner, a one-off script, a bun --eval session against a booted server — none of them can declare a field the container will fill. For those, the server itself hands out components:

typescript
const server = await AsenaServerFactory.create({ adapter, logger });

await server.start();

const feed = await server.resolve<PriceFeed>('PriceFeed');

await feed.warmUp();

The name is the component's registered name: the class name by default, or whatever string you passed to @Service('name'). It is the same key @Inject('PriceFeed') takes, and the same signature the test harness exposes as app.resolve().

Three things worth knowing before you reach for it:

  • Resolve after start(), not after create(). create() builds the graph, but @OnStart runs in start() — a component pulled out in between is constructed and injected, yet its pool is unopened and its cache is empty.
  • An unknown name throws, with <name> is not registered. There is no undefined to check for.
  • A name shared by two classes resolves to an array, because the container promotes duplicate names rather than letting one silently win. See Inheritance for how a class ends up sharing its base's name.

Replaces coreContainer.container

server.coreContainer.container.resolve() also works, and up to 0.9.x it was what everyone found instead. server.resolve() is the supported spelling — prefer it.

Inside a component, keep using @Inject. Resolving by hand from a component works but hides the dependency from the graph, so the container can no longer order construction around it.

Complete Example

Combining all concepts:

typescript
// Interface
export interface StorageProvider {
  save(key: string, data: any): Promise<void>;
  get(key: string): Promise<any>;
}

// Implementations
@Service()
@Implements('StorageProvider')
export class RedisStorage implements StorageProvider {
  @Inject(RedisService, (service) => service.client)
  private redis: RedisClient;

  @OnStart()
  async connect() {
    await this.redis.connect();
    console.log('Redis storage ready');
  }

  @OnStop()
  async close() {
    await this.redis.close();
  }

  async save(key: string, data: any) {
    await this.redis.set(key, JSON.stringify(data));
  }

  async get(key: string) {
    const data = await this.redis.get(key);
    return JSON.parse(data);
  }
}

@Service()
@Implements('StorageProvider')
export class LocalStorage implements StorageProvider {
  private storage = new Map<string, any>();

  async save(key: string, data: any) {
    this.storage.set(key, data);
  }

  async get(key: string) {
    return this.storage.get(key);
  }
}

// Service using Strategy
@Service()
export class DataService {
  @Strategy('StorageProvider')
  private storageProviders: StorageProvider[];

  @OnStart()
  initialize() {
    console.log(`Loaded ${this.storageProviders.length} storage providers`);
  }

  async saveToAll(key: string, data: any) {
    // Save to all storage providers
    await Promise.all(
      this.storageProviders.map(provider => provider.save(key, data))
    );
  }

  async getFromFirst(key: string) {
    // Try each provider until one succeeds
    for (const provider of this.storageProviders) {
      try {
        return await provider.get(key);
      } catch (error) {
        continue;
      }
    }
    return null;
  }
}

Best Practices

1. Prefer Class-Based Injection

typescript
// ✅ Good: Type-safe
@Inject(UserService)
private userService: UserService;

// ⚠️ Only when necessary
@Inject('UserService')
private userService: any;

2. Use @OnStart for Setup, @OnStop to Release

typescript
// ✅ Good: Initialize after injection, release symmetrically
@OnStart()
async initialize() {
  this.connection = await this.setupConnection();
}

@OnStop()
async release() {
  await this.connection?.close();
}

// ❌ Bad: Dependencies not available in constructor
constructor() {
  await this.setupConnection(); // this.dependency is undefined!
}

3. Avoid Circular Dependencies

typescript
// ❌ Bad: Circular dependency
@Service()
class ServiceA {
  @Inject(ServiceB)
  private serviceB: ServiceB;
}

@Service()
class ServiceB {
  @Inject(ServiceA)
  private serviceA: ServiceA; // Circular!
}

// ✅ Good: Extract shared logic
@Service()
class SharedService { }

@Service()
class ServiceA {
  @Inject(SharedService)
  private shared: SharedService;
}

@Service()
class ServiceB {
  @Inject(SharedService)
  private shared: SharedService;
}

4. Use Expressions Wisely

typescript
// ✅ Good: Extract specific property
@Inject(DatabaseService, (s) => s.connection)
private db: Database;

// ❌ Bad: Too complex
@Inject(ConfigService, (s) => s.getConfig().db.primary.connection.pool)
private pool: any; // Hard to maintain

5. Document @Strategy Interfaces

typescript
// ✅ Good: Clear interface documentation
/**
 * Payment provider interface.
 * All implementations will be available via @Strategy('PaymentProvider')
 */
export interface PaymentProvider {
  name: string;
  process(amount: number): Promise<PaymentResult>;
}

Released under the MIT License.