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 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

Stateless Services

Services should be stateless when possible:
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

Repository with TypeORM

Repository Interface Pattern

Define interfaces for better abstraction:
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
Value Objects:
  • Immutable objects with no identity
  • Defined by their attributes
  • Example: Email, Money, Address
Aggregates:
  • Groups of related entities
  • Have a root entity (aggregate root)
  • Maintain consistency boundaries
Domain Services:
  • Business logic that doesn’t fit in entities
  • Stateless operations
  • Coordinate between multiple entities
Repositories:
  • Abstract data access
  • Work with aggregates
  • Provide domain-friendly interface
Diagram: Service & Repository Layers
Decision Framework — When to Introduce DDD: Service Architecture Comparison: 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

Service Depending on Service

Optional Dependencies

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

Supporting Services

Diagram: Registration Flow

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.
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:

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

Use Repositories for Data Access

Favor Constructor Injection

Write Unit Tests

Use Interfaces for Testability

Avoid Circular Dependencies

Separate Business Logic from Data Access


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%.”