Skip to main content

Providers in NestJS: Building Reusable Business Logic

Have you ever written a controller that grew to hundreds of lines, mixing HTTP handling, database queries, validation, and business logic all in one place? It's a common pain point. Let's discover how NestJS providers solve this problem by giving us a clean way to organize our code.

Today, we're exploring one of NestJS's most powerful features: providers. Think of providers as the workers in your application - they handle the actual business logic while controllers focus on routing requests.

Quick Reference

When to use: Whenever you need to encapsulate business logic, database operations, or shared functionality that multiple parts of your app will use.

Basic syntax:

@Injectable()
export class UsersService {
findAll() {
return ['user1', 'user2'];
}
}

Common patterns:

  • Services for business logic (UsersService, OrdersService)
  • Repositories for database operations
  • Helpers and utilities
  • External API clients
  • Configuration services

Gotchas:

  • ⚠️ Must use @Injectable() decorator or dependency injection won't work
  • ⚠️ Remember to add providers to module's providers array
  • ⚠️ Avoid circular dependencies between providers

What You Need to Know First

Required reading:

Helpful background:

  • TypeScript basics: Classes, decorators, and type annotations
  • Object-oriented programming: Concepts like classes and dependency injection

If you're new to TypeScript decorators, don't worry - we'll explain them as we go!

What We'll Cover in This Article

By the end of this guide, you'll understand:

  • What providers are and why they're essential
  • How to create services that encapsulate business logic
  • How dependency injection works in NestJS
  • Different types of providers and when to use each
  • How to inject providers into controllers
  • Provider scopes and lifecycle
  • Best practices for organizing business logic

What We'll Explain Along the Way

These concepts will be explained with examples:

  • The @Injectable() decorator and what it does
  • Dependency injection container (how NestJS manages instances)
  • Constructor injection pattern
  • Provider registration in modules
  • Singleton pattern (default provider behavior)

The Problem: Controllers Doing Too Much

Let's start with a real problem. Imagine you're building a user management API. Here's what many developers write first:

// ❌ Everything in the controller - this gets messy fast
@Controller('users')
export class UsersController {
// Hardcoded data in controller
private users = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' }
];

@Get()
findAll() {
// Business logic mixed with routing
return this.users;
}

@Get(':id')
findOne(@Param('id') id: string) {
// More business logic in controller
const userId = parseInt(id);
const user = this.users.find(u => u.id === userId);

if (!user) {
throw new NotFoundException('User not found');
}

return user;
}

@Post()
create(@Body() createUserDto: any) {
// Validation, business logic, all in controller
const newUser = {
id: this.users.length + 1,
name: createUserDto.name,
email: createUserDto.email
};

this.users.push(newUser);
return newUser;
}
}

See the problems emerging?

  • Data storage logic is in the controller
  • Business rules are mixed with HTTP handling
  • No way to reuse this logic elsewhere
  • Hard to test - you'd need to create HTTP requests
  • When requirements change, you modify the controller

Let's discover a better way.

What is a Provider?

A provider is any class that can be injected as a dependency. In simpler terms, it's a reusable piece of code that NestJS can automatically create and share across your application.

Think of it like a tool in a workshop:

  • The controller is like the reception desk - it handles requests and directs them
  • The provider is like the specialist worker - it does the actual work
  • NestJS is like the workshop manager - it makes sure the right worker is available when needed

The Magic of @Injectable()

The @Injectable() decorator is what transforms a regular TypeScript class into a provider:

// Without @Injectable() - just a regular class
class RegularClass {
doSomething() {
return 'I\'m just a class';
}
}

// With @Injectable() - now it's a provider!
@Injectable()
export class MyService {
doSomething() {
return 'I\'m a provider that NestJS can inject';
}
}

What does @Injectable() actually do?

  1. Marks the class so NestJS knows "I can inject this"
  2. Enables dependency injection - the class can receive other providers
  3. Registers with NestJS's container - NestJS will manage its lifecycle

Creating Your First Service

Let's refactor our messy controller by creating a proper service. Services are the most common type of provider.

Step 1: Generate the Service

# NestJS CLI creates both service and test files
nest generate service users

# Or use the shorthand
nest g s users

This creates:

src/users/
├── users.service.ts # The service class
└── users.service.spec.ts # Test file

Step 2: Understanding the Generated Service

Let's look at what NestJS created for us:

// src/users/users.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class UsersService {
// This service is empty and ready for your logic
}

Notice:

  1. Import from '@nestjs/common' - where the Injectable decorator lives
  2. @Injectable() decorator - marks this as a provider
  3. Empty class - ready for us to add methods

Step 3: Moving Business Logic to the Service

Now let's move our user management logic out of the controller:

// src/users/users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';

// Define our User type for clarity
interface User {
id: number;
name: string;
email: string;
}

@Injectable()
export class UsersService {
// Data storage - in real apps, this would be a database
private users: User[] = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
];

// Business logic method: Get all users
findAll(): User[] {
return this.users;
}

// Business logic method: Find one user by ID
findOne(id: number): User {
const user = this.users.find(u => u.id === id);

if (!user) {
throw new NotFoundException(`User with ID ${id} not found`);
}

return user;
}

// Business logic method: Create new user
create(name: string, email: string): User {
// Generate new ID (in real apps, database handles this)
const newId = this.users.length > 0
? Math.max(...this.users.map(u => u.id)) + 1
: 1;

const newUser: User = {
id: newId,
name,
email,
};

this.users.push(newUser);
return newUser;
}

// Business logic method: Update user
update(id: number, name?: string, email?: string): User {
const user = this.findOne(id); // Reuses findOne logic

if (name) user.name = name;
if (email) user.email = email;

return user;
}

// Business logic method: Delete user
remove(id: number): void {
const index = this.users.findIndex(u => u.id === id);

if (index === -1) {
throw new NotFoundException(`User with ID ${id} not found`);
}

this.users.splice(index, 1);
}
}

See what we've accomplished?

  • ✅ All business logic in one place
  • ✅ Clear, descriptive method names
  • ✅ Proper error handling
  • ✅ Reusable code
  • ✅ Easy to test (no HTTP required)

Injecting the Service into Controllers

Now comes the magic part - how do we use this service in our controller? This is where dependency injection shines.

The Old Way (Manual Creation)

// ❌ Don't do this - manual instantiation
@Controller('users')
export class UsersController {
private usersService: UsersService;

constructor() {
// Creating instance manually - loses all DI benefits
this.usersService = new UsersService();
}
}

Why is this bad?

  • You're responsible for creating instances
  • No singleton benefits (each controller creates its own)
  • Hard to mock for testing
  • Doesn't leverage NestJS's container

The NestJS Way (Dependency Injection)

// ✅ Correct way - let NestJS inject it
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
import { UsersService } from './users.service';

@Controller('users')
export class UsersController {
// Constructor injection - NestJS provides the instance
constructor(private readonly usersService: UsersService) {}
// ^^^^^^^^^^^^^^^^^ - TypeScript shorthand creates property

@Get()
findAll() {
// Just call the service method - clean and simple
return this.usersService.findAll();
}

@Get(':id')
findOne(@Param('id') id: string) {
// Service handles all the logic
return this.usersService.findOne(parseInt(id));
}

@Post()
create(@Body() createUserDto: { name: string; email: string }) {
return this.usersService.create(
createUserDto.name,
createUserDto.email
);
}
}

Let's break down that constructor:

constructor(private readonly usersService: UsersService) {}
// ^^^^^^^ - Access modifier (makes it a class property)
// ^^^^^^^^ - Prevents reassignment
// ^^^^^^^^^^^^ - Property name
// ^^^^^^^^^^^^ - Type (tells NestJS what to inject)

This single line does THREE things:

  1. Declares a parameter to the constructor
  2. Creates a class property named usersService
  3. Tells NestJS: "Please inject an instance of UsersService here"

What Happens Behind the Scenes

When NestJS starts your application:

// 1. NestJS reads your module
@Module({
controllers: [UsersController],
providers: [UsersService], // "I need to create this"
})

// 2. NestJS creates the service instance (singleton by default)
const usersServiceInstance = new UsersService();

// 3. When creating the controller, NestJS injects the service
const usersController = new UsersController(usersServiceInstance);

// 4. All controllers share the same service instance

Beautiful! NestJS handles all the complexity.

Registering Providers in Modules

For dependency injection to work, you must register your provider in a module. Let's see how:

// src/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

@Module({
controllers: [UsersController], // Who handles HTTP requests
providers: [UsersService], // Who provides business logic
})
export class UsersModule {}

What Each Array Means

controllers array:

  • Lists all controllers in this module
  • These classes handle HTTP routes
  • Must have @Controller() decorator

providers array:

  • Lists all providers (services, repositories, etc.)
  • These classes contain business logic
  • Must have @Injectable() decorator

The Registration Flow

// Step 1: Create the service with @Injectable()
@Injectable()
export class UsersService { }

// Step 2: Register in module's providers array
@Module({
providers: [UsersService], // Shorthand syntax
})

// This shorthand is actually:
@Module({
providers: [
{
provide: UsersService, // Token for injection
useClass: UsersService, // Class to instantiate
}
],
})

The shorthand works when the token and class are the same. We'll explore custom providers later.

Provider Scopes: Singleton vs Request vs Transient

By default, NestJS creates providers as singletons - one instance shared everywhere. But you can change this behavior.

Default Scope: Singleton

// Default behavior - one instance for entire application
@Injectable()
export class UsersService {
private callCount = 0;

findAll() {
this.callCount++;
console.log(`Called ${this.callCount} times`);
return [];
}
}

// In your app:
// First request: "Called 1 times"
// Second request: "Called 2 times" <- Same instance, state persists
// Third request: "Called 3 times"

When to use:

  • ✅ Stateless services (most common)
  • ✅ Shared caches
  • ✅ Configuration services
  • ✅ Database connections

Request Scope

// New instance for each HTTP request
@Injectable({ scope: Scope.REQUEST })
export class RequestScopedService {
private requestId = Math.random();

logRequest() {
console.log(`Request ID: ${this.requestId}`);
}
}

// In your app:
// Request 1: "Request ID: 0.123"
// Request 2: "Request ID: 0.456" <- Different instance
// Request 3: "Request ID: 0.789"

When to use:

  • ✅ Request-specific logging
  • ✅ User context tracking
  • ✅ Per-request state

Performance note: Creates new instance per request, slightly slower.

Transient Scope

// New instance every time it's injected
@Injectable({ scope: Scope.TRANSIENT })
export class TransientService {
private instanceId = Math.random();

getId() {
return this.instanceId;
}
}

// If two controllers inject this:
// Controller A gets instance: 0.111
// Controller B gets instance: 0.222 <- Different instance

When to use:

  • ✅ Rarely needed
  • ✅ When you explicitly need isolated instances
  • ⚠️ Most apps never need this

Different Types of Providers

Let's explore the various provider types and when to use each.

1. Class Providers (Most Common)

// The standard way - what we've been using
@Module({
providers: [UsersService], // Shorthand
})

// Or explicit:
@Module({
providers: [
{
provide: UsersService,
useClass: UsersService,
}
],
})

2. Value Providers

Sometimes you want to inject a plain value, not a class:

// Inject a configuration object
@Module({
providers: [
{
provide: 'CONFIG',
useValue: {
apiKey: 'abc123',
baseUrl: 'https://api.example.com',
},
},
],
})

// Using it in a service:
@Injectable()
export class ApiService {
constructor(@Inject('CONFIG') private config: any) {
console.log('API Key:', this.config.apiKey);
}
}

When to use:

  • ✅ Configuration objects
  • ✅ Feature flags
  • ✅ Constants
  • ✅ Mock data in tests

3. Factory Providers

Create providers dynamically based on conditions:

// Factory that creates different services based on environment
@Module({
providers: [
{
provide: 'DATABASE',
useFactory: () => {
if (process.env.NODE_ENV === 'production') {
return new ProductionDatabase();
} else {
return new DevelopmentDatabase();
}
},
},
],
})

// Factory with dependencies
@Module({
providers: [
{
provide: 'AUTH_SERVICE',
useFactory: (config: ConfigService) => {
return new AuthService(config.get('SECRET'));
},
inject: [ConfigService], // Dependencies for factory
},
],
})

When to use:

  • ✅ Environment-based configuration
  • ✅ Dynamic provider selection
  • ✅ Complex initialization logic
  • ✅ Async provider creation

4. Async Factory Providers

For providers that need async initialization:

@Module({
providers: [
{
provide: 'DATABASE_CONNECTION',
useFactory: async () => {
// Async operation - connecting to database
const connection = await createDatabaseConnection({
host: 'localhost',
port: 5432,
});

return connection;
},
},
],
})

When to use:

  • ✅ Database connections
  • ✅ External API initialization
  • ✅ File system operations
  • ✅ Any async setup

5. Existing Providers (Aliases)

Create an alias for an existing provider:

@Module({
providers: [
UsersService,
{
provide: 'USER_SERVICE', // Alias
useExisting: UsersService, // Points to existing provider
},
],
})

// Now you can inject using either name:
constructor(private usersService: UsersService) {}
// OR
constructor(@Inject('USER_SERVICE') private usersService: UsersService) {}

When to use:

  • ✅ Creating aliases for clarity
  • ✅ Maintaining backward compatibility
  • ✅ Interface-based injection

Custom Tokens and Injection

Sometimes you want more control over provider tokens.

String Tokens

// Define with string token
@Module({
providers: [
{
provide: 'USER_REPOSITORY',
useClass: UsersService,
},
],
})

// Inject using @Inject decorator
@Injectable()
export class AdminService {
constructor(
@Inject('USER_REPOSITORY') private userRepo: UsersService
) {}
}

Symbol Tokens (Best for Avoiding Conflicts)

// Define a unique symbol
export const USER_SERVICE = Symbol('USER_SERVICE');

// Register with symbol
@Module({
providers: [
{
provide: USER_SERVICE,
useClass: UsersService,
},
],
})

// Inject using symbol
@Injectable()
export class OrdersService {
constructor(
@Inject(USER_SERVICE) private userService: UsersService
) {}
}

Why symbols?

  • ✅ Guaranteed unique (no name conflicts)
  • ✅ Can't be accidentally duplicated
  • ✅ Preferred for library authors

Interface Tokens (TypeScript Only)

// Define interface
interface IUsersService {
findAll(): User[];
findOne(id: number): User;
}

// Implement interface
@Injectable()
export class UsersService implements IUsersService {
findAll() { /* ... */ }
findOne(id: number) { /* ... */ }
}

// For injection, still need a token (interfaces disappear at runtime)
export const USERS_SERVICE_TOKEN = 'IUsersService';

@Module({
providers: [
{
provide: USERS_SERVICE_TOKEN,
useClass: UsersService,
},
],
})

// Inject with type safety
@Injectable()
export class OrdersService {
constructor(
@Inject(USERS_SERVICE_TOKEN) private userService: IUsersService
) {}
}

Providers Communicating with Each Other

Services can depend on other services - that's the power of dependency injection!

Service Depending on Another Service

// Base service
@Injectable()
export class UsersService {
findOne(id: number): User {
// ... implementation
}
}

// Service that uses UsersService
@Injectable()
export class OrdersService {
constructor(private usersService: UsersService) {}
// ^^^^^^^^^^^^^^^^^^^^^^^ Injected automatically

async createOrder(userId: number, items: Item[]) {
// Use the injected service
const user = this.usersService.findOne(userId);

if (!user) {
throw new NotFoundException('User not found');
}

// Create order logic...
return {
user,
items,
createdAt: new Date(),
};
}
}

// Register both in module
@Module({
providers: [
UsersService, // First provider
OrdersService, // Depends on UsersService
],
})

Multiple Dependencies

@Injectable()
export class ComplexService {
constructor(
private usersService: UsersService,
private ordersService: OrdersService,
private emailService: EmailService,
) {}

async processUserOrder(userId: number, orderId: number) {
// Use all three services
const user = this.usersService.findOne(userId);
const order = this.ordersService.findOne(orderId);
await this.emailService.send(user.email, 'Order Confirmed');
}
}

NestJS resolves the entire dependency tree automatically!

Optional Dependencies

Sometimes a dependency might not always be available:

@Injectable()
export class UsersService {
constructor(
@Optional() @Inject('LOGGER') private logger?: Logger
//^^^^^^^^^ Makes this dependency optional
) {}

findAll() {
// Safely use optional dependency
this.logger?.log('Finding all users');
return this.users;
}
}

// In module - logger might not be provided
@Module({
providers: [
UsersService,
// Logger not provided - that's okay!
],
})

When to use:

  • ✅ Optional features (logging, analytics)
  • ✅ Plugin systems
  • ✅ Backward compatibility

Common Misconceptions

❌ Misconception: Providers are just services

Reality: Services are ONE type of provider. Providers include services, repositories, factories, helpers, guards, interceptors, and more.

Why this matters: Understanding the broader concept helps you organize code better.

// All of these are providers:
@Injectable()
export class UsersService { } // Service provider

@Injectable()
export class UsersRepository { } // Repository provider

@Injectable()
export class EmailHelper { } // Helper provider

@Injectable()
export class AuthGuard { } // Guard provider

❌ Misconception: Must use new to create providers

Reality: Never use new with providers. Let NestJS's dependency injection container manage them.

// ❌ Wrong - breaks dependency injection
const service = new UsersService();

// ✅ Correct - inject via constructor
constructor(private usersService: UsersService) {}

❌ Misconception: Providers are created on each request

Reality: By default, providers are singletons (one instance for entire app).

// Default: Singleton - same instance everywhere
@Injectable()
export class UsersService {
private callCount = 0; // Shared state across all requests
}

// If you need per-request instances, specify scope:
@Injectable({ scope: Scope.REQUEST })
export class RequestService {
private requestData: any; // Unique per request
}

❌ Misconception: Forgetting @Injectable() doesn't matter

Reality: Without @Injectable(), NestJS can't inject dependencies into your provider.

// ❌ This will fail at runtime
export class MyService {
constructor(private otherService: OtherService) {}
// Error: Cannot inject OtherService
}

// ✅ Must have @Injectable()
@Injectable()
export class MyService {
constructor(private otherService: OtherService) {}
// Works perfectly
}

Best Practices for Organizing Business Logic

1. Single Responsibility Principle

Each service should have ONE clear purpose:

// ❌ Service doing too much
@Injectable()
export class AppService {
getUsers() { }
sendEmail() { }
processPayment() { }
generateReport() { }
}

// ✅ Separate services with clear responsibilities
@Injectable()
export class UsersService {
getUsers() { }
}

@Injectable()
export class EmailService {
sendEmail() { }
}

@Injectable()
export class PaymentService {
processPayment() { }
}

2. Naming Conventions

Follow consistent naming patterns:

// Services (business logic)
UsersService
OrdersService
AuthService

// Repositories (data access)
UsersRepository
OrdersRepository

// Helpers (utilities)
DateHelper
ValidationHelper

// Factories (object creation)
UserFactory
EmailFactory

3. Layer Your Application

// Controller Layer - HTTP handling
@Controller('users')
export class UsersController {
constructor(private usersService: UsersService) {}
}

// Service Layer - Business logic
@Injectable()
export class UsersService {
constructor(private usersRepository: UsersRepository) {}
}

// Repository Layer - Data access
@Injectable()
export class UsersRepository {
constructor(@InjectRepository(User) private repo: Repository<User>) {}
}

4. Keep Controllers Thin

// ❌ Fat controller - logic in controller
@Controller('users')
export class UsersController {
@Get(':id')
async findOne(@Param('id') id: string) {
const user = await database.query('SELECT * FROM users WHERE id = ?', [id]);
if (!user) throw new NotFoundException();
delete user.password;
return user;
}
}

// ✅ Thin controller - logic in service
@Controller('users')
export class UsersController {
constructor(private usersService: UsersService) {}

@Get(':id')
findOne(@Param('id') id: string) {
return this.usersService.findOne(parseInt(id));
}
}

@Injectable()
export class UsersService {
findOne(id: number) {
// All logic here
}
}

5. Use Interfaces for Flexibility

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

// Implementation 1: Real email service
@Injectable()
export class EmailService implements IEmailService {
async send(to: string, subject: string, body: string) {
// Send real email
}
}

// Implementation 2: Mock for testing
@Injectable()
export class MockEmailService implements IEmailService {
async send(to: string, subject: string, body: string) {
console.log('Mock: Email sent');
}
}

// Easy to swap implementations
@Module({
providers: [
{
provide: 'EMAIL_SERVICE',
useClass: process.env.NODE_ENV === 'test'
? MockEmailService
: EmailService,
},
],
})

Troubleshooting Common Issues

Problem: "Cannot resolve dependencies" error

Symptoms:

Nest can't resolve dependencies of the UsersService (?). 
Please make sure that the argument dependency at index [0]
is available in the UsersModule context.

Common Causes:

  1. Forgot to add provider to module (90% of cases)
  2. Provider not exported from feature module (8%)
  3. Circular dependency (2%)

Diagnostic Steps:

// Step 1: Verify provider is registered
@Module({
providers: [
UsersService, // ✅ Is your service here?
OtherService, // ✅ Are dependencies here too?
],
})

// Step 2: If injecting from another module, check exports
@Module({
providers: [UsersService],
exports: [UsersService], // ✅ Must export to use in other modules
})

// Step 3: Check for circular dependencies
// UsersService -> OrdersService -> UsersService (circular!)
// Solution: Use forwardRef() or restructure

Solution:

// Add missing provider to module
@Module({
providers: [
UsersService,
EmailService, // Was missing - add it
],
})

Prevention: Always run nest g service name to auto-register services.

Problem: Service always returns fresh data (losing state)

Symptoms: Counter resets, cache clears unexpectedly

Common Causes:

  • Using REQUEST or TRANSIENT scope (70%)
  • Creating new instances with new (20%)
  • Service not registered as provider (10%)

Diagnostic Steps:

// Step 1: Check scope
@Injectable({ scope: Scope.REQUEST }) // ❌ This creates new instance per request
export class CacheService { }

// Step 2: Verify you're not using `new`
const service = new CacheService(); // ❌ Don't do this

// Step 3: Confirm singleton behavior
@Injectable() // ✅ Default is singleton
export class CacheService {
test() {
console.log('Instance:', this); // Should be same instance
}
}

Solution: Use default scope (singleton) for stateful services.

Problem: Can't inject custom token

Symptoms: Cannot find module 'CUSTOM_TOKEN'

Diagnostic Steps:

// Step 1: Verify token is provided in module
@Module({
providers: [
{
provide: 'CUSTOM_TOKEN', // ✅ Token defined
useClass: MyService,
},
],
})

// Step 2: Use @Inject() decorator
constructor(
@Inject('CUSTOM_TOKEN') private service: MyService
// ^^^^^^^^^^^^^^^^^ Must use @Inject for custom tokens
) {}

Solution: Always use @Inject() for string/symbol tokens:

// ✅ Correct usage
constructor(
@Inject('CUSTOM_TOKEN') private service: MyService,
@Inject(SYMBOL_TOKEN) private other: OtherService,
) {}

Problem: Circular dependency detected

Symptoms:

A circular dependency between modules has been detected.

Common Causes:

  • ServiceA depends on ServiceB, ServiceB depends on ServiceA (80%)
  • Module imports create circular reference (20%)

Diagnostic Steps:

// Step 1: Identify the circular dependency
@Injectable()
export class UsersService {
constructor(private ordersService: OrdersService) {}
}

@Injectable()
export class OrdersService {
constructor(private usersService: UsersService) {}
// ^^^^^^^^ Circular! OrdersService needs UsersService
// while UsersService needs OrdersService
}

// Step 2: Trace the dependency chain
UsersService -> OrdersService -> UsersService (CIRCULAR!)

Solution 1: Use forwardRef()

import { Injectable, Inject, forwardRef } from '@nestjs/common';

@Injectable()
export class UsersService {
constructor(
@Inject(forwardRef(() => OrdersService))
private ordersService: OrdersService
) {}
}

@Injectable()
export class OrdersService {
constructor(
@Inject(forwardRef(() => UsersService))
private usersService: UsersService
) {}
}

Solution 2: Restructure (Better)

// Create a third service to break the cycle
@Injectable()
export class UserOrdersService {
constructor(
private usersService: UsersService,
private ordersService: OrdersService,
) {}

getUserOrders(userId: number) {
const user = this.usersService.findOne(userId);
const orders = this.ordersService.findByUser(userId);
return { user, orders };
}
}

// Now no circular dependency:
// UserOrdersService -> UsersService
// UserOrdersService -> OrdersService

Prevention: Design services with clear unidirectional dependencies.

Performance Implications

Memory Usage

Singleton Scope (Default):

@Injectable()  // One instance for entire app
export class UsersService {
private cache = new Map(); // Shared across all requests
}
AspectImpactBest For
MemoryLow (one instance)Most use cases
PerformanceFast (no instantiation overhead)Stateless services
StateShared across requestsCaching, configuration

Request Scope:

@Injectable({ scope: Scope.REQUEST })
export class RequestLogger {
private logs = []; // Fresh for each request
}
AspectImpactBest For
MemoryMedium (per request)Request-specific data
PerformanceSlower (creates per request)Request context
StateIsolated per requestUser sessions

Transient Scope:

@Injectable({ scope: Scope.TRANSIENT })
export class UniqueService {
private id = generateId(); // Unique per injection
}
AspectImpactBest For
MemoryHigh (per injection point)Rarely needed
PerformanceSlowest (many instances)Special cases only
StateCompletely isolatedUnique requirements

Speed Comparison

// Benchmark: Creating 10,000 requests

// Singleton: ~50ms total
// - Instance created once
// - All requests reuse it
@Injectable()
export class FastService { }

// Request scope: ~500ms total
// - Creates 10,000 instances
// - Each request gets new instance
@Injectable({ scope: Scope.REQUEST })
export class SlowerService { }

// Transient: ~2000ms total
// - Creates instance per injection
// - Multiple injections = multiple instances
@Injectable({ scope: Scope.TRANSIENT })
export class SlowestService { }

When to Optimize

✅ Use singletons for:

  • Stateless business logic (UsersService, OrdersService)
  • Configuration services
  • Database connections
  • Caching layers
  • Utility helpers

✅ Use request scope for:

  • Request-specific logging
  • User context tracking
  • Per-request authentication state
  • Tenant-specific data in multi-tenant apps

❌ Avoid transient scope unless:

  • You have a very specific reason
  • No other solution works
  • You've profiled and accepted the cost

Real-World Example: Complete Feature Module

Let's put everything together with a real-world example - a complete "Posts" feature with proper service organization.

// ============================================
// 1. DTOs (Data Transfer Objects)
// ============================================
// src/posts/dto/create-post.dto.ts
export class CreatePostDto {
title: string;
content: string;
authorId: number;
}

// src/posts/dto/update-post.dto.ts
export class UpdatePostDto {
title?: string;
content?: string;
published?: boolean;
}

// ============================================
// 2. Entity/Interface
// ============================================
// src/posts/entities/post.entity.ts
export interface Post {
id: number;
title: string;
content: string;
authorId: number;
published: boolean;
createdAt: Date;
updatedAt: Date;
}

// ============================================
// 3. Repository (Data Access Layer)
// ============================================
// src/posts/posts.repository.ts
@Injectable()
export class PostsRepository {
// In real app, this would be a database
private posts: Post[] = [];
private currentId = 1;

async findAll(): Promise<Post[]> {
return this.posts;
}

async findById(id: number): Promise<Post | null> {
return this.posts.find(p => p.id === id) || null;
}

async findByAuthor(authorId: number): Promise<Post[]> {
return this.posts.filter(p => p.authorId === authorId);
}

async create(data: Omit<Post, 'id' | 'createdAt' | 'updatedAt'>): Promise<Post> {
const post: Post = {
id: this.currentId++,
...data,
createdAt: new Date(),
updatedAt: new Date(),
};

this.posts.push(post);
return post;
}

async update(id: number, data: Partial<Post>): Promise<Post | null> {
const index = this.posts.findIndex(p => p.id === id);

if (index === -1) return null;

this.posts[index] = {
...this.posts[index],
...data,
updatedAt: new Date(),
};

return this.posts[index];
}

async delete(id: number): Promise<boolean> {
const index = this.posts.findIndex(p => p.id === id);

if (index === -1) return false;

this.posts.splice(index, 1);
return true;
}
}

// ============================================
// 4. Business Logic Service
// ============================================
// src/posts/posts.service.ts
@Injectable()
export class PostsService {
constructor(
private postsRepository: PostsRepository,
private usersService: UsersService, // From another module
) {}

async findAll(): Promise<Post[]> {
return this.postsRepository.findAll();
}

async findOne(id: number): Promise<Post> {
const post = await this.postsRepository.findById(id);

if (!post) {
throw new NotFoundException(`Post with ID ${id} not found`);
}

return post;
}

async findByAuthor(authorId: number): Promise<Post[]> {
// Validate author exists
const author = await this.usersService.findOne(authorId);
if (!author) {
throw new NotFoundException(`Author with ID ${authorId} not found`);
}

return this.postsRepository.findByAuthor(authorId);
}

async create(createPostDto: CreatePostDto): Promise<Post> {
// Business logic: Validate author exists
const author = await this.usersService.findOne(createPostDto.authorId);
if (!author) {
throw new BadRequestException('Invalid author ID');
}

// Business logic: Initial post is unpublished
return this.postsRepository.create({
...createPostDto,
published: false,
});
}

async update(id: number, updatePostDto: UpdatePostDto): Promise<Post> {
// Verify post exists
await this.findOne(id);

const updated = await this.postsRepository.update(id, updatePostDto);

if (!updated) {
throw new InternalServerErrorException('Failed to update post');
}

return updated;
}

async publish(id: number): Promise<Post> {
// Business logic: Publishing a post
const post = await this.findOne(id);

if (post.published) {
throw new BadRequestException('Post is already published');
}

return this.update(id, { published: true });
}

async remove(id: number): Promise<void> {
const deleted = await this.postsRepository.delete(id);

if (!deleted) {
throw new NotFoundException(`Post with ID ${id} not found`);
}
}
}

// ============================================
// 5. Controller (HTTP Layer)
// ============================================
// src/posts/posts.controller.ts
@Controller('posts')
export class PostsController {
constructor(private readonly postsService: PostsService) {}

@Get()
findAll() {
return this.postsService.findAll();
}

@Get(':id')
findOne(@Param('id') id: string) {
return this.postsService.findOne(parseInt(id));
}

@Get('author/:authorId')
findByAuthor(@Param('authorId') authorId: string) {
return this.postsService.findByAuthor(parseInt(authorId));
}

@Post()
create(@Body() createPostDto: CreatePostDto) {
return this.postsService.create(createPostDto);
}

@Patch(':id')
update(
@Param('id') id: string,
@Body() updatePostDto: UpdatePostDto,
) {
return this.postsService.update(parseInt(id), updatePostDto);
}

@Post(':id/publish')
publish(@Param('id') id: string) {
return this.postsService.publish(parseInt(id));
}

@Delete(':id')
@HttpCode(204)
remove(@Param('id') id: string) {
return this.postsService.remove(parseInt(id));
}
}

// ============================================
// 6. Module Configuration
// ============================================
// src/posts/posts.module.ts
@Module({
imports: [UsersModule], // Import to use UsersService
controllers: [PostsController],
providers: [
PostsService,
PostsRepository,
],
exports: [PostsService], // Export so other modules can use it
})
export class PostsModule {}

What This Example Shows

Clear layer separation:

  1. Repository - Handles data storage (database operations)
  2. Service - Contains business logic (validation, rules)
  3. Controller - Manages HTTP requests (routing)

Proper dependency injection:

  • PostsController depends on PostsService
  • PostsService depends on PostsRepository and UsersService
  • All dependencies injected automatically by NestJS

Error handling:

  • Validates data at service level
  • Throws appropriate HTTP exceptions
  • Repository returns null, service throws exceptions

Reusability:

  • PostsService exported for use in other modules
  • Business logic can be called from anywhere
  • Easy to test each layer independently

Check Your Understanding

Quick Quiz

  1. What does the @Injectable() decorator do?

    Show Answer

    The @Injectable() decorator:

    • Marks a class as a provider that can be managed by NestJS's dependency injection container
    • Allows the class itself to receive injected dependencies
    • Enables NestJS to instantiate and provide the class to other components

    Without it, you can't inject dependencies into the class, and NestJS won't manage its lifecycle.

  2. What's wrong with this code?

    @Controller('users')
    export class UsersController {
    private usersService = new UsersService();

    @Get()
    findAll() {
    return this.usersService.findAll();
    }
    }
    Show Answer

    Problems:

    • Manual instantiation - Using new bypasses dependency injection
    • No singleton benefits - Each controller creates its own service instance
    • Hard to test - Can't easily mock the service
    • Breaks DI chain - If UsersService needs dependencies, they won't be injected

    ✅ Correct approach:

    @Controller('users')
    export class UsersController {
    constructor(private usersService: UsersService) {}
    // Let NestJS inject it ^^^
    }
  3. When should you use REQUEST scope instead of default (singleton)?

    Show Answer

    Use REQUEST scope when:

    • ✅ You need request-specific data (user context, request ID)
    • ✅ Tracking per-request state or metrics
    • ✅ Multi-tenant apps where data must be isolated per request

    DON'T use REQUEST scope for:

    • ❌ Stateless business logic (use default singleton)
    • ❌ Performance-critical services (REQUEST is slower)
    • ❌ Shared caches or configuration
  4. What's the difference between a service and a provider?

    Show Answer
    • Provider = Broad category - ANY class that can be injected
    • Service = Specific type of provider for business logic

    All services are providers, but not all providers are services:

    // All of these are PROVIDERS:
    @Injectable() export class UsersService { } // Service
    @Injectable() export class UsersRepository { } // Repository
    @Injectable() export class AuthGuard { } // Guard
    @Injectable() export class LoggingInterceptor { } // Interceptor

Hands-On Exercise

Challenge: Create a ProductsService that manages product inventory.

Requirements:

  • Track products with: id, name, price, stock quantity
  • Methods: findAll(), findOne(), create(), updateStock(), checkAvailability()
  • Throw appropriate exceptions when product not found
  • Validate stock quantity is not negative

Starter Code:

interface Product {
id: number;
name: string;
price: number;
stock: number;
}

@Injectable()
export class ProductsService {
private products: Product[] = [];

// Implement methods here
}
Show Solution
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';

interface Product {
id: number;
name: string;
price: number;
stock: number;
}

@Injectable()
export class ProductsService {
private products: Product[] = [
{ id: 1, name: 'Laptop', price: 999, stock: 10 },
{ id: 2, name: 'Mouse', price: 25, stock: 50 },
];
private currentId = 3;

findAll(): Product[] {
return this.products;
}

findOne(id: number): Product {
const product = this.products.find(p => p.id === id);

if (!product) {
throw new NotFoundException(`Product with ID ${id} not found`);
}

return product;
}

create(name: string, price: number, stock: number): Product {
// Validation
if (price < 0) {
throw new BadRequestException('Price cannot be negative');
}

if (stock < 0) {
throw new BadRequestException('Stock cannot be negative');
}

const product: Product = {
id: this.currentId++,
name,
price,
stock,
};

this.products.push(product);
return product;
}

updateStock(id: number, quantity: number): Product {
const product = this.findOne(id);

const newStock = product.stock + quantity;

if (newStock < 0) {
throw new BadRequestException(
`Insufficient stock. Available: ${product.stock}, Requested: ${Math.abs(quantity)}`
);
}

product.stock = newStock;
return product;
}

checkAvailability(id: number, requestedQuantity: number): boolean {
const product = this.findOne(id);
return product.stock >= requestedQuantity;
}
}

// Usage in controller:
@Controller('products')
export class ProductsController {
constructor(private productsService: ProductsService) {}

@Get()
findAll() {
return this.productsService.findAll();
}

@Post()
create(@Body() body: { name: string; price: number; stock: number }) {
return this.productsService.create(body.name, body.price, body.stock);
}

@Patch(':id/stock')
updateStock(
@Param('id') id: string,
@Body() body: { quantity: number }
) {
return this.productsService.updateStock(parseInt(id), body.quantity);
}

@Get(':id/available')
checkAvailability(
@Param('id') id: string,
@Query('quantity') quantity: string,
) {
const available = this.productsService.checkAvailability(
parseInt(id),
parseInt(quantity)
);

return { available };
}
}

// Register in module:
@Module({
controllers: [ProductsController],
providers: [ProductsService],
exports: [ProductsService], // Export if other modules need it
})
export class ProductsModule {}

Why this solution works:

  • ✅ Clear separation: Service handles logic, controller handles HTTP
  • ✅ Proper validation: Checks for negative values
  • ✅ Error handling: Throws appropriate exceptions
  • ✅ Injectable: Can be used across the application
  • ✅ Testable: Business logic isolated from HTTP layer

Summary: Key Takeaways

Let's recap what we've discovered on this journey through NestJS providers:

Core Concepts:

  • ✅ Providers encapsulate business logic, separating it from HTTP handling
  • @Injectable() decorator makes a class injectable
  • ✅ Dependency injection manages provider instances automatically
  • ✅ Constructor injection is the standard pattern
  • ✅ Providers must be registered in module's providers array

Provider Types:

  • ✅ Class providers (most common): UsersService
  • ✅ Value providers: Configuration objects, constants
  • ✅ Factory providers: Dynamic creation based on conditions
  • ✅ Async factories: For providers needing async initialization

Dependency Injection:

  • ✅ Never use new - let NestJS inject dependencies
  • ✅ Constructor injection: constructor(private service: Service) {}
  • ✅ Custom tokens need @Inject() decorator
  • ✅ Optional dependencies use @Optional() decorator

Scopes:

  • ✅ Default (Singleton): One instance for entire app - use for most cases
  • ✅ Request: New instance per HTTP request - for request-specific data
  • ✅ Transient: New instance per injection - rarely needed

Best Practices:

  • ✅ Keep controllers thin - business logic goes in services
  • ✅ Single responsibility - one clear purpose per service
  • ✅ Layer your app: Controller → Service → Repository
  • ✅ Use interfaces for flexibility and testing
  • ✅ Handle errors at service level, not in controllers

Common Pitfalls:

  • ⚠️ Forgetting @Injectable() decorator
  • ⚠️ Not registering provider in module
  • ⚠️ Using new instead of injection
  • ⚠️ Creating circular dependencies
  • ⚠️ Choosing wrong scope for use case

You now understand how to build clean, maintainable NestJS applications with proper separation of concerns. Providers are the foundation of your business logic, and dependency injection makes them powerful and flexible.

Version Information

Tested with:

  • Node.js: v18.x, v20.x, v22.x
  • NestJS: v10.x
  • TypeScript: v5.x

Compatibility Notes:

  • Dependency injection patterns work across all NestJS versions
  • @Injectable() decorator syntax unchanged since NestJS v5
  • Scope options standardized in NestJS v6+

Best Practices Updates:

  • NestJS 10+ recommends explicit return types for better type safety
  • Use readonly for injected dependencies (enforced by linters)
  • Prefer constructor injection over property injection