Skip to content

Dependency Injection

CruzJS uses dependency injection (DI) to wire services together without hard-coding dependencies. The DI system is built on Inversify with a declarative @Module() layer on top.

Without DI, services directly instantiate their dependencies:

// Tightly coupled - hard to test, hard to swap implementations
class ProductService {
private db = new DatabaseClient();
private events = new EventEmitter();
}

With DI, dependencies are declared and injected by the container:

// Loosely coupled - testable, swappable
@Injectable()
class ProductService {
constructor(
@Inject(DRIZZLE) private readonly db: DrizzleDatabase,
@Inject(EventEmitterService) private readonly events: EventEmitterService,
) {}
}

This gives you:

  • Testability: Swap real services for mocks in tests.
  • Modularity: Features register their own services without touching framework code.
  • Singleton management: The container ensures one instance per service.
  • Decoupling: Services depend on tokens, not concrete implementations.

Every service class must be decorated with @Injectable() to be managed by the container:

import { Injectable } from '@cruzjs/core/di';
@Injectable()
export class ProductService {
// This class can now be injected and resolved
}

Without @Injectable(), the container cannot construct the class and will throw an error at resolution time.

Use @Inject() on constructor parameters to tell the container which dependency to provide:

import { Injectable, Inject } from '@cruzjs/core/di';
import { DRIZZLE, type DrizzleDatabase } from '@cruzjs/core/shared/database/drizzle.service';
@Injectable()
export class ProductService {
constructor(
@Inject(DRIZZLE) private readonly db: DrizzleDatabase,
) {}
}

The argument to @Inject() is a token — either a class reference or a symbol that identifies the binding.

Tokens are the keys the container uses to look up bindings. CruzJS supports two kinds:

Class tokens — the class itself serves as the token:

@Injectable()
export class ProductService {}
// Resolve by class reference
const service = container.resolve(ProductService);

Symbol tokens — for infrastructure services and interfaces:

import { DRIZZLE } from '@cruzjs/core/shared/database/drizzle.service';
// DRIZZLE is a Symbol token bound to the database instance
@Inject(DRIZZLE) private readonly db: DrizzleDatabase

The framework creates and configures the DI container during bootstrap. Access it with getAppContainer():

import { getAppContainer } from '@cruzjs/core';
const container = await getAppContainer();
// Resolve a service by class token
const service = container.resolve(ProductService);
// Resolve all implementations of a multi-injection token
const handlers = container.resolveAll(JOB_HANDLER);

These are the two methods you will use: resolve() for single bindings and resolveAll() for multi-injection tokens.

The most common pattern. The class is bound as a singleton by default:

@Module({
providers: [ProductService, InventoryService],
})
export class ProductModule {}

Override the default singleton scope:

@Module({
providers: [
{ provide: RequestContext, scope: 'transient' },
],
})
export class MyModule {}

Bind a token to a specific implementation:

@Module({
providers: [
{ provide: USER_HYDRATOR, useClass: UserProfileHydrator },
],
})
export class UserProfileModule {}

Bind a token to an already-constructed value:

@Module({
providers: [
{ provide: DRIZZLE, useValue: DrizzleService.getDb() },
],
})
export class SharedModule {}

Dynamically create a value with access to other services:

@Module({
providers: [
{
provide: CacheService,
useFactory: (config: ConfigService) =>
new CacheService(config.getOrThrow('CACHE_PREFIX')),
inject: [ConfigService],
},
],
})
export class MyModule {}

Point one token at another existing binding:

@Module({
providers: [
{ provide: 'DATABASE', useExisting: DRIZZLE },
],
})
export class MyModule {}
ScopeBehaviorDefault?Use Case
singletonOne instance for the entire app lifetimeYesStateless services, database connections
transientNew instance every time it is resolvedNoRequest-scoped contexts, stateful helpers
@Module({
providers: [
// Singleton (default) -- shared across all requests
ProductService,
// Explicit singleton
{ provide: ProductService, scope: 'singleton' },
// Transient -- new instance per resolution
{ provide: RequestContext, scope: 'transient' },
],
})
export class MyModule {}

Most services should be singletons. Use transient scope only when the service holds per-request state.

Multiple implementations can be registered under the same token using multi: true:

@Module({
providers: [
{ provide: JOB_HANDLER, useClass: SendEmailJobHandler, multi: true },
{ provide: JOB_HANDLER, useClass: EventListenerJobHandler, multi: true },
{ provide: JOB_HANDLER, useClass: CleanupJobHandler, multi: true },
],
})
export class JobModule {}

Resolve all implementations with @MultiInject() or container.resolveAll():

import { Injectable, MultiInject, Optional } from '@cruzjs/core/di';
import { JOB_HANDLER } from '@cruzjs/core/jobs';
@Injectable()
export class JobHandlerRegistry {
constructor(
@MultiInject(JOB_HANDLER) @Optional() private readonly handlers: JobHandler[] = [],
) {
for (const handler of this.handlers) {
this.register(handler);
}
}
}

The @Optional() decorator prevents an error if no implementations are registered.

Use @Optional() when a dependency might not be registered:

import { Injectable, Inject, Optional } from '@cruzjs/core/di';
@Injectable()
export class NotificationService {
constructor(
@Inject(SlackService) @Optional() private readonly slack?: SlackService,
) {}
async notify(message: string) {
if (this.slack) {
await this.slack.send(message);
}
}
}

These are the tokens and services you will use most often:

Token / ServiceTypeImportPurpose
DRIZZLEDrizzleDatabase@cruzjs/core/shared/database/drizzle.serviceDatabase instance
EventEmitterServiceEventEmitterService@cruzjs/core/shared/events/event-emitter.service.serverDispatch domain events
ConfigServiceConfigService@cruzjs/core/shared/config/config.serviceRead environment variables
StorageServiceStorageService@cruzjs/coreFile storage (R2/local)
LoggerLogger@cruzjs/coreStructured logging
JobServiceJobService@cruzjs/coreEnqueue background jobs
JOB_HANDLERJobHandler@cruzjs/core/jobsMulti-injection token for job handlers
USER_HYDRATORIUserHydrator@cruzjs/coreHydrate user session data
OrgServiceOrgService@cruzjs/startOrganization management
MemberServiceMemberService@cruzjs/startOrg member management
BillingServiceBillingService@cruzjs/proSubscription management

tRPC routers are plain functions, not classes, so they cannot use constructor injection. Instead, resolve services from the container inside each procedure:

import { getAppContainer } from '@cruzjs/core';
import { router, orgProcedure } from '@cruzjs/core/trpc/context';
import { ProductService } from './product.service';
export const productRouter = router({
list: orgProcedure.query(async ({ ctx }) => {
const container = await getAppContainer();
const service = container.resolve(ProductService);
return service.list(ctx.org.orgId);
}),
create: orgProcedure
.input(createProductSchema)
.mutation(async ({ ctx, input }) => {
const container = await getAppContainer();
const service = container.resolve(ProductService);
return service.create(ctx.org.orgId, ctx.org.userId, input);
}),
});

Full Example: Injecting Multiple Dependencies

Section titled “Full Example: Injecting Multiple Dependencies”
import { Injectable, Inject } from '@cruzjs/core/di';
import { DRIZZLE, type DrizzleDatabase } from '@cruzjs/core/shared/database/drizzle.service';
import { EventEmitterService } from '@cruzjs/core/shared/events/event-emitter.service.server';
import { ConfigService } from '@cruzjs/core/shared/config/config.service';
import { eq, desc } from 'drizzle-orm';
import { products } from './product.schema';
import { ProductCreatedEvent } from './events';
@Injectable()
export class ProductService {
constructor(
@Inject(DRIZZLE) private readonly db: DrizzleDatabase,
@Inject(EventEmitterService) private readonly events: EventEmitterService,
@Inject(ConfigService) private readonly config: ConfigService,
) {}
async list(orgId: string): Promise<Product[]> {
return this.db
.select()
.from(products)
.where(eq(products.orgId, orgId))
.orderBy(desc(products.createdAt));
}
async create(orgId: string, userId: string, input: CreateProductInput): Promise<Product> {
const [product] = await this.db
.insert(products)
.values({
orgId,
createdById: userId,
name: input.name,
description: input.description,
})
.returning();
await this.events.dispatch(
new ProductCreatedEvent(product.id, orgId, userId, product.name),
);
return product;
}
}
  1. Always use @Injectable() on service classes.
  2. Always use @Inject() on constructor parameters.
  3. Never use new to instantiate a service — resolve it from the container.
  4. Default to singleton scope unless the service holds per-request state.
  5. Use @Module() to register services rather than manual container bindings. Use the modules array in createCruzApp() to register modules.
  6. Use getAppContainer() in functional routers to resolve services. Use @Inject() property injection in OOP routers.
  7. Use symbol tokens (like DRIZZLE) for infrastructure; use class tokens for application services.