As Express projects grow, dependency management falls on the developer. Creating service objects, passing them where needed, tracking their lifecycle — all manual work. Nest.js solves this at the framework level. It provides a DI container and Module system that give applications structure.

IoC and DI

Nest.js builds on an IoC Container and Dependency Injection. Registering a class as a Provider via the @Injectable() decorator is enough — declaring that type in another class’s constructor causes the instance to be injected automatically.

@Injectable()
class OrderService {
  constructor(
    private readonly productService: ProductService,
    private readonly paymentService: PaymentService,
  ) {}
}

Developers do not instantiate with new. The IoC Container finds the type among registered Providers and hands it to the constructor. The hierarchy of DIP, IoC, and DI itself is covered in the dependency-injection post.

Module System

A Nest.js application consists of Modules. The @Module decorator accepts four metadata properties.

  • imports: Other modules this module depends on
  • providers: Services, repositories, and other injectables this module supplies
  • controllers: Controllers handling HTTP requests
  • exports: Providers exposed to other modules
@Module({
  imports: [TypeOrmModule.forFeature([ProductEntity])],
  providers: [OrderService, ProductService],
  controllers: [OrderController],
  exports: [OrderService],
})
class OrderModule {}

Module boundaries enforce encapsulation. Without these boundaries, the entire project becomes one tangled dependency graph. Providers not listed in exports remain inaccessible from outside. If OrderModule does not export ProductService, other modules cannot use it directly. Only OrderService is exposed.

Global Module

Some Providers need to be available everywhere: loggers, configuration stores, error trackers. The @Global decorator makes a module’s exports accessible application-wide after a single registration.

@Global()
@Module({
  providers: [Logger, ConfigStore],
  exports: [Logger, ConfigStore],
})
class SharedModule {}

Dynamic Module

Some modules change behavior based on configuration. TypeORM’s database connection is a common example.

TypeOrmModule.forRoot({
  type: "mysql",
  host: "localhost",
});

forRoot registers global configuration in the root module. forFeature registers specific entities in consuming modules. The same module class gets reused with different configurations.

Provider

A Provider is anything injectable in Nest.js. Services, repositories, factories all qualify. The @Injectable() decorator registers a class with the IoC container. Multiple registration methods enable flexible implementation swapping.

Registration Methods

Four ways to register a Provider in a Module.

useClass: Registers a class as the Provider.

{ provide: OrderRepository, useClass: OrderRepositoryImpl }

useFactory: Creates a Provider through a factory function. Can inject other Providers for dynamic instantiation.

{
  provide: CACHE_CLIENT,
  useFactory: (config: ConfigStore) => CacheClient.from(config),
  inject: [ConfigStore],
}

useValue: Registers an already-created value as a Provider.

useExisting: Creates an alias for an existing Provider.

Custom Provider and Symbol Tokens

TypeScript interfaces do not exist at runtime. They cannot serve as DI tokens. Symbols solve this problem.

const ORDER_REPOSITORY = Symbol("ORDER_REPOSITORY");

interface OrderRepository {
  fetchById(orderId: string): Promise<Order | null>;
}

The module registers the Symbol as the token and the implementation as the Provider.

{ provide: ORDER_REPOSITORY, useClass: OrderRepositoryImpl }

The consumer specifies the token with the @Inject decorator.

constructor(@Inject(ORDER_REPOSITORY) private readonly repo: OrderRepository) {}

This pattern implements the Dependency Inversion Principle. Business logic depends only on interfaces. Implementations swap through Module configuration.

Scope

Controls Provider lifecycle.

  • DEFAULT: Singleton. One instance shared across the entire application.
  • REQUEST: New instance per request.
  • TRANSIENT: New instance per injection.

DEFAULT (singleton) fits most cases. REQUEST scope applies only when per-request state is needed.

Controller and Request Pipeline

Controllers receive HTTP requests and delegate to services. They handle routing and HTTP concerns only.

@Controller("/orders")
class OrderController {
  constructor(private readonly orderService: OrderService) {}

  @Post()
  public async create(@Body() request: CreateOrderDto): Promise<OrderDto> {
    return this.orderService.create(request);
  }
}

Nest.js provides four tools for intercepting request processing.

  • Guard: Authentication and authorization checks. Runs before the request reaches the Controller.
  • Interceptor: Request/response transformation, logging, metric collection. Separates cross-cutting concerns through the AOP pattern.
  • Pipe: Input data transformation and validation.
  • Filter: Exception handling. Converts errors into appropriate HTTP responses.

Global registration applies them to all requests.

@Module({
  providers: [
    { provide: APP_INTERCEPTOR, useClass: MetricInterceptor },
    { provide: APP_FILTER, useClass: DefaultExceptionFilter },
  ],
})
class AppModule {}

Practical Patterns

Repository Pattern and DIP

The Symbol token pattern from earlier applies directly to the data access layer as the Repository pattern. Abstracts data access. Define interfaces in the business logic layer; provide implementations in the infrastructure layer.

// Interface (business layer)
const PRODUCT_REPOSITORY = Symbol("PRODUCT_REPOSITORY");

interface ProductRepository {
  fetchById(productId: string): Promise<Product | null>;
}

// Implementation (infrastructure layer)
@Injectable()
class ProductRepositoryImpl implements ProductRepository {
  constructor(
    @InjectRepository(ProductEntity)
    private readonly repository: Repository<ProductEntity>,
  ) {}

  public async fetchById(productId: string): Promise<Product | null> {
    return this.repository.findOneBy({ id: productId });
  }
}

The module connects token and implementation.

{ provide: PRODUCT_REPOSITORY, useClass: ProductRepositoryImpl }

Swapping databases or injecting mocks for tests requires only a Module configuration change. Business logic stays untouched.

Encapsulation Through Module Boundaries

Use exports to expose only a Facade. Internal implementations stay hidden.

@Module({
  providers: [OrderService, ProductService, ShippingService],
  exports: [OrderService],
})
class OrderServiceModule {}

Other modules can use only OrderService. ProductService and ShippingService remain internal to OrderServiceModule.

As Express projects grow, dependency management becomes the developer’s burden. Nest.js addresses this at the framework level. It controls dependency direction, enforces encapsulation through module boundaries, and makes implementations swappable through the Provider pattern.

References