unlock unlimited code runs for $79, limited time →
NOSSLETTER.techLIVE
back to OSSpedia
dependency-managementmemory-managementtesting-strategy

fix(core): share existing singleton providers with lazily loaded modules

Project: nestjs/nest | Language: TypeScript | Concepts: dependency-management, memory-management, testing-strategy | Difficulty: mid
Link: https://github.com/nestjs/nest/pull/17430

The problem

When a module was lazily loaded using LazyModuleLoader.load() and that module imported another module already present in the application's dependency graph, the providers from the imported module were re-instantiated. This meant that instead of sharing the existing singleton instance, a new instance was created for the lazy module. This led to resource leaks (e.g., DB connections, SDK clients) because the newly created instances would never be cleaned up, and repeated lazy loads would create more and more unused instances.

Why it's non-trivial

A naive fix might simply check if a module exists and skip adding it again, but the dependency injection system is complex:

  • The code must ensure that the existing provider instance is actually reused, not just the module reference.
  • The lazy loading mechanism must not re-run constructors or overwrite existing providers.
  • The fix must not break global module sharing or other lifecycle behaviors.
  • Tests must verify both eager and lazy loading, repeated loads, and global module scenarios.

The approach

Correcting the module insertion logic

Previously, the container would always return inserted: true even if the module already existed, causing downstream logic to treat it as a new module and re-instantiate providers. This is fixed by:

-        inserted: true,
+        inserted: false,

What this does:
When a module is already present in the container, addModule now correctly returns inserted: false. This signals to the scanner that the module was not newly inserted.

Preventing re-scanning and re-instantiation on lazy load

The scanner now short-circuits when a module is already present and being lazily loaded:

+    if (lazy && !moduleInserted) {
+      return [];
+    }

What this does:
If the module wasn't inserted (i.e., it already exists) and the operation is a lazy load, the scanner returns immediately, skipping the provider re-registration and re-instantiation steps.

Ensuring proper default values in injector creation

A minor fix ensures that the preview flag is properly defaulted to false instead of using a potentially undefined value:

-        preview: container.contextOptions?.preview!,
+        preview: container.contextOptions?.preview ?? false,

What this does:
Prevents accidental use of undefined for the preview option, which could affect injector behavior.

Regression tests for singleton sharing and repeated loading

New tests verify the correct sharing of singleton providers, prevention of duplicate constructions, and continued correct behavior for global modules:

      it('should share already-initialized singleton providers with lazily-loaded consumer', async () => {
        const lazyModuleRef = await lazyModuleLoader.load(() => LazyModule);
        const lazyConsumer = lazyModuleRef.get(Consumer);
        expect(lazyConsumer.shared).to.equal(eagerSharedService);
        expect(constructionsCount).to.equal(1);
      });

      it('should not construct the provider again on repeated load() calls', async () => {
        await lazyModuleLoader.load(() => LazyModule);
        expect(constructionsCount).to.equal(1);

        await lazyModuleLoader.load(() => LazyModule);
        expect(constructionsCount).to.equal(1);
      });

      it('should correctly support global modules without duplicating constructor calls', async () => {
        const lazyGlobalRef = await lazyModuleLoader.load(
          () => LazyGlobalModule,
        );
        const globalConsumer = lazyGlobalRef.get('GlobalConsumer', {
          strict: false,
        });
        expect(globalConsumer).to.equal('global');
        expect(globalConstructionsCount).to.equal(1);

        await lazyModuleLoader.load(() => LazyGlobalModule);
        expect(globalConstructionsCount).to.equal(1);
      });

What these do:

  • Assert that the singleton provider is shared between eager and lazy modules (same instance).
  • Confirm that repeated lazy loads do not re-run the provider constructor.
  • Verify that global modules also do not duplicate provider construction.

From the review thread

kamilmysliwiec: both lint and integration tests fail now

shariyerShazan: Thanks for flagging this. I've verified locally that the new tests in lazy-module-loader.spec.ts pass (5/5), and lint on the changed files (container.ts, scanner.ts, and the spec file) is clean. I'm looking into the CI failure now to see what's different in that environment. will update once I find the cause.

shariyerShazan: Hi @kamilmysliwiec ,

I've addressed the issues and pushed the fixes. The lint errors have been resolved, and the integration tests and full CI are now passing.

I'd appreciate it if you could take another look when you have time. Thanks!

Takeaways

  • When managing dependency injection, always ensure that singleton providers are not re-instantiated when modules are re-imported or lazily loaded.
  • Return accurate status flags from core container methods to allow higher-level logic to make correct decisions (e.g., whether a module was newly inserted or already present).
  • Add regression tests that verify not just correctness but also resource and lifecycle behaviors, especially around lazy-loading and repeated operations.