Circular DependencyFundamentals · NestJS

Resolve two modules and two providers that need each other with forwardRef(), and learn what native ES modules add to the docs' recipe.

What you will learn

Read the theory for Circular Dependency

All Fundamentals lessons

All NestJS courses

loading types…

What you'll learn

  • Recognise a circular dependency from the error it produces, at module level and at provider level
  • Resolve a module cycle and a provider cycle with forwardRef(), on both sides
  • Explain why, under native ES modules, the parameter's type must be a type-only import, and know the alternatives: ModuleRef and breaking the cycle

Circular Dependency

Two features that refer to each other are ordinary. Cats have owners, and an owner has cats, so CatsService wants OwnersService to name a cat's owner and OwnersService wants CatsService to list an owner's cats. Written the obvious way, that is a circular dependency, and it fails twice over: once as JavaScript loads the files, and once as Nest builds the providers. The docs give one tool for both, forwardRef(). Under native ES modules, which Nest and this runtime use, the tool needs one more line than the docs show, and this lesson is about why.

How a cycle fails while loading

A module that imports another is evaluated after it. In a cycle, that is impossible for both, so JavaScript picks one: when cats.service.ts is loading and imports owners.service.ts, which imports cats.service.ts back, the second import is skipped, because that file is already in progress, and owners.service.ts runs with CatsService still uninitialised. Any line that reads CatsService while the file runs throws:

ReferenceError: Cannot access 'CatsService' before initialization

Lines that only read it later, inside a function called after loading, are fine. The problem is code that runs at the top level of the file, and decorators are exactly that: they run when the class is defined. @Module({ imports: [CatsModule] }) reads CatsModule immediately. So does the metadata TypeScript emits for a constructor parameter typed CatsService, which is how Nest learns what to inject.

Nest's tool: a reference that is read later

forwardRef() takes a function that returns the class. The function is called by Nest when it needs the class, long after every file has loaded, so nothing is read too early:

@Module({
  imports: [forwardRef(() => CommonModule)],
})
export class CatsModule {}

Only the module that loads second actually reads the other too early, but which one that is depends on who imported whom first, and that changes when a file is added. Put it on both sides, as the docs do, and the order stops mattering. That is the whole fix at module level.

At provider level the docs show the same idea in @Inject():

@Injectable()
export class CatsService {
  constructor(
    @Inject(forwardRef(() => CommonService))
    private commonService: CommonService,
  ) {}
}

@Inject(forwardRef(...)) tells Nest which token to resolve, lazily. But look at the parameter's type, CommonService. TypeScript turns that annotation into metadata, design:paramtypes, an array containing the class itself, evaluated when the decorator runs. That reads CommonService at load time, and in a cycle it throws the error above. The docs' snippet dates from CommonJS, where an import in a cycle is a half-filled object and reading a missing property gives undefined instead of an error. Under ES modules the read itself is illegal.

The ES modules line

The fix is to make the parameter's type something TypeScript erases. A type-only import never reaches the emitted JavaScript, so the metadata contains Object and nothing is read at load time; the value import stays, used only inside forwardRef's function:

import { CommonService } from './common.service';
import type { CommonService as CommonServiceType } from './common.service';

@Injectable()
export class CatsService {
  constructor(
    @Inject(forwardRef(() => CommonService))
    private readonly commonService: CommonServiceType,
  ) {}
}

Two imports of one class: the first is the token, read late; the second is the type, never read at all. The alias exists only because one name cannot be imported twice. With this, the order in which the files load stops mattering, which is the property to want.

Nest itself warns that the order of instantiation is indeterminate too: neither constructor may assume the other has run. Keep constructors to assignments and do the work in methods.

The alternatives

ModuleRef, which the next lesson covers, lets one side fetch the other from the container in a method instead of the constructor. That removes the provider cycle. It does not remove the file cycle, so the class-typed parameter on the remaining side still depends on which file happens to load first; the type-only import is the safer habit.

Better than either is not having the cycle. Most cycles hide a third thing both sides want, an OwnershipService that knows which cat belongs to whom, and extracting it leaves two services that both depend on it and not on each other. The docs also warn about barrel files (index.ts files that re-export a folder): importing a class through one pulls in the whole folder, and cycles appear that no file asked for. Import module and provider classes from their own files.

Your task

The starter is the obvious code, and it does not boot. Fix the cycle in the order the errors arrive:

  1. The first error is about a service, because the services' files load before the modules' decorators run. Both services inject each other with a class-typed parameter. Give each parameter its token through @Inject(forwardRef(...)), and make its type a type-only import, on both sides.
  2. Run, and read the new error, now about a module. Both modules import each other. Reference each through forwardRef().

GET /cats/1 crosses the cycle one way, GET /owners/ada the other.

When it fails

  • Cannot access 'CatsService' before initialization: a constructor parameter is typed with the class from a value import, so the metadata reads it while the file loads. The annotation must come from import type.
  • Cannot access 'CatsModule' before initialization, once the services are fixed: a module reads the other in imports while loading. Wrap it in forwardRef(), on both sides.
  • Nest can't resolve dependencies of the CatsService (?), argument Function: the type is erased, which is right, but the parameter has no @Inject(forwardRef(...)) to say which token to resolve.
  • Nest cannot create the CatsModule instance ... A circular dependency between modules. Use forwardRef(): Nest's own detection, for a module cycle it could see. Same fix as the first line.

Remember

  • A cycle fails at load time for code that runs at the top level, and decorators do.
  • forwardRef(() => X) defers the read: in a module's imports, and in @Inject() on both sides of a provider cycle.
  • Under ES modules the parameter's type must be a type-only import, or the metadata reads the class too early.
  • ModuleRef moves the lookup into a method; extracting the shared part removes the cycle; never import through barrel files.
Stuck? Show a hint

Modules first: imports: [forwardRef(() => OwnersModule)] in CatsModule, and the mirror image in OwnersModule. Then the services: @Inject(forwardRef(() => OwnersService)) on the parameter, and a second, type-only import of OwnersService under another name for the parameter's type, so that nothing about the class is read while the modules are still loading. The same on the other side.