ExisJS

Dependency Injection and Providers

Providers in ExisJS represent classes and values managed by the Inversion of Control (IoC) Dependency Injection (DI) container. Services, repositories, configuration factories, and database clients can be registered as providers and injected into controllers, routes, and other services.


Defining Injectable Services

To make a class discoverable by the IoC container, annotate it with the @Injectable() decorator.

src/http/users/service.ts
import { Injectable } from 'exisjs/decorators'
export interface User {  id: string  name: string  email: string}
@Injectable()export class UsersService {  private users: User[] = []
  async findAll(): Promise<User[]> {    return this.users  }
  async create(user: User): Promise<User> {    this.users.push(user)    return user  }}

Injecting Dependencies

ExisJS supports three injection patterns:

  1. Contextual Injection (inject()): Used in functional route handlers, middleware, and boundaries.
  2. Constructor Injection: Automatically resolves types from constructor parameters in OOP classes.
  3. Property Field Injection (@Inject()): Injects dependencies directly into class properties.

Functional Route Context (inject())

In the functional paradigm, call inject(Token) inside route handlers, middleware, or boundaries. The container resolves the dependency from the active execution context:

src/http/users/route.ts
import { controller, route } from 'exisjs/router'import { inject } from 'exisjs/di'import { UsersService } from './service'
export default controller({  list: route.get('/', {    async handle({ res }) {      const usersService = inject(UsersService)      const users = await usersService.findAll()      res.json({ users })    },  }),})

OOP Constructor and Property Injection

In class-based controllers and services, constructor dependencies are injected by token. Mark each parameter with @Inject(Token); a class is its own token. ExisJS does not rely on TypeScript's emitted type metadata (the tsx/esbuild toolchain does not produce it), so an undecorated parameter on a controller or @Injectable() class fails with a clear error instead of receiving undefined:

src/http/users/route.ts
import { Controller, Get, Post, Body } from 'exisjs/decorators'import { Inject, Optional } from 'exisjs/di'import { UsersService, User } from './service'import { MetricsService } from '../../services/metrics.service'
@Controller('/users')export default class UsersController {  // Property injection with an optional token  @Inject('METRICS_SERVICE')  @Optional()  private metrics?: MetricsService
  // Constructor injection: the class is the token  constructor(@Inject(UsersService) private usersService: UsersService) {}
  @Get('/')  async list() {    this.metrics?.track('get_users')    return this.usersService.findAll()  }
  @Post('/')  async create(@Body() user: User) {    return this.usersService.create(user)  }}

Provider Tokens and Custom Providers

By default, passing a class constructor to the container creates an instance of that class. You can also register custom providers using string or symbol tokens, explicit values, factory functions with injected dependencies, or token aliases via app.provide() or module provider arrays.

Provider Types

Provider FormSyntaxDescription
Class ProviderUsersService or { provide: 'IUsersService', useClass: UsersService }Instantiates and manages a class constructor.
Value Provider{ provide: 'CONFIG', useValue: value }Injects a static value, configuration object, or third-party client.
Factory Provider{ provide: 'DB', useFactory: (cfg) => connect(cfg), inject: ['CONFIG'] }Evaluates a factory function with automatically resolved dependencies.
Existing / Alias Provider{ provide: 'DB_ALIAS', useExisting: 'DB' }Maps an alias token directly to another registered provider token.

Registering Custom Providers in Application Startup

Functional Paradigm

src/http/server.ts
import { exis } from 'exisjs'import { UsersService } from './users/service'
export default exis({  async onStart(app) {    // 1. Standard class provider    app.provide(UsersService, UsersService)
    // 2. Value provider with single-object syntax    app.provide({      provide: 'DATABASE_URL',      useValue: process.env.DATABASE_URL || 'postgres://localhost:5432/mydb',    })
    // 3. Factory provider with dependency injection    app.provide({      provide: 'DATABASE_CLIENT',      useFactory: (dbUrl: string) => ({        url: dbUrl,        query: (sql: string) => `Executed ${sql}`,      }),      inject: ['DATABASE_URL'],    })
    // 4. Existing alias provider    app.provide({      provide: 'DB',      useExisting: 'DATABASE_CLIENT',    })  },})

Class-Based (OOP) Paradigm

src/http/server.ts
import { Server } from 'exisjs/decorators'import { UsersService } from './users/service'
@Server({  providers: [    UsersService,    { provide: 'DATABASE_URL', useValue: 'postgres://localhost:5432/mydb' },    {      provide: 'DATABASE_CLIENT',      useFactory: (dbUrl: string) => ({ url: dbUrl }),      inject: ['DATABASE_URL'],    },    { provide: 'DB', useExisting: 'DATABASE_CLIENT' },    { provide: 'IUsersService', useClass: UsersService },  ],})export default class RootServer {}

Injection Scopes

ExisJS supports three provider lifetime scopes configured via @Injectable({ scope }) or the scope option in custom provider definitions:

ScopeIdentifierLifetime Behavior
Singleton (Default)'singleton'A single instance is cached globally and shared across all incoming requests.
Request'request'A new instance is instantiated for each incoming HTTP request and destroyed when the request completes.
Transient'transient'A new instance is created every time the dependency is injected or resolved.
src/services/context.service.ts
import { Injectable } from 'exisjs/decorators'
@Injectable({ scope: 'request' })export class RequestContextService {  public readonly requestId = crypto.randomUUID()  public readonly timestamp = new Date()}
DEFAULT

[!WARNING] Scopes propagate: a controller or service that depends on a request-scoped provider is itself created per request, so it can never keep one request's instance for the next. transient providers get a new instance for every injection.

DEFAULT

Request Scope Lifecycle: Request-scoped providers can only be resolved within an active HTTP request context. Attempting to resolve a request-scoped provider during boot scripts, background cron tasks, or standalone worker processes will throw a container error.


Circular Dependencies (forwardRef)

When two providers depend on each other, direct TypeScript constructor references cause ReferenceError during module loading. Wrap the injection token in forwardRef() to resolve circular dependencies using lazy proxy instances:

src/services/order.service.ts
import { Injectable } from 'exisjs/decorators'import { Inject, forwardRef } from 'exisjs/di'import { InventoryService } from './inventory.service'
@Injectable()export class OrderService {  constructor(    @Inject(forwardRef(() => InventoryService))    private inventoryService: InventoryService  ) {}
  async processOrder(orderId: string) {    return this.inventoryService.reserveStock(orderId)  }}
src/services/inventory.service.ts
import { Injectable } from 'exisjs/decorators'import { Inject, forwardRef } from 'exisjs/di'import { OrderService } from './order.service'
@Injectable()export class InventoryService {  constructor(    @Inject(forwardRef(() => OrderService))    private orderService: OrderService  ) {}
  async reserveStock(orderId: string) {    // Inventory reservation logic    return { reserved: true, orderId }  }}

Service Lifecycle Hooks

Providers can implement OnInit and OnDestroy lifecycle interfaces to handle asynchronous resource initialization and graceful cleanup:

src/services/database.service.ts
import { Injectable, type OnInit, type OnDestroy } from 'exisjs/decorators'
@Injectable()export class DatabaseService implements OnInit, OnDestroy {  async onInit(): Promise<void> {    // Triggered automatically during application startup    await this.connectConnectionPool()  }
  async onDestroy(): Promise<void> {    // Triggered automatically during graceful shutdown (SIGINT / SIGTERM)    await this.closeConnectionPool()  }
  private async connectConnectionPool() {    // Connection logic  }
  private async closeConnectionPool() {    // Teardown logic  }}

Method Decorator Enhancements

ExisJS provides method decorators to add caching, transactions, and execution profiling to service methods:

src/services/roles.service.ts
import { Injectable, Cacheable, CacheEvict, Transactional, Profile } from 'exisjs/decorators'import { DatabaseService } from './database.service'
@Injectable()export class RolesService {  constructor(@Inject(DatabaseService) private db: DatabaseService) {}
  // Caches method results using an LRU cache  @Cacheable({ ttl: 300, key: (id: string) => `role:${id}` })  @Profile({ thresholdMs: 50, warnSlow: true })  async findById(id: string) {    return this.db.query('SELECT * FROM roles WHERE id = $1', [id])  }
  // Wraps operations in an atomic database transaction and evicts cache keys  @Transactional()  @CacheEvict({ key: (id: string) => `role:${id}` })  async updateRole(id: string, name: string) {    return this.db.query('UPDATE roles SET name = $1 WHERE id = $2', [name, id])  }}

Database Providers & Lifecycle Management (exisjs/database)

The exisjs/database package coordinates database connections, health checks, transactions, and graceful connection draining.

1. Connection Lifecycle Registration (registerDatabase)

Registering databases with registerDatabase ensures connections are initialized during server boot and drained gracefully on application shutdown without lingering open sockets:

src/http/server.ts
import { exis } from 'exisjs'import { registerDatabase } from 'exisjs/database'import mongoose from 'mongoose'
export default exis({  async onStart(app) {    registerDatabase(app, {      name: 'mongodb',      connect: async () => {        await mongoose.connect(process.env.MONGODB_URI!)      },      disconnect: async () => {        await mongoose.disconnect()      },      healthCheck: async () => {        return mongoose.connection.readyState === 1      },    })  },})

2. Universal Transaction Runner (withTransaction)

withTransaction provides a unified transaction wrapper compatible with Mongoose, Prisma, Drizzle, and generic SQL connection clients:

src/services/order.service.ts
import { withTransaction } from 'exisjs/database'import mongoose from 'mongoose'import { Order } from '../models/order.model'import { Inventory } from '../models/inventory.model'
export async function placeOrder(customerId: string, items: { sku: string; qty: number }[]) {  return withTransaction(mongoose, async (session) => {    const order = await Order.create([{ customerId, items }], { session })
    for (const item of items) {      const updated = await Inventory.findOneAndUpdate(        { sku: item.sku, stock: { $gte: item.qty } },        { $inc: { stock: -item.qty } },        { session, returnDocument: 'after' }      )      if (!updated) {        throw new Error(`Insufficient inventory for SKU: ${item.sku}`)      }    }
    return order[0]  })}

3. Modern Mongoose Interoperability

Use mongoPoolOptions and modernUpdateOptions to standardize connection pools and query behavior:

src/models/user.model.ts
import { modernUpdateOptions, mongoPoolOptions } from 'exisjs/database'import mongoose from 'mongoose'
export const poolOptions = mongoPoolOptions({ maxPoolSize: 50 })
export async function updateUser(id: string, name: string) {  return mongoose.model('User').findByIdAndUpdate(    id,    { $set: { name } },    modernUpdateOptions({ returnDocument: 'after' })  )}