Skip to main content

Chapter 4: Providers & Services

Providers are the backbone of NestJS’s dependency injection system. They enable you to write modular, testable, and maintainable code. The most common provider is a service, which contains business logic and data access code. Think of providers as the “workers” in your application—they do the heavy lifting behind the scenes.

4.1 What is a Provider?

In NestJS, a provider is any class annotated with @Injectable(). Providers can be injected into controllers, other providers, or modules. They can be services, repositories, factories, or even values.

Types of Providers

Services:
  • Contain business logic
  • Most common type of provider
  • Stateless (usually)
  • Orchestrate data flow
Repositories:
  • Abstract data access
  • Encapsulate database operations
  • Make testing easier
  • Can swap implementations
Factories:
  • Create instances dynamically
  • Handle complex initialization
  • Can be async
Values:
  • Configuration objects
  • Constants
  • Third-party library instances

Provider Characteristics

All providers share these characteristics:
  • Decorated with @Injectable()
  • Registered in a module’s providers array
  • Can be injected via constructor
  • Managed by NestJS DI container
  • Have a lifecycle (singleton, request-scoped, transient)

4.2 The Service Layer

The service layer encapsulates business logic and orchestrates data flow between controllers and repositories. Services should be focused on business rules, not HTTP or database details.

Service Responsibilities

Services handle:
  • Business Logic: Core application rules and workflows
  • Data Orchestration: Coordinate between multiple repositories
  • Validation: Business-level validation (beyond DTO validation)
  • Transformation: Transform data between layers
  • Integration: Call external services, APIs, etc.
Services should NOT handle:
  • HTTP concerns (controllers do this)
  • Database queries (repositories do this)
  • Request/response formatting (controllers/interceptors do this)
Analogy:
If your app is a restaurant, the service is the chef (prepares the meal), the controller is the waiter (takes orders, serves food), and the repository is the pantry (stores ingredients).

Basic Service Structure

import { Injectable } from '@nestjs/common';
import { UserRepository } from './user.repository';
import { CreateUserDto } from './dto/create-user.dto';

@Injectable()
export class UsersService {
  constructor(private readonly userRepository: UserRepository) {}

  async create(dto: CreateUserDto) {
    // Business logic: validate business rules
    if (await this.userRepository.existsByEmail(dto.email)) {
      throw new Error('Email already exists');
    }

    // Business logic: apply business rules
    const user = await this.userRepository.create({
      ...dto,
      status: 'pending',  // Business rule: new users are pending
      createdAt: new Date(),
    });

    // Business logic: trigger side effects
    await this.sendWelcomeEmail(user);

    return user;
  }

  async findAll() {
    return this.userRepository.findAll();
  }

  private async sendWelcomeEmail(user: any) {
    // Integration with email service
    // ...
  }
}

Stateless Services

Services should be stateless when possible:
// Bad: Stateful service
@Injectable()
export class UsersService {
  private users: User[] = [];  // State in service

  create(user: User) {
    this.users.push(user);  // Modifying state
  }
}

// Good: Stateless service
@Injectable()
export class UsersService {
  constructor(private userRepository: UserRepository) {}

  async create(dto: CreateUserDto) {
    return this.userRepository.create(dto);  // Delegates to repository
  }
}
Why Stateless?
  • Easier to test
  • Thread-safe
  • Can be shared across requests
  • No side effects between calls
Best Practice: Keep services focused on business logic, not HTTP or database details. Services should be stateless when possible.

4.3 Repository Pattern

Repositories abstract data access, making it easy to swap databases or mock for tests. This pattern keeps your business logic decoupled from the database implementation.

Why Use Repositories?

Benefits:
  • Abstraction: Business logic doesn’t depend on database
  • Testability: Easy to mock repositories in tests
  • Flexibility: Can swap database implementations
  • Separation of Concerns: Data access is isolated
  • Reusability: Repository methods can be reused

Basic Repository

import { Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { User } from './entities/user.entity';

@Injectable()
export class UserRepository {
  // In a real app, this would use TypeORM, Prisma, etc.
  private users: User[] = [];

  async create(dto: CreateUserDto): Promise<User> {
    const user: User = {
      id: this.users.length + 1,
      ...dto,
      createdAt: new Date(),
    };
    this.users.push(user);
    return user;
  }

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

  async findOne(id: number): Promise<User | null> {
    return this.users.find(u => u.id === id) || null;
  }

  async findByEmail(email: string): Promise<User | null> {
    return this.users.find(u => u.email === email) || null;
  }

  async update(id: number, data: Partial<User>): Promise<User> {
    const user = await this.findOne(id);
    if (!user) {
      throw new Error('User not found');
    }
    Object.assign(user, data);
    return user;
  }

  async remove(id: number): Promise<void> {
    const index = this.users.findIndex(u => u.id === id);
    if (index === -1) {
      throw new Error('User not found');
    }
    this.users.splice(index, 1);
  }
}

Repository with TypeORM

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { CreateUserDto } from './dto/create-user.dto';

@Injectable()
export class UserRepository {
  constructor(
    @InjectRepository(User)
    private readonly repository: Repository<User>,
  ) {}

  async create(dto: CreateUserDto): Promise<User> {
    const user = this.repository.create(dto);
    return this.repository.save(user);
  }

  async findAll(): Promise<User[]> {
    return this.repository.find();
  }

  async findOne(id: number): Promise<User | null> {
    return this.repository.findOne({ where: { id } });
  }

  async findByEmail(email: string): Promise<User | null> {
    return this.repository.findOne({ where: { email } });
  }

  async update(id: number, data: Partial<User>): Promise<User> {
    await this.repository.update(id, data);
    return this.findOne(id);
  }

  async remove(id: number): Promise<void> {
    await this.repository.delete(id);
  }
}

Repository Interface Pattern

Define interfaces for better abstraction:
// interfaces/user-repository.interface.ts
export interface IUserRepository {
  create(dto: CreateUserDto): Promise<User>;
  findAll(): Promise<User[]>;
  findOne(id: number): Promise<User | null>;
  findByEmail(email: string): Promise<User | null>;
  update(id: number, data: Partial<User>): Promise<User>;
  remove(id: number): Promise<void>;
}

// repositories/user.repository.ts
@Injectable()
export class UserRepository implements IUserRepository {
  // Implementation...
}

// Usage with interface
@Injectable()
export class UsersService {
  constructor(
    @Inject('IUserRepository')
    private readonly userRepository: IUserRepository,
  ) {}
}
Tip: Always use repositories for data access. This makes your code easier to test and maintain. Repositories abstract away database details from your business logic.

4.4 Domain-Driven Design (DDD)

For complex applications, consider using Domain-Driven Design principles. DDD helps you model your business domain and organize code around business concepts.

DDD Building Blocks

Entities:
  • Objects with unique identity
  • Can change over time
  • Example: User, Order, Product
// entities/user.entity.ts
export class User {
  id: number;
  email: string;
  name: string;
  createdAt: Date;

  // Business methods
  changeEmail(newEmail: string) {
    if (!this.isValidEmail(newEmail)) {
      throw new Error('Invalid email');
    }
    this.email = newEmail;
  }

  private isValidEmail(email: string): boolean {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  }
}
Value Objects:
  • Immutable objects with no identity
  • Defined by their attributes
  • Example: Email, Money, Address
// value-objects/email.vo.ts
export class Email {
  private readonly value: string;

  constructor(email: string) {
    if (!this.isValid(email)) {
      throw new Error('Invalid email');
    }
    this.value = email;
  }

  toString(): string {
    return this.value;
  }

  private isValid(email: string): boolean {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  }
}
Aggregates:
  • Groups of related entities
  • Have a root entity (aggregate root)
  • Maintain consistency boundaries
// aggregates/order.aggregate.ts
export class Order {
  id: number;
  items: OrderItem[];
  total: number;

  addItem(product: Product, quantity: number) {
    const item = new OrderItem(product, quantity);
    this.items.push(item);
    this.calculateTotal();
  }

  private calculateTotal() {
    this.total = this.items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0,
    );
  }
}
Domain Services:
  • Business logic that doesn’t fit in entities
  • Stateless operations
  • Coordinate between multiple entities
// services/order-calculation.service.ts
@Injectable()
export class OrderCalculationService {
  calculateDiscount(order: Order, customer: Customer): number {
    // Complex business logic involving multiple entities
    if (customer.isVIP() && order.total > 1000) {
      return order.total * 0.1;  // 10% discount
    }
    return 0;
  }
}
Repositories:
  • Abstract data access
  • Work with aggregates
  • Provide domain-friendly interface
Diagram: Service & Repository Layers
Controller (HTTP Layer)

Service (Business Logic Layer)

Repository (Data Access Layer)

Database (Persistence Layer)
Tip: DDD helps you keep your codebase organized as your app grows. Start simple, add DDD patterns as complexity increases.

4.5 Dependency Injection in Services

Services can depend on other services, repositories, or configuration providers. Use constructor injection for clarity and testability.

Injecting Dependencies

import { Injectable } from '@nestjs/common';
import { UserRepository } from './user.repository';
import { EmailService } from './email.service';
import { ConfigService } from '../config/config.service';

@Injectable()
export class UsersService {
  constructor(
    private readonly userRepository: UserRepository,
    private readonly emailService: EmailService,
    private readonly configService: ConfigService,
  ) {}
}

Service Depending on Service

@Injectable()
export class NotificationService {
  constructor(
    private readonly mailer: MailerService,
    private readonly smsService: SmsService,
  ) {}

  async sendWelcome(user: User) {
    await Promise.all([
      this.mailer.send(user.email, 'Welcome!'),
      this.smsService.send(user.phone, 'Welcome!'),
    ]);
  }
}

Optional Dependencies

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

@Injectable()
export class UsersService {
  constructor(
    @Optional()
    @Inject('LOGGER')
    private readonly logger?: LoggerService,
  ) {}

  log(message: string) {
    if (this.logger) {
      this.logger.log(message);
    }
  }
}
// Avoid: Property injection
@Injectable()
export class UsersService {
  @Inject()
  private userRepository: UserRepository;
}

// Prefer: Constructor injection
@Injectable()
export class UsersService {
  constructor(private readonly userRepository: UserRepository) {}
}
Why Constructor Injection?
  • Dependencies are explicit
  • Easier to test (can see all dependencies)
  • TypeScript can infer types
  • Fail fast (errors at construction time)
Tip: Favor constructor injection over property injection for better testability and clarity.

4.6 Real-World Example: User Registration

Let’s see how services and repositories work together in a real-world scenario:

Complete Registration Flow

// auth/auth.service.ts
import { Injectable, ConflictException } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { MailerService } from '../mailer/mailer.service';
import { HashService } from '../hash/hash.service';
import { RegisterDto } from './dto/register.dto';

@Injectable()
export class AuthService {
  constructor(
    private readonly usersService: UsersService,
    private readonly mailer: MailerService,
    private readonly hashService: HashService,
  ) {}

  async register(dto: RegisterDto) {
    // Check if user exists
    const existingUser = await this.usersService.findByEmail(dto.email);
    if (existingUser) {
      throw new ConflictException('Email already registered');
    }

    // Hash password
    const hashedPassword = await this.hashService.hash(dto.password);

    // Create user
    const user = await this.usersService.create({
      ...dto,
      password: hashedPassword,
    });

    // Send welcome email (don't wait for it)
    this.mailer.sendWelcome(user.email).catch(err => {
      console.error('Failed to send welcome email:', err);
    });

    // Return user (without password)
    const { password, ...userWithoutPassword } = user;
    return userWithoutPassword;
  }
}

Supporting Services

// users/users.service.ts
@Injectable()
export class UsersService {
  constructor(private readonly userRepository: UserRepository) {}

  async create(dto: CreateUserDto): Promise<User> {
    return this.userRepository.create(dto);
  }

  async findByEmail(email: string): Promise<User | null> {
    return this.userRepository.findByEmail(email);
  }
}
// hash/hash.service.ts
import { Injectable } from '@nestjs/common';
import * as bcrypt from 'bcrypt';

@Injectable()
export class HashService {
  async hash(password: string): Promise<string> {
    const salt = await bcrypt.genSalt(10);
    return bcrypt.hash(password, salt);
  }

  async compare(password: string, hash: string): Promise<boolean> {
    return bcrypt.compare(password, hash);
  }
}
Diagram: Registration Flow
Controller (receives RegisterDto)

AuthService.register()

UsersService.findByEmail() → UserRepository
    ↓ (if not exists)
HashService.hash() → Hash password

UsersService.create() → UserRepository → Database

MailerService.sendWelcome() → Email Service

Return user (without password)

4.7 Service Composition

Services can be composed to build complex workflows:

Orchestration Service

@Injectable()
export class OrderService {
  constructor(
    private readonly orderRepository: OrderRepository,
    private readonly paymentService: PaymentService,
    private readonly inventoryService: InventoryService,
    private readonly notificationService: NotificationService,
  ) {}

  async processOrder(orderDto: CreateOrderDto) {
    // 1. Check inventory
    const available = await this.inventoryService.checkAvailability(
      orderDto.items,
    );
    if (!available) {
      throw new Error('Items not available');
    }

    // 2. Process payment
    const payment = await this.paymentService.process(orderDto.payment);

    // 3. Create order
    const order = await this.orderRepository.create({
      ...orderDto,
      paymentId: payment.id,
      status: 'confirmed',
    });

    // 4. Update inventory
    await this.inventoryService.reserve(orderDto.items);

    // 5. Send notifications
    await this.notificationService.sendOrderConfirmation(order);

    return order;
  }
}

4.8 Error Handling in Services

Services should throw domain-specific exceptions:
import { Injectable, ConflictException, NotFoundException } from '@nestjs/common';

@Injectable()
export class UsersService {
  constructor(private readonly userRepository: UserRepository) {}

  async create(dto: CreateUserDto) {
    const existing = await this.userRepository.findByEmail(dto.email);
    if (existing) {
      throw new ConflictException('Email already exists');
    }
    return this.userRepository.create(dto);
  }

  async findOne(id: number) {
    const user = await this.userRepository.findOne(id);
    if (!user) {
      throw new NotFoundException(`User with ID ${id} not found`);
    }
    return user;
  }
}

4.9 Best Practices

Following best practices ensures your services are maintainable and testable.

Keep Services Stateless

// Bad: Stateful
@Injectable()
export class UsersService {
  private cache = new Map();
}

// Good: Stateless
@Injectable()
export class UsersService {
  constructor(private cacheService: CacheService) {}
}

Use Repositories for Data Access

// Bad: Direct database access in service
@Injectable()
export class UsersService {
  constructor(private dataSource: DataSource) {}

  async findAll() {
    return this.dataSource.query('SELECT * FROM users');
  }
}

// Good: Use repository
@Injectable()
export class UsersService {
  constructor(private userRepository: UserRepository) {}

  async findAll() {
    return this.userRepository.findAll();
  }
}

Favor Constructor Injection

// Good: Constructor injection
@Injectable()
export class UsersService {
  constructor(private userRepository: UserRepository) {}
}

Write Unit Tests

describe('UsersService', () => {
  let service: UsersService;
  let repository: UserRepository;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        UsersService,
        {
          provide: UserRepository,
          useValue: {
            create: jest.fn(),
            findAll: jest.fn(),
          },
        },
      ],
    }).compile();

    service = module.get(UsersService);
    repository = module.get(UserRepository);
  });

  it('should create user', async () => {
    const dto = { name: 'John', email: '[email protected]' };
    const user = { id: 1, ...dto };

    jest.spyOn(repository, 'create').mockResolvedValue(user);

    const result = await service.create(dto);
    expect(result).toEqual(user);
    expect(repository.create).toHaveBeenCalledWith(dto);
  });
});

Use Interfaces for Testability

// Define interface
export interface IUserRepository {
  create(dto: CreateUserDto): Promise<User>;
}

// Implement interface
@Injectable()
export class UserRepository implements IUserRepository {
  // ...
}

// Use interface in service
@Injectable()
export class UsersService {
  constructor(
    @Inject('IUserRepository')
    private userRepository: IUserRepository,
  ) {}
}

Avoid Circular Dependencies

// Bad: Circular dependency
// UsersService depends on OrdersService
// OrdersService depends on UsersService

// Good: Extract shared logic
// Create OrderService that doesn't depend on UsersService
// Or use forwardRef() if necessary

Separate Business Logic from Data Access

// Bad: Business logic in repository
@Injectable()
export class UserRepository {
  async createUserWithValidation(dto: CreateUserDto) {
    // Business validation in repository
    if (dto.email.includes('@')) {
      // ...
    }
  }
}

// Good: Business logic in service
@Injectable()
export class UsersService {
  async create(dto: CreateUserDto) {
    // Business validation in service
    if (!this.isValidEmail(dto.email)) {
      throw new Error('Invalid email');
    }
    return this.userRepository.create(dto);
  }
}

4.10 Summary

You’ve learned how to structure business logic using providers, services, and repositories: Key Concepts:
  • Providers: Injectable classes that provide functionality
  • Services: Contain business logic and orchestrate workflows
  • Repositories: Abstract data access from business logic
  • DDD: Domain-Driven Design patterns for complex applications
  • Dependency Injection: Services depend on other services/repositories
Best Practices:
  • Keep services stateless
  • Use repositories for all data access
  • Favor constructor injection
  • Write unit tests for services
  • Use interfaces for testability
  • Avoid circular dependencies
  • Separate business logic from data access
Next Chapter: Learn about database integration with TypeORM and Prisma, including migrations, transactions, and query optimization.