Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

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 hospital, the controller is the receptionist (takes patient information, routes them to the right department), the service is the doctor (diagnoses and prescribes treatment — the actual business logic), and the repository is the medical records room (stores and retrieves patient data). The receptionist never performs surgery, the doctor never files paperwork, and the records clerk never diagnoses patients. When you see a service calling res.status(200).json(...), or a controller running a SQL query, that is the equivalent of a doctor filing paperwork — the system works, but it is doing someone else’s job, and it will cause problems as the hospital scales.

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?

Without a repository layer, your services end up littered with database-specific code. Imagine you start with PostgreSQL, then management says “we need to support DynamoDB for this module.” If your service calls this.dataSource.query('SELECT * FROM users') directly, you are rewriting business logic. If your service calls this.userRepository.findAll(), you only swap the repository implementation — the service never changes. Benefits:
  • Abstraction: Business logic does not depend on database — your service does not know (or care) if it is talking to PostgreSQL, MongoDB, or an in-memory array
  • Testability: Easy to mock repositories in tests — just provide a fake object with the same method signatures
  • Flexibility: Can swap database implementations without touching services — just register a different class for the same injection token
  • Separation of Concerns: Data access logic (query optimization, joins, caching) lives in one place
  • Reusability: Repository methods like findByEmail() or findActive() can be used by multiple services

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)
Decision Framework — When to Introduce DDD:
SignalAction
CRUD operations with simple validationStandard service + repository is enough
Business rules span 2-3 entities (e.g., “discount applies if customer is VIP AND order is above threshold”)Introduce domain services
You catch yourself writing the same validation in multiple servicesExtract to a value object (e.g., Email, Money)
Entity state changes have side effects (send email, update cache)Introduce domain events
Multiple developers argue about where logic belongsDefine aggregate boundaries
Your service file exceeds 500 linesBreak into domain services + application service
Service Architecture Comparison:
PatternComplexityWhen to UseFile Count (per feature)
Controller + ServiceLowSimple CRUD, prototypes2-3 files
Controller + Service + RepositoryMediumMost production apps3-4 files
Controller + Service + Repository + DDDHighComplex domains, large teams6-10 files
Controller + CQRS + Event SourcingVery HighEvent-driven systems, audit requirements10-15 files
Start at the lowest complexity that solves your problem. Moving up the ladder later is straightforward if your module boundaries are clean.

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 — glancing at the constructor tells you everything the class needs
  • Easier to test (you can see all dependencies and provide mocks for each)
  • TypeScript can infer types from constructor parameters, so NestJS can auto-resolve them
  • Fail fast — if a dependency is missing, the app crashes at startup, not when a user hits a specific route
Practical Rule of Thumb: If your constructor has more than 4-5 dependencies, your service is probably doing too much. Consider breaking it into smaller, more focused services. This is not a hard limit, but it is a reliable smell that something needs refactoring. Tip: Favor constructor injection over property injection for better testability and clarity. Property injection (@Inject() on a field) hides dependencies, making it unclear what a class needs to function.

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

Orchestration services coordinate multiple services to complete a business workflow. They are the conductors of your application’s orchestra — they do not play any instrument themselves, but they make sure every musician plays at the right time, in the right order.
@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) {
    // Step 1: Check inventory BEFORE charging the customer.
    // Order matters here -- you never want to charge for out-of-stock items.
    const available = await this.inventoryService.checkAvailability(
      orderDto.items,
    );
    if (!available) {
      throw new Error('Items not available');
    }

    // Step 2: Process payment. If this fails, we have not created any
    // order or reserved any inventory -- clean failure.
    const payment = await this.paymentService.process(orderDto.payment);

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

    // Step 4: Reserve inventory. If this fails after payment, you need
    // a compensation strategy (refund). In production, wrap steps 2-4
    // in a saga or transaction. See Chapter 8 (Microservices) for patterns.
    await this.inventoryService.reserve(orderDto.items);

    // Step 5: Notifications are non-critical. Use fire-and-forget so a
    // failed email does not roll back a successful order.
    this.notificationService.sendOrderConfirmation(order).catch(err => {
      // Log but do not throw -- the order succeeded regardless
      console.error('Notification failed:', err);
    });

    return order;
  }
}
Common Mistake: Making every step awaited and letting any failure abort the entire flow. In reality, some steps (like notifications) are non-critical and should not block or fail the main transaction. Design your orchestration with failure modes in mind.

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 Edge Cases in Service Design

Edge Case 1: Fire-and-forget operations that fail silently In the order processing example (section 4.7), we used .catch() for notifications. But what if the notification failure means the user never receives their order confirmation? You need a strategy for “important but non-blocking” operations: write them to a persistent queue (database table, Redis list, or message broker) and process them asynchronously with retries. Pure fire-and-forget with .catch(console.error) is only acceptable for truly non-critical side effects. Edge Case 2: Service methods that return different shapes A service method that returns User on success but throws NotFoundException on failure has a clear contract. But what about methods that can return null, undefined, or an empty array? Establish a convention early: return null for “not found” in repository methods and throw NotFoundException in service methods. Never let null propagate to the controller — that results in a 200 response with an empty body, which is confusing for API consumers. Edge Case 3: Circular service dependencies AuthService needs UsersService to validate credentials. UsersService needs AuthService to hash passwords on user creation. This is a real circular dependency. The fix is not forwardRef — it is extracting HashService into its own module that both can import. When you see a circular dependency, ask: “What is the shared concern?” Extract it into its own service. Edge Case 4: Long-running service methods and request timeouts If a service method takes 30 seconds (generating a report, processing a large file), the HTTP request will likely timeout before it completes. Options: (1) return a 202 Accepted with a job ID immediately, then poll for status; (2) use WebSockets to push the result; (3) use a background job queue like Bull. Never make the client wait for long-running operations synchronously.

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: 'john@example.com' };
    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.

Interview Deep-Dive

Strong Answer:
  • The repository pattern abstracts data access behind an interface, so your service says this.userRepository.findByEmail(email) instead of this.prisma.user.findUnique({ where: { email } }). The service does not know or care whether the data comes from PostgreSQL, MongoDB, or an in-memory array.
  • The primary benefit is testability. Without a repository, testing a service means mocking the entire Prisma client or TypeORM repository with all their methods. With a custom repository, you mock a simple interface with 5-6 methods that you control.
  • The second benefit is portability. I worked on a project that started with TypeORM and needed to migrate to Prisma after TypeORM’s performance on complex joins became a bottleneck. Because we had a repository layer, the migration was contained: we rewrote 12 repository files, and the 40+ service files never changed.
  • The trade-off is indirection. For a simple CRUD app with 5 entities, the repository layer adds boilerplate with little benefit. My rule of thumb: add a repository layer when (1) your service needs to be tested without a database, (2) you might swap ORMs, or (3) your queries are complex enough to warrant encapsulation.
  • A common anti-pattern is putting business logic in the repository. The repository should only answer “get me this data” or “save this data.” Deciding whether a user is allowed to update their profile is business logic that belongs in the service.
Follow-up: TypeORM already has a Repository class via @InjectRepository(). Why would you wrap it in your own custom repository?TypeORM’s built-in repository exposes the entire QueryBuilder API, which means your service can construct arbitrary SQL. A custom repository hides these details behind methods like findWithPosts(userId), so the service never touches the query builder. Additionally, custom repository methods are self-documenting — findActiveByRole('admin') is more readable than a 10-line query builder chain scattered across multiple service methods.
Strong Answer:
  • Four to five constructor dependencies is at the edge. The question is: does the OrderService genuinely need to orchestrate all four concerns, or has it accumulated responsibilities over time?
  • First, check if any dependencies are cross-cutting concerns. AuditService is a prime candidate — audit logging could be handled by an interceptor instead of explicit service calls. That removes one dependency.
  • NotificationService is another candidate for extraction. Sending a confirmation email is a side effect, not core order creation logic. If the email service is down, should the order fail? Usually not. Move it to an event handler: OrderService emits an OrderCreatedEvent, and NotificationService listens for it asynchronously. This decouples the two and removes another dependency.
  • After refactoring, OrderService has two dependencies: PaymentService and InventoryService. These are genuinely part of the order creation workflow.
  • The general rule: if your constructor has more than 4-5 dependencies, check whether some are cross-cutting concerns that can be moved to interceptors, event handlers, or middleware. If all are genuinely part of core business logic, the service might be an orchestrator, which is a valid pattern.
Follow-up: How do you handle the case where payment succeeds but inventory reservation fails?This is the distributed transaction problem. The solution is the Saga pattern: define compensation actions for each step. If inventory reservation fails after payment succeeds, trigger a RefundPaymentCommand to reverse the charge. In NestJS, implement this with the @nestjs/cqrs event bus — the OrderSaga listens for PaymentSucceededEvent and InventoryReservationFailedEvent, and dispatches compensation commands.
Strong Answer:
  • My position: services should throw HTTP exceptions for typical REST APIs, and domain exceptions for complex or multi-transport applications.
  • The pragmatic argument for HTTP exceptions: in a typical REST API, the service and HTTP layer are deployed together. NotFoundException maps directly to 404. If the service throws a generic Error('User not found'), the controller has to catch and convert it — that is boilerplate with no benefit.
  • The purist argument for domain exceptions: if your service is used by both an HTTP controller and a gRPC handler, NotFoundException is meaningless in gRPC. A domain exception like UserNotFoundError can be mapped to 404 in the HTTP exception filter and to NOT_FOUND in the gRPC filter.
  • My practical rule: if the application only has an HTTP API and is unlikely to add other transports, throw HttpException subclasses directly. If it has multiple transports or uses DDD principles, create domain exception classes and map them in transport-specific filters.
  • The one thing I would never do is throw raw Error objects. They carry no semantic meaning, and the exception filter has to guess the status code.
Follow-up: How would you implement a custom exception hierarchy for an application serving both REST and gRPC?Create a base DomainException class with a code property (like USER_NOT_FOUND). Subclass it for specific errors. Then create two exception filters: an HttpDomainExceptionFilter mapping codes to HTTP statuses, and a GrpcDomainExceptionFilter mapping to gRPC status codes. Register each on its respective transport. The service throws domain exceptions, and each transport handles the mapping independently.
Strong Answer:
  • For complex domains, I use Domain-Driven Design principles within NestJS’s service layer. The key distinction is between application services (orchestrate workflows) and domain services (encapsulate business rules that span multiple entities).
  • The loan approval system would have a LoanApplicationService (application service) that orchestrates the workflow: receive application, run credit check, calculate risk score, apply underwriting rules. It injects domain services like CreditScoringService and UnderwritingService.
  • The domain logic lives in entities and value objects. The LoanApplication entity has methods like approve(), deny(reason), requestAdditionalDocuments(). These enforce invariants: you cannot approve an application that has not passed underwriting. This is a “rich domain model.”
  • Value objects like Money, InterestRate, CreditScore encapsulate validation. new CreditScore(850) succeeds, new CreditScore(-1) throws. This prevents invalid data from propagating.
  • The anti-pattern to avoid is an “anemic domain model” where entities are just data containers and all logic is in the service. This leads to 500-line service methods that are hard to test.
Follow-up: How do you test business rules that span multiple entities without hitting the database?Create entities in memory using plain constructors (new LoanApplication({ amount: 50000, creditScore: new CreditScore(720) })), call domain methods, and assert results. Since entities are plain classes with no DI, they are trivially testable. For domain services, inject mocked repositories. The test reads like a specification: “Given a credit score of 720 and amount of 50,000, when underwriting rules are applied, the application should be approved at 4.5%.”