Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d87af3d
feat(shared): add LazyModule type and PROVIDER_TOKEN_CONFLICT graph e…
Isqanderm Sep 15, 2026
4f056b0
feat(core): add lazy() module import marker
Isqanderm Sep 15, 2026
8fa0333
feat(core): skip lazy imports during module registration
Isqanderm Sep 15, 2026
2532e44
feat(core): add LAZY placeholder nodes to the module graph
Isqanderm Sep 15, 2026
6a76f5c
feat(core): compile lazy module segments incrementally with rollback
Isqanderm Sep 15, 2026
6dd1dd7
fix(core): roll back lazy placeholders created by a failed segment
Isqanderm Sep 15, 2026
54c766e
feat(core): add Container.load with lazy module errors
Isqanderm Sep 15, 2026
9f2f98b
feat(core): add NexusApplication.load and ModuleRef
Isqanderm Sep 15, 2026
6ce0428
feat(core): provide LazyModuleLoader through an internal global module
Isqanderm Sep 15, 2026
2204424
docs(core): document lazy modules and LazyModuleLoader
Isqanderm Sep 15, 2026
42e131b
fix(core): serialize lazy segment compilation
Isqanderm Sep 15, 2026
53b9c67
fix(core): return undefined for non-provider tokens in resolver
Isqanderm Sep 15, 2026
467c7fe
fix(core): hide compileSegment from the scanner plugin graph
Isqanderm Sep 15, 2026
6b63da7
test(core): assert the internal module does not leak into the graph
Isqanderm Sep 15, 2026
ab3228d
feat(testing): provide LazyModuleLoader in Test containers
Isqanderm Sep 15, 2026
5c62fe6
docs(core): clarify lazy module API
Isqanderm Sep 15, 2026
ae0ab40
fix(ci): bump internal @nexus-ioc/core dev range to match the workspa…
Isqanderm Sep 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 3 additions & 39 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"sideEffects": false,
"license": "MIT",
"devDependencies": {
"@nexus-ioc/core": "^0.6.1",
"@nexus-ioc/core": "^1.0.0",
"@nexus-ioc/testing": "^0.6.1",
"@types/node": "^24.8.1",
"@vitest/coverage-v8": "^3.2.4",
Expand Down
90 changes: 90 additions & 0 deletions packages/ioc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Nexus IoC is a powerful and flexible Inversion of Control (IoC) container for Ty
- [Features](#features)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Lazy Modules](#lazy-modules)
- [Testing](#testing)
- [License](#license)
- [Author](#author)
Expand Down Expand Up @@ -95,6 +96,95 @@ bootstrap();

```

## Lazy Modules

Declare a module import as lazy to keep it out of the initial bundle. The
module is loaded into the same container on demand and its providers become
resolvable afterwards.

```typescript
import { lazy, Module, NexusApplication } from '@nexus-ioc/core';

export const FeatureLazy = lazy(() =>
import('./feature/feature.module').then((m) => m.FeatureModule),
);

@Module({ imports: [CoreModule, FeatureLazy] })
export class AppModule {}

const app = await NexusApplication.create(AppModule).bootstrap();
const ref = await app.load(FeatureLazy);
const service = await ref.get<FeatureService>(FeatureService);
```

`lazy()` also takes a `name`, used in diagnostics, in error messages and, by
the Vite plugin, for chunk naming. Without it every ref is called
`"LazyModule"`, so name the ones you want to recognize:

```typescript
export const FeatureLazy = lazy(
() => import('./feature/feature.module').then((m) => m.FeatureModule),
{ name: 'Feature' },
);
```

Rules:

- Eager providers cannot depend on providers of a not yet loaded module;
`bootstrap()` reports it as a missing provider.
- Loading is idempotent: the loader runs once per `lazy()` ref.
- Modules already in the container (for example a `SharedModule` imported by
both the root and the lazy module) are reused, so singletons are shared.
- A lazy module that registers a token another module already provides fails
with `PROVIDER_TOKEN_CONFLICT` and is rolled back.

### ModuleRef.get()

`ModuleRef.get()` is strict by default: it sees the module's own providers and
what its imports (and global modules) export to it, and returns `undefined`
for every other token β€” including tokens that do exist elsewhere in the
container. Pass `{ strict: false }` to look the token up in the whole
container instead. Either way the result is `T | undefined`, so check it:

```typescript
const own = await ref.get<FeatureService>(FeatureService); // visible: instance
const other = await ref.get<UnrelatedService>(UnrelatedService); // undefined
const anywhere = await ref.get<UnrelatedService>(UnrelatedService, {
strict: false,
});
```

### Errors

- `LazyModuleLoadError` β€” the loader rejected (a failed dynamic import, for
example) or did not return a class decorated with `@Module()`. Nothing is
added to the graph and the ref may be loaded again.
- `LazyModuleGraphError` β€” the module loaded but its dependency graph is
invalid (a missing dependency, a cycle, a token conflict). Its `errors`
property carries the graph errors, and the segment is rolled back entirely,
so the container is left exactly as it was.

### Loading from a service

To load from a service, inject the built-in `LazyModuleLoader`:

```typescript
@Injectable()
class OrdersService {
constructor(@Inject(LazyModuleLoader) private readonly loader: LazyModuleLoader) {}

async check(order: Order) {
const ref = await this.loader.load(FraudLazy);
const fraud = await ref.get<FraudService>(FraudService);
return fraud?.check(order);
}
}
```

Under `@nexus-ioc/testing` the same loader is registered by `Test.compile()`,
so services that inject it work in tests; the `Test` instance itself also has
`load(ref)` once it is compiled.

## Testing

### Installation
Expand Down
185 changes: 185 additions & 0 deletions packages/ioc/__test__/lazy/application-load.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import "reflect-metadata";
import {
Inject,
Injectable,
lazy,
Module,
ModuleRef,
NexusApplication,
} from "../../src";

describe("NexusApplication.load", () => {
@Injectable()
class SharedService {
readonly id = Math.random();
}
@Module({ providers: [SharedService], exports: [SharedService] })
class SharedModule {}

@Injectable()
class PostsService {
constructor(@Inject(SharedService) readonly shared: SharedService) {}
}
@Injectable()
class PostsInternal {}
@Module({
imports: [SharedModule],
providers: [PostsService, PostsInternal],
exports: [PostsService],
})
class PostsModule {}

@Injectable()
class AnalyticsService {
constructor(@Inject(SharedService) readonly shared: SharedService) {}
}
@Module({
imports: [SharedModule],
providers: [AnalyticsService],
exports: [AnalyticsService],
})
class AnalyticsModule {}

it("returns a ModuleRef that resolves the module's own and exported providers", async () => {
const PostsLazy = lazy(async () => PostsModule, { name: "Posts" });
@Module({ imports: [PostsLazy] })
class AppModule {}
const app = await NexusApplication.create(AppModule).bootstrap();

const ref = await app.load(PostsLazy);
expect(ref).toBeInstanceOf(ModuleRef);
expect(ref.module).toBe(PostsModule);
expect(ref.name).toBe("Posts");
expect(await ref.get(PostsService)).toBeInstanceOf(PostsService);
expect(await ref.get(PostsInternal)).toBeInstanceOf(PostsInternal);
expect(await ref.get(SharedService)).toBeInstanceOf(SharedService);
await app.close();
});

it("strict get() hides tokens the module cannot see; strict:false sees the container", async () => {
@Injectable()
class RootOnly {}
const PostsLazy = lazy(async () => PostsModule);
@Module({ imports: [PostsLazy], providers: [RootOnly] })
class AppModule {}
const app = await NexusApplication.create(AppModule).bootstrap();

const ref = await app.load(PostsLazy);
expect(await ref.get(RootOnly)).toBeUndefined();
expect(await ref.get(RootOnly, { strict: false })).toBeInstanceOf(RootOnly);
await app.close();
});

it("shares one SharedService instance between root and two lazy modules", async () => {
const PostsLazy = lazy(async () => PostsModule);
const AnalyticsLazy = lazy(async () => AnalyticsModule);
@Module({ imports: [SharedModule, PostsLazy, AnalyticsLazy] })
class AppModule {}
const app = await NexusApplication.create(AppModule).bootstrap();

const root = await app.get<SharedService>(SharedService);
const posts = await (await app.load(PostsLazy)).get<PostsService>(
PostsService,
);
const analytics = await (
await app.load(AnalyticsLazy)
).get<AnalyticsService>(AnalyticsService);
expect(posts?.shared).toBe(root);
expect(analytics?.shared).toBe(root);
await app.close();
});

it("makes lazy providers reachable through app.get() after load", async () => {
const PostsLazy = lazy(async () => PostsModule);
@Module({ imports: [PostsLazy] })
class AppModule {}
const app = await NexusApplication.create(AppModule).bootstrap();
expect(await app.get(PostsService)).toBeUndefined();
await app.load(PostsLazy);
expect(await app.get(PostsService)).toBeInstanceOf(PostsService);
await app.close();
});

it("warms up new singletons unless the app is lazy()", async () => {
const initialized: string[] = [];
@Injectable()
class Eager {
onModuleInit() {
initialized.push("Eager");
}
}
@Module({ providers: [Eager] })
class EagerModule {}

const EagerLazy = lazy(async () => EagerModule);
@Module({ imports: [EagerLazy] })
class AppModule {}

const app = await NexusApplication.create(AppModule).bootstrap();
await app.load(EagerLazy);
expect(initialized).toEqual(["Eager"]);
await app.close();

initialized.length = 0;
const lazyApp = await NexusApplication.create(AppModule).lazy().bootstrap();
await lazyApp.load(EagerLazy);
expect(initialized).toEqual([]);
await lazyApp.close();
});

it("supports a lazy module nested inside a lazy module", async () => {
const AnalyticsLazy = lazy(async () => AnalyticsModule);
@Module({ imports: [AnalyticsLazy] })
class ReportsModule {}
const ReportsLazy = lazy(async () => ReportsModule);
@Module({ imports: [SharedModule, ReportsLazy] })
class AppModule {}
const app = await NexusApplication.create(AppModule).bootstrap();

await app.load(ReportsLazy);
expect(await app.get(AnalyticsService)).toBeUndefined();
const analytics = await app.load(AnalyticsLazy);
expect(await analytics.get(AnalyticsService)).toBeInstanceOf(
AnalyticsService,
);
await app.close();
});

it("get() returns undefined for non-provider tokens instead of throwing", async () => {
const PostsLazy = lazy(async () => PostsModule, { name: "Posts" });
@Module({ imports: [PostsLazy] })
class AppModule {}

const app = await NexusApplication.create(AppModule).bootstrap();

expect(await app.get(PostsLazy.id)).toBeUndefined();
expect(await app.get(AppModule)).toBeUndefined();

await app.load(PostsLazy);

expect(await app.get(PostsLazy.id)).toBeUndefined();
expect(await app.get(PostsModule)).toBeUndefined();

await app.close();
});

it("close() destroys providers loaded lazily", async () => {
const destroyed: string[] = [];
@Injectable()
class Disposable {
onModuleDestroy() {
destroyed.push("Disposable");
}
}
@Module({ providers: [Disposable] })
class DisposableModule {}
const DisposableLazy = lazy(async () => DisposableModule);
@Module({ imports: [DisposableLazy] })
class AppModule {}

const app = await NexusApplication.create(AppModule).bootstrap();
await app.load(DisposableLazy);
await app.close();
expect(destroyed).toEqual(["Disposable"]);
});
});
Loading
Loading