Brands fund creator campaigns on a marketplace where payouts run through Stripe Connect, and internal support staff need tools to fix profiles, freeze accounts, and reset a creator's payout link without opening the database by hand. I shipped that as a new NestJS admin module on an API that already had payments and domain services split across modules, deployed to Google Cloud Run with merge-to-main auto-deploy to staging.
The pull request was boring in a good way: lint, typecheck, Jest, even a Postgres integration test for the new admin routes. After merge, staging never came healthy. The new Cloud Run revision never listened on port 8080. Locally I got the same crash at bootstrap: TypeError: Cannot read properties of undefined (reading 'provide'). No module name in the stack, no file hint, just Nest walking metadata and hitting undefined. Production never saw this build; only staging failed, and nobody outside the team noticed.
What forwardRef actually defers
A Nest module is a class decorated with @Module({ imports, exports, providers }). Nest builds a dependency graph from that metadata at boot, not when you run a single spec file. forwardRef(() => OtherModule) tells Nest to resolve OtherModule later for that import edge, which helps when two modules inject each other.
ServicesModule and PaymentsModule already depended on each other. Both sides used forwardRef inside imports, which felt like the hard part was solved. What I missed: ServicesModule also listed PaymentsModule plainly in exports. An export entry is not deferred the same way. It is a direct reference to the imported binding at the moment the decorator runs.
Node still has to evaluate the two module files in some order. When file A imports file B and file B imports file A, whichever file runs first can see the other's class binding as undefined until the cycle finishes. forwardRef on imports does not rewind an eager exports: [PaymentsModule] line that already captured that binding.
How a harmless import rewired the cycle
The admin module imported ConfigModule, repositories, services, and payments: [ConfigModule, RepositoriesModule, ServicesModule, PaymentsModule]. Payments was redundant on paper because ServicesModule already re-exported it for consumers that only imported services.
That redundant top-level import { PaymentsModule } from './payments.module' was enough. It nudged Node to evaluate payments.module.ts before the admin file finished wiring the graph. While payments was mid-evaluation, services ran its @Module decorator and copied PaymentsModule into exports[24]. The binding was still undefined. Nest later tried to read .provide on that slot and threw the opaque TypeError.
Unit tests that compile a slice of the graph never exercised the full AppModule boot order. Only importing the root module the way Cloud Run does reproduced it.
Pick PaymentsModule file first and Plain export entry, then flip Show CI line to see the guard message; switch to ServicesModule only to mirror the hotfix.
Simulation
Import order and the undefined export slot
Toy evaluation order for two Nest modules in a file-level cycle. Steps are illustrative, not a real Node loader.
File evaluation trace
- 1. admin-users.module.ts
top-level import pulls in payments.module.ts first
- 2. payments.module.ts
starts; imports forwardRef(() => ServicesModule)
- 3. services.module.ts
starts while PaymentsModule class is still pending
- 4. services.module.ts
@Module decorator reads exports[24] = PaymentsModule
- 5. services.module.ts
PaymentsModule binding is still undefined at decorator time
exports binding: undefined
- 6. Nest bootstrap
reads .provide on undefined export entry
undefinedTypeError: Cannot read properties of undefined (reading 'provide')
Simulated boot crashes with TypeError: Cannot read properties of undefined (reading 'provide').
Hotfix and a boot-time guard
The twenty-minute hotfix dropped the direct PaymentsModule import from the admin module and relied on the existing re-export through ServicesModule. Staging deploy green on the next revision. Longer term I added tests/module-graph.test.ts, which imports AppModule, walks imports, exports, and providers via Reflect.getMetadata, and fails with lines like ServicesModule.exports[24] is undefined. That test fails on the pre-hotfix metadata and passes now.
Other ways out if you cannot drop an import: wrap the export in forwardRef as well (Nest allows it on exports, but verify against your version's docs), or break the file-level cycle so the class binding exists before any eager reference. I chose the smallest diff because the import was genuinely redundant.
If your staging deploy suddenly dies listening on its port while CI stayed green, grep your module files for forwardRef in imports paired with a bare module symbol in exports. The error text will not tell you which slot is undefined; metadata walking will.
Happy coding!
Sander