Backend with NestJS
Backend with NestJS

Modules, providers & the injector

Lesson · ~25 min
After this unit you can
  • Fix a 'Nest can't resolve dependencies' error with exports and imports, and say what @Global() costs
  • Predict instance sharing: one singleton for all consumers, two from duplicate providers, and how far Scope.REQUEST spreads
  • Inject an interface-typed dependency with a Symbol token and @Inject, and swap in a fake for tests
  • Choose useFactory with inject for async setup, and keep connection work out of constructors

Read

Simple
1 / 2

A module is a registry; the injector is a lookup

Flag

This unit assumes the vocabulary from the previous one — decorator, controller, service, constructor(private readonly x: T). Nest looks magical until you name the two moving parts:

  • A provider is anything the injector can hand out — usually a class marked @Injectable().
  • A module is the registry that says which providers exist, which of them leave the module, and which other modules it can borrow from.

At boot Nest walks the module graph once, reads each class's constructor parameter types, and builds one instance of each provider (that's the default scope — the Complex tier covers per-request and per-consumer providers). That's it — constructor(private users: UsersService) means "look up the provider registered under the token UsersService and pass it in". A token is just the key a provider is filed under; for a class provider, the class itself is the key.

@Injectable()
export class UsersService {
  findAll() { return [{ id: "1" }]; }
}

@Controller("users")
export class UsersController {
  // Not `new UsersService()` — the injector supplies the instance it already built.
  constructor(private readonly users: UsersService) {}

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

@Module({
  controllers: [UsersController],
  providers: [UsersService],   // registered here -> resolvable inside this module
})
export class UsersModule {}

Two consequences worth internalizing on day one:

  • You never call new on a service. The class's dependencies are declared, not constructed, so a test can pass a fake in the same position.
  • Providers are singletons by default — one instance per application, shared by every consumer (anything that injects it — a controller, another service, a guard). A field you set on a service is shared state for the whole process, not per request.
Medium

Reading "Nest can't resolve dependencies of the X"

Flag

This is the error you will meet most often, and it tells you exactly where to look. Nest 12 prints it like this (one long first line, wrapped here), followed by a list of potential solutions:

Nest can't resolve dependencies of the OrdersService (?, ConfigService). Please make sure
that the argument PricingService at index [0] is available in the OrdersModule module.

Read it as three facts:

  1. Who needs something — OrdersService.
  2. Which argument — the ? marks the unresolved constructor parameter; index [0].
  3. Which registry was searched — OrdersModule. Not "the app": Nest modules are encapsulated, so only this module's own providers plus the exports of its imports are in scope.

So there are exactly four causes:

  • The provider isn't in any module's providers.
  • Its owning module doesn't exports it.
  • OrdersModule doesn't imports the owning module.
  • The type is erased at runtime — an interface, an import type, or a circular import — so there is no token to look up (see the Complex tier). You can tell this one from the error itself: it names no token and says "is available in the current module".

Encapsulation, in one picture

@Module({ providers: [PricingService] })            // not exported
export class PricingModule {}

@Module({ imports: [PricingModule], providers: [OrdersService] })
export class OrdersModule {}                        // 💥 PricingService is private

Importing a module gets you its exports, nothing more. exports: [PricingService] is the one-line fix, and refusing to add it is a legitimate design decision: an unexported provider is an implementation detail of that module.

Global modules, and why to be sparing

@Global() on a module makes its exports injectable everywhere without an imports entry. It's right for exactly one class of thing — cross-cutting infrastructure registered once at the root (config, logger, database connection). Reach for it more often and the module graph stops documenting anything: every service looks like it depends on nothing, and boot-order bugs get much harder to see.

Why this shape is worth the ceremony

Because the same seam that Nest uses to inject the real provider is the one your test uses to inject a fake:

const moduleRef = await Test.createTestingModule({
  providers: [
    OrdersService,
    { provide: PricingService, useValue: { quote: () => 100 } },
  ],
}).compile();

const orders = moduleRef.get(OrdersService);

No module mocking, no import interception — you overrode a token. That is the entire payoff of DI, and it's why "just import the singleton directly" costs you later.

Complex
1 / 2

Tokens, factories and scopes

Flag

providers: [UsersService] is shorthand for the full form:

{ provide: UsersService, useClass: UsersService }

Once you see the long form, the rest of the provider system is just choosing a token and a strategy.

Four strategies

@Module({
  providers: [
    UsersService,                                            // useClass shorthand
    { provide: "CLOCK", useValue: { now: () => new Date() } }, // a value/fake
    {
      provide: PaymentGateway,                               // swap the implementation
      useClass: process.env.NODE_ENV === "test" ? FakeGateway : StripeGateway,
    },
    {
      provide: "REDIS",                                      // needs async setup / other providers
      useFactory: async (config: ConfigService) => {
        const client = createClient({ url: config.get("REDIS_URL") });
        await client.connect();       // node-redis (v4 onward) doesn't connect on create — await it here
        return client;                // consumers get a client that's ready to use
      },
      inject: [ConfigService],
    },
  ],
})
export class InfraModule {}

useFactory is the escape hatch for anything you can't express as "call the constructor": async connections, values that depend on config, one provider assembled from several others. inject is positional — it feeds the factory's parameters in order.

Interfaces can't be tokens

The injector reads runtime metadata, and TypeScript interfaces don't exist at runtime. constructor(private mailer: Mailer) where Mailer is an interface gives you an unresolvable dependency. Use a string/symbol token — declared once, next to the interface — plus @Inject:

export const MAILER = Symbol("MAILER");
export interface Mailer { send(to: string, body: string): Promise<void> }

constructor(@Inject(MAILER) private readonly mailer: Mailer) {}

An abstract class works too, and keeps the type and the token as one symbol.

Scopes: singleton is a decision, not a default you can ignore

ScopeInstancesUse when
DEFAULTOne per appAlmost always — services must be stateless
REQUESTOne per requestYou need the request (tenant, user) inside a deep provider
TRANSIENTOne per consumerEach injector wants its own copy (e.g. a scoped logger)

Request scope is contagious — it bubbles up the injection chain:

(-> means "injects")
OrdersController  ->  OrdersService  ->  TenantContext   @Injectable({ scope: Scope.REQUEST })
per request now       per request now    per request
OrdersController  ->  PricingService                     still one instance: it never reaches TenantContext

Everything that depends on a request-scoped provider, directly or through others, is rebuilt for every request, controller included; providers off that path stay singletons. That's a real throughput cost, and it's why AsyncLocalStorage (or passing the value as an argument) often beats Scope.REQUEST for "the current user/tenant". The opposite mistake — keeping the current user in a field on a singleton — is the shared-state bug from the previous unit.

Quiz

OrdersService injects PricingService, which is declared in PricingModule's providers. Boot fails with "Nest can't resolve dependencies of the OrdersService (?)". What are the two things to check?

Flag

What is the actual consequence of listing the same service class in two modules' providers arrays?

Flag

constructor(private mailer: Mailer) where Mailer is a TypeScript interface fails to resolve. Why?

Flag

You need a Redis client whose URL comes from ConfigService and whose connection is established asynchronously. Which provider form?

Flag

TenantContext is @Injectable({ scope: Scope.REQUEST }). OrdersService injects it; OrdersController injects OrdersService and PricingService (which doesn't touch TenantContext). Which of these are created per request?

Flag

What do you give up by marking many modules @Global()?

Flag

Fill in the blank

Let another module inject OrdersService while keeping OrdersRepository private to this module.

Flag
@Module({
  imports: [DatabaseModule],
  controllers: [OrdersController],
  providers: [OrdersService, OrdersRepository],
  : [OrdersService],
})
export class OrdersModule {}

Inject an implementation behind an interface, using an explicit symbol token.

Flag
export const MAILER = Symbol("MAILER");

@Injectable()
export class SignupService {
  constructor(@(MAILER) private readonly mailer: Mailer) {}
}

Build a provider asynchronously from another provider.

Flag
{
  provide: "REDIS",
  : async (config: ConfigService) => {
    const client = createClient({ url: config.get("REDIS_URL") });
    await client.connect();
    return client;
  },
  : [ConfigService],
}

Replace a real dependency with a fake in a unit test, using the same token the injector uses.

Flag
const moduleRef = await Test.createTestingModule({
  providers: [
    OrdersService,
    { : PricingService, : { quote: () => 100 } },
  ],
}).compile();

Predict the output

@Injectable()
class CounterService {
  private hits = 0;
  bump() { return ++this.hits; }
}

// CounterService is in ONE module's providers; two controllers inject it.
// Request 1 hits ControllerA -> counter.bump()
// Request 2 hits ControllerB -> counter.bump()
// Request 3 hits ControllerA -> counter.bump()
Flag
@Injectable()
class CacheService {
  store = new Map<string, string>();
}

@Module({ providers: [CacheService, AService], exports: [AService] })
class AModule {}

@Module({ providers: [CacheService, BService], exports: [BService] })
class BModule {}

// AService: this.cache.store.set("k", "v")
// then BService: this.cache.store.get("k")
Flag
@Injectable()
class DbService implements OnModuleInit {
  ready = false;
  constructor() { this.connect(); }          // async, not awaited
  async connect() { await sleep(50); this.ready = true; }
  async onModuleInit() { console.log("init:", this.ready); }
}

// A controller handles a request immediately after boot and logs `db.ready`.
Flag

Further reading