Web Engineering ·

Dependency Injection in TypeScript: Containers, Scoped Services, and Testing Patterns for Production

A practical guide to dependency injection in TypeScript backends, covering manual constructor injection, lightweight containers like tsyringe and InversifyJS, framework-native DI in NestJS, request-scoped services in serverless, and testing patterns that avoid excessive mocking.

Dependency Injection in TypeScript: Containers, Scoped Services, and Testing Patterns for Production

Most DI tutorials show you how to wire up a container. Few explain what breaks when you don’t use one, or what breaks when you use the wrong one for your context. This article covers both: the mechanics of DI in TypeScript, the tradeoffs between approaches, and the production patterns that actually hold up.

The problem DI is solving

The concrete problem is not “coupling.” That is too abstract to act on. The concrete problem is one of these:

Test setup is impossible without side effects. Your UserService creates its own database client. To test createUser, you either need a live database or you start monkeypatching module internals. Both paths are painful at scale.

Lifetime management is invisible. You create a Stripe client in three different places. You create a Postgres pool in five. There is no single place to set configuration, observe connections, or shut down cleanly.

Configuration is scattered. Environment variables are read inside individual service constructors, which means configuration errors surface at call time, not at startup.

DI is primarily a solution to these three problems, not an architectural ideology.

Manual constructor injection: the baseline

Before reaching for a container, understand what constructor injection looks like without one. For many services, this is sufficient.

// config.ts
export interface AppConfig {
  databaseUrl: string;
  stripeSecretKey: string;
  redisUrl: string;
}

// database.ts
import { Pool } from 'pg';

export class DatabaseClient {
  private pool: Pool;

  constructor(connectionString: string) {
    this.pool = new Pool({ connectionString, max: 10 });
  }

  async query<T>(sql: string, params?: unknown[]): Promise<T[]> {
    const result = await this.pool.query(sql, params);
    return result.rows as T[];
  }

  async close(): Promise<void> {
    await this.pool.end();
  }
}

// user-repository.ts
export class UserRepository {
  constructor(private db: DatabaseClient) {}

  async findById(id: string): Promise<User | null> {
    const rows = await this.db.query<User>(
      'SELECT * FROM users WHERE id = $1',
      [id]
    );
    return rows[0] ?? null;
  }
}

// user-service.ts
export class UserService {
  constructor(
    private users: UserRepository,
    private stripe: StripeClient,
  ) {}

  async createSubscription(userId: string, planId: string): Promise<Subscription> {
    const user = await this.users.findById(userId);
    if (!user) throw new Error(`User ${userId} not found`);
    return this.stripe.createSubscription(user.stripeCustomerId, planId);
  }
}

// composition root (main.ts or app.ts)
const config: AppConfig = {
  databaseUrl: process.env.DATABASE_URL!,
  stripeSecretKey: process.env.STRIPE_SECRET_KEY!,
  redisUrl: process.env.REDIS_URL!,
};

const db = new DatabaseClient(config.databaseUrl);
const userRepo = new UserRepository(db);
const stripe = new StripeClient(config.stripeSecretKey);
const userService = new UserService(userRepo, stripe);

export { userService };

The key discipline here is the composition root: one place where all dependencies are created and wired. Everything downstream receives dependencies through constructors; nothing constructs its own dependencies.

This scales well to 10-20 services. Beyond that, the wiring boilerplate gets tedious and the dependency graph is hard to reason about.

Where manual wiring breaks down

Three failure modes appear as the codebase grows:

Transitive dependencies multiply. If OrderService needs UserService, InventoryService, and PaymentService, and each of those has three dependencies, instantiating OrderService in a test file means writing 10+ lines of construction before you write a single assertion.

Lifetime management diverges. A database pool should be a singleton. A request-scoped logger should be created per request. If you wire everything manually in one place, you either ignore lifetimes (everything becomes a singleton by accident) or you build your own scope management, which is what containers do.

Circular dependencies are undetectable. Manual wiring fails at runtime, not at startup. A container detects circular dependencies eagerly when the graph is built.

tsyringe: lightweight container with decorators

tsyringe is a lightweight DI container from Microsoft. It uses decorators and reflect-metadata, which requires experimentalDecorators: true and emitDecoratorMetadata: true in tsconfig.json.

// tsconfig.json additions
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}
import 'reflect-metadata';
import { injectable, inject, container } from 'tsyringe';

// Token-based injection for non-class dependencies (config, primitives)
const CONFIG_TOKEN = 'AppConfig';

@injectable()
export class DatabaseClient {
  private pool: Pool;

  constructor(@inject(CONFIG_TOKEN) config: AppConfig) {
    this.pool = new Pool({ connectionString: config.databaseUrl, max: 10 });
  }

  async query<T>(sql: string, params?: unknown[]): Promise<T[]> {
    const result = await this.pool.query(sql, params);
    return result.rows as T[];
  }
}

@injectable()
export class UserRepository {
  constructor(private db: DatabaseClient) {}

  async findById(id: string): Promise<User | null> {
    const rows = await this.db.query<User>(
      'SELECT * FROM users WHERE id = $1',
      [id]
    );
    return rows[0] ?? null;
  }
}

@injectable()
export class UserService {
  constructor(
    private users: UserRepository,
    private stripe: StripeClient,
  ) {}
}

// Composition root
container.registerInstance(CONFIG_TOKEN, {
  databaseUrl: process.env.DATABASE_URL!,
  stripeSecretKey: process.env.STRIPE_SECRET_KEY!,
  redisUrl: process.env.REDIS_URL!,
});

// Singletons
container.registerSingleton(DatabaseClient);
container.registerSingleton(StripeClient);

// Transient (new instance per resolve)
container.register(UserService, { useClass: UserService });

const userService = container.resolve(UserService);

tsyringe resolves the full dependency graph when you call resolve. If a dependency is missing or circular, it throws immediately, not at the first call site.

For testing, you create a child container and register mocks:

import { container } from 'tsyringe';
import { describe, it, expect, vi, beforeEach } from 'vitest';

describe('UserService', () => {
  let testContainer: typeof container;

  beforeEach(() => {
    testContainer = container.createChildContainer();

    // Register a real config
    testContainer.registerInstance(CONFIG_TOKEN, testConfig);

    // Register a mock repository
    testContainer.registerInstance(UserRepository, {
      findById: vi.fn().mockResolvedValue({
        id: 'user-1',
        stripeCustomerId: 'cus_test',
      }),
    });

    // Register a mock Stripe client
    testContainer.registerInstance(StripeClient, {
      createSubscription: vi.fn().mockResolvedValue({ id: 'sub_test' }),
    });
  });

  it('creates a subscription for a known user', async () => {
    const service = testContainer.resolve(UserService);
    const result = await service.createSubscription('user-1', 'plan-pro');
    expect(result.id).toBe('sub_test');
  });
});

The child container inherits registrations from the parent but can override them. Tests never touch the live database or Stripe API.

InversifyJS: more explicit, more control

InversifyJS is more verbose than tsyringe but gives you finer control over binding types, middleware, and container hierarchies. It is the better choice when you need custom provider factories or container-level interceptors (logging, tracing, authorization checks at resolve time).

import 'reflect-metadata';
import { Container, injectable, inject } from 'inversify';

const TYPES = {
  Config: Symbol.for('Config'),
  DatabaseClient: Symbol.for('DatabaseClient'),
  UserRepository: Symbol.for('UserRepository'),
  UserService: Symbol.for('UserService'),
};

@injectable()
class DatabaseClient {
  constructor(@inject(TYPES.Config) private config: AppConfig) {
    this.pool = new Pool({ connectionString: config.databaseUrl });
  }
}

@injectable()
class UserRepository {
  constructor(@inject(TYPES.DatabaseClient) private db: DatabaseClient) {}
}

// Binding
const ioc = new Container();

ioc.bind<AppConfig>(TYPES.Config).toConstantValue(loadConfig());
ioc.bind<DatabaseClient>(TYPES.DatabaseClient).to(DatabaseClient).inSingletonScope();
ioc.bind<UserRepository>(TYPES.UserRepository).to(UserRepository).inRequestScope();
ioc.bind<UserService>(TYPES.UserService).to(UserService).inTransientScope();

The explicit symbol map (TYPES) is verbose, but it eliminates the decorator metadata ambiguity that tsyringe sometimes hits with interface types. For large monorepos where multiple packages share a container, the symbol-based approach is safer.

Request-scoped services in serverless

The lifetime management problem gets interesting in serverless environments. In a long-running Node.js server, request scope maps cleanly to an HTTP request lifetime. In AWS Lambda or Cloudflare Workers, the execution model is different.

Lambda: Each invocation may reuse the same container (warm start) or create a new one (cold start). If you store request-scoped state in a singleton, it leaks across invocations in the same execution environment.

The pattern that works reliably is to scope request state to the handler call, not to the container:

// services/request-context.ts
export interface RequestContext {
  requestId: string;
  userId: string | null;
  startedAt: number;
}

// Create this per-invocation, pass it down
export function createRequestContext(event: APIGatewayEvent): RequestContext {
  return {
    requestId: event.requestContext.requestId,
    userId: extractUserId(event),
    startedAt: Date.now(),
  };
}

// handler.ts
const db = new DatabaseClient(process.env.DATABASE_URL!);  // singleton, outside handler
const userRepo = new UserRepository(db);                   // singleton

export const handler = async (event: APIGatewayEvent) => {
  const ctx = createRequestContext(event);
  const logger = new RequestLogger(ctx.requestId);       // scoped to this call

  // Pass ctx explicitly to services that need it
  const userService = new UserService(userRepo, logger, ctx);
  return userService.handleRequest(event);
};

The database pool and repository are created once per Lambda container (outside the handler function). The request logger and context are created per invocation. This is manual scope management, but it is explicit and predictable.

Cloudflare Workers: Workers do not share state between requests by default. The entire module scope resets between isolate instantiations. This means you can safely use module-level singletons for clients that are safe to reuse (HTTP clients, Stripe SDK instances), but you cannot rely on warm-start reuse the way Lambda allows.

// worker.ts
import { StripeClient } from './stripe';
import { UserService } from './user-service';

// These are recreated per isolate instantiation, not per request
// but Stripe client is stateless, so this is fine
const stripe = new StripeClient(/* env injected at runtime */);

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // env is the binding context (secrets, KV, D1)
    const db = new D1DatabaseClient(env.DB);
    const userRepo = new UserRepository(db);
    const userService = new UserService(userRepo, stripe);
    return userService.handle(request);
  },
};

Note that in Workers, env is only available inside the fetch handler, so database clients that depend on env must be constructed per request. Clients that only need configuration available at module level (API keys passed as constructor args) can be singletons.

NestJS: framework-native DI

If you are using NestJS, its DI system is the right choice. Building your own container alongside NestJS creates two competing ownership models for the same objects.

NestJS uses modules as the container scope boundary. Dependencies registered in a module are available within that module; they must be explicitly exported to be used by other modules.

// database.module.ts
@Module({
  providers: [
    {
      provide: DatabaseClient,
      useFactory: (config: ConfigService) =>
        new DatabaseClient(config.get('DATABASE_URL')!),
      inject: [ConfigService],
    },
  ],
  exports: [DatabaseClient],
})
export class DatabaseModule {}

// user.module.ts
@Module({
  imports: [DatabaseModule],
  providers: [UserRepository, UserService],
  exports: [UserService],
})
export class UserModule {}

// user.service.ts
@Injectable()
export class UserService {
  constructor(
    private users: UserRepository,
    private stripe: StripeService,
  ) {}
}

For request-scoped services in NestJS, use scope: Scope.REQUEST:

@Injectable({ scope: Scope.REQUEST })
export class RequestLogger {
  constructor(@Inject(REQUEST) private request: Request) {}

  log(message: string) {
    console.log({ requestId: this.request.headers['x-request-id'], message });
  }
}

REQUEST-scoped providers have a cost: NestJS creates a new instance for every HTTP request and every service that depends on them. For high-throughput routes, this adds allocation pressure. Profile before making a commonly-used service request-scoped. Prefer passing a request context object explicitly if performance is a concern.

Tradeoffs

DimensionManual wiringtsyringeInversifyJSNestJS
Setup complexityLowMediumHighBundled with framework
Decorator metadata requiredNoYesYesYes
Container featuresNoneScopes, child containersMiddleware, interceptorsModules, scopes, guards
Tree-shakeableYesPartialPartialNo
Test ergonomicsExplicit but verboseChild containersChild containersTesting module API
Good fit<15 servicesMedium services without frameworkLarge services, custom interceptorsNestJS applications

Production considerations

Configuration validation at startup. Validate all environment variables when building the composition root, not lazily when a service first uses them. A missing DATABASE_URL should crash the process at startup, not on the first request from a real user.

import { z } from 'zod';

const EnvSchema = z.object({
  DATABASE_URL: z.string().url(),
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  REDIS_URL: z.string().url(),
});

const env = EnvSchema.parse(process.env);  // throws at startup if invalid

Graceful shutdown. Register cleanup handlers at the composition root, where you have references to all singleton instances:

process.on('SIGTERM', async () => {
  await db.close();       // drain connection pool
  await redis.quit();     // close Redis connection
  process.exit(0);
});

Avoid service locator anti-pattern. The container should be called once at the composition root, not injected into services and called from within business logic. If a service calls container.resolve(SomeDependency) internally, it creates an implicit dependency on the container itself, which makes testing harder, not easier.

Interface-based injection for external dependencies. Define interfaces for third-party clients (Stripe, SendGrid, S3). Inject the interface, not the concrete class. This makes it trivial to swap providers in tests or to stub out external calls entirely:

export interface EmailProvider {
  send(to: string, subject: string, body: string): Promise<void>;
}

// In tests
const mockEmail: EmailProvider = {
  send: vi.fn().mockResolvedValue(undefined),
};

Watch for memory leaks in request-scoped factories. If your container creates a new database connection per request instead of pulling from a pool, you will exhaust available connections under load. Pools should be singletons; repositories that use pools can be transient.

The pattern that lasts

DI is not about containers. It is about making dependencies visible and controllable. The pattern that holds up across team size and codebase growth is:

  1. Every service receives all its dependencies as constructor arguments.
  2. Exactly one place in the application (the composition root) creates and wires concrete instances.
  3. Business logic never references process.env directly; it receives configuration through its constructor.
  4. Tests override specific dependencies via constructor or container registration; they never monkeypatch modules.

Whether you use manual wiring, tsyringe, InversifyJS, or NestJS depends on the size and structure of the codebase. The discipline is the same regardless of the mechanism.

A service that does not know where its dependencies come from is a service that is easy to test, easy to move, and easy to reason about in an incident at 2am.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.