@stitchem/core 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/LICENSE +21 -0
  3. package/README.md +815 -0
  4. package/dist/container/container.d.ts +79 -0
  5. package/dist/container/container.js +156 -0
  6. package/dist/container/module.map.d.ts +22 -0
  7. package/dist/container/module.map.js +40 -0
  8. package/dist/context/context.d.ts +181 -0
  9. package/dist/context/context.js +395 -0
  10. package/dist/context/scope.d.ts +30 -0
  11. package/dist/context/scope.js +42 -0
  12. package/dist/core/core.lifecycle.d.ts +41 -0
  13. package/dist/core/core.lifecycle.js +37 -0
  14. package/dist/core/core.lifetime.d.ts +21 -0
  15. package/dist/core/core.lifetime.js +22 -0
  16. package/dist/core/core.types.d.ts +2 -0
  17. package/dist/core/core.types.js +2 -0
  18. package/dist/core/core.utils.d.ts +8 -0
  19. package/dist/core/core.utils.js +13 -0
  20. package/dist/decorator/inject.decorator.d.ts +50 -0
  21. package/dist/decorator/inject.decorator.js +78 -0
  22. package/dist/decorator/injectable.decorator.d.ts +45 -0
  23. package/dist/decorator/injectable.decorator.js +46 -0
  24. package/dist/errors/core.error.d.ts +24 -0
  25. package/dist/errors/core.error.js +59 -0
  26. package/dist/errors/error.codes.d.ts +17 -0
  27. package/dist/errors/error.codes.js +21 -0
  28. package/dist/index.d.ts +25 -0
  29. package/dist/index.js +23 -0
  30. package/dist/injector/injector.d.ts +78 -0
  31. package/dist/injector/injector.js +295 -0
  32. package/dist/instance-wrapper/instance-wrapper.d.ts +61 -0
  33. package/dist/instance-wrapper/instance-wrapper.js +142 -0
  34. package/dist/instance-wrapper/instance-wrapper.types.d.ts +18 -0
  35. package/dist/instance-wrapper/instance-wrapper.types.js +2 -0
  36. package/dist/logger/console.logger.d.ts +52 -0
  37. package/dist/logger/console.logger.js +90 -0
  38. package/dist/logger/logger.token.d.ts +23 -0
  39. package/dist/logger/logger.token.js +23 -0
  40. package/dist/logger/logger.types.d.ts +38 -0
  41. package/dist/logger/logger.types.js +12 -0
  42. package/dist/module/module.d.ts +104 -0
  43. package/dist/module/module.decorator.d.ts +28 -0
  44. package/dist/module/module.decorator.js +42 -0
  45. package/dist/module/module.graph.d.ts +52 -0
  46. package/dist/module/module.graph.js +263 -0
  47. package/dist/module/module.js +181 -0
  48. package/dist/module/module.ref.d.ts +81 -0
  49. package/dist/module/module.ref.js +123 -0
  50. package/dist/module/module.types.d.ts +80 -0
  51. package/dist/module/module.types.js +10 -0
  52. package/dist/provider/provider.guards.d.ts +46 -0
  53. package/dist/provider/provider.guards.js +62 -0
  54. package/dist/provider/provider.interface.d.ts +39 -0
  55. package/dist/provider/provider.interface.js +2 -0
  56. package/dist/test/test.d.ts +22 -0
  57. package/dist/test/test.js +23 -0
  58. package/dist/test/test.module-builder.d.ts +136 -0
  59. package/dist/test/test.module-builder.js +377 -0
  60. package/dist/test/test.module.d.ts +71 -0
  61. package/dist/test/test.module.js +151 -0
  62. package/dist/token/lazy.token.d.ts +44 -0
  63. package/dist/token/lazy.token.js +42 -0
  64. package/dist/token/token.types.d.ts +8 -0
  65. package/dist/token/token.types.js +2 -0
  66. package/dist/token/token.utils.d.ts +9 -0
  67. package/dist/token/token.utils.js +19 -0
  68. package/package.json +62 -0
package/README.md ADDED
@@ -0,0 +1,815 @@
1
+ # @stitchem/core
2
+
3
+ Extensible modular dependency injection for TypeScript.
4
+
5
+ Built on TC39 decorators and modern JavaScript — no `reflect-metadata`, no legacy experimentalDecorators. Uses `AsyncLocalStorage` for scope propagation and `Symbol.asyncDispose` for deterministic cleanup.
6
+
7
+ ## Requirements
8
+
9
+ - TypeScript >= 5.2 (TC39 decorators + `AsyncDisposable`)
10
+ - Node.js >= 22 (or any runtime with `AsyncLocalStorage` + `Symbol.asyncDispose`)
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install @stitchem/core
16
+ # or
17
+ pnpm add @stitchem/core
18
+ ```
19
+
20
+ ## Quick Start
21
+
22
+ ```ts
23
+ import { injectable, module, Context } from '@stitchem/core';
24
+
25
+ @injectable()
26
+ class GreetingService {
27
+ greet(name: string) {
28
+ return `Hello, ${name}!`;
29
+ }
30
+ }
31
+
32
+ @module({ providers: [GreetingService] })
33
+ class AppModule {}
34
+
35
+ await using ctx = await Context.create(AppModule);
36
+
37
+ const svc = await ctx.resolve(GreetingService);
38
+ console.log(svc.greet('World')); // Hello, World!
39
+ ```
40
+
41
+ The `await using` syntax ensures the context (and all managed instances) is disposed when the scope exits. You can also call `await ctx[Symbol.asyncDispose]()` manually.
42
+
43
+ ---
44
+
45
+ ## Table of Contents
46
+
47
+ - [Providers](#providers)
48
+ - [Constructor Provider](#constructor-provider)
49
+ - [Value Provider](#value-provider)
50
+ - [Factory Provider](#factory-provider)
51
+ - [Class Provider](#class-provider)
52
+ - [Existing Provider](#existing-provider)
53
+ - [Dependency Injection](#dependency-injection)
54
+ - [Constructor Injection](#constructor-injection)
55
+ - [Accessor Injection](#accessor-injection)
56
+ - [Lifetimes](#lifetimes)
57
+ - [Singleton](#singleton)
58
+ - [Transient](#transient)
59
+ - [Scoped](#scoped)
60
+ - [Scopes](#scopes)
61
+ - [withScope](#withscope)
62
+ - [createScope](#createscope)
63
+ - [Modules](#modules)
64
+ - [Imports and Exports](#imports-and-exports)
65
+ - [Global Modules](#global-modules)
66
+ - [Dynamic Modules](#dynamic-modules)
67
+ - [Re-exports](#re-exports)
68
+ - [Lifecycle Hooks](#lifecycle-hooks)
69
+ - [OnInit](#oninit)
70
+ - [OnReady](#onready)
71
+ - [OnDispose](#ondispose)
72
+ - [Circular Dependencies](#circular-dependencies)
73
+ - [Logger](#logger)
74
+ - [Components](#components)
75
+ - [ModuleRef](#moduleref)
76
+ - [Error Handling](#error-handling)
77
+ - [Testing](#testing)
78
+ - [API Reference](#api-reference)
79
+
80
+ ---
81
+
82
+ ## Providers
83
+
84
+ Providers are the building blocks of the DI system. A provider tells the container how to create or locate a dependency.
85
+
86
+ ### Constructor Provider
87
+
88
+ The simplest form — just pass a class decorated with `@injectable()`:
89
+
90
+ ```ts
91
+ @injectable()
92
+ class UserService {
93
+ getUsers() { return ['alice', 'bob']; }
94
+ }
95
+
96
+ @module({ providers: [UserService] })
97
+ class AppModule {}
98
+ ```
99
+
100
+ ### Value Provider
101
+
102
+ Register a static value:
103
+
104
+ ```ts
105
+ const DB_URL = Symbol('DB_URL');
106
+
107
+ @module({
108
+ providers: [
109
+ { provide: DB_URL, useValue: 'postgres://localhost/mydb' },
110
+ ],
111
+ })
112
+ class AppModule {}
113
+ ```
114
+
115
+ Value providers are always singletons. The value is used as-is — no instantiation or lifecycle hooks.
116
+
117
+ ### Factory Provider
118
+
119
+ Use a factory function to create instances. Dependencies can be injected into the factory via `inject`:
120
+
121
+ ```ts
122
+ @module({
123
+ providers: [
124
+ ConfigService,
125
+ {
126
+ provide: 'DB_CONNECTION',
127
+ useFactory: (config: ConfigService) => createConnection(config.dbUrl),
128
+ inject: [ConfigService],
129
+ lifetime: Lifetime.SCOPED,
130
+ },
131
+ ],
132
+ })
133
+ class DbModule {}
134
+ ```
135
+
136
+ ### Class Provider
137
+
138
+ Map a token to a different class implementation:
139
+
140
+ ```ts
141
+ @module({
142
+ providers: [
143
+ { provide: PaymentService, useClass: StripePaymentService },
144
+ ],
145
+ })
146
+ class PaymentModule {}
147
+ ```
148
+
149
+ ### Existing Provider
150
+
151
+ Alias one token to another:
152
+
153
+ ```ts
154
+ @module({
155
+ providers: [
156
+ RealLogger,
157
+ { provide: LOGGER, useExisting: RealLogger },
158
+ ],
159
+ })
160
+ class AppModule {}
161
+ ```
162
+
163
+ ---
164
+
165
+ ## Dependency Injection
166
+
167
+ ### Constructor Injection
168
+
169
+ Declare dependencies with a static `inject` property. The container resolves them in order and passes them to the constructor:
170
+
171
+ ```ts
172
+ @injectable()
173
+ class UserService {
174
+ static inject = [UserRepository, LOGGER] as const;
175
+ constructor(
176
+ private repo: UserRepository,
177
+ private logger: Logger,
178
+ ) {}
179
+ }
180
+ ```
181
+
182
+ The `as const` assertion ensures type safety. Any `Token` type can be used — classes, symbols, or strings.
183
+
184
+ ### Accessor Injection
185
+
186
+ Use the `@inject()` decorator on accessor properties for field-based injection:
187
+
188
+ ```ts
189
+ @injectable()
190
+ class OrderService {
191
+ @inject(LOGGER) accessor logger!: Logger;
192
+ @inject(UserService) accessor users!: UserService;
193
+
194
+ process() {
195
+ this.logger.info('Processing order...');
196
+ return this.users.getUsers();
197
+ }
198
+ }
199
+ ```
200
+
201
+ Accessor injection is resolved after construction. Both injection styles can be mixed in the same class.
202
+
203
+ ---
204
+
205
+ ## Lifetimes
206
+
207
+ Every provider has a lifetime that controls how long its instance lives.
208
+
209
+ ### Singleton
210
+
211
+ **Default.** One instance shared across the entire container. Created once during initialization.
212
+
213
+ ```ts
214
+ @injectable() // or @injectable({ lifetime: Lifetime.SINGLETON })
215
+ class DatabasePool {}
216
+ ```
217
+
218
+ ### Transient
219
+
220
+ A new instance is created every time the dependency is resolved. Never cached.
221
+
222
+ ```ts
223
+ @injectable({ lifetime: Lifetime.TRANSIENT })
224
+ class RequestHandler {}
225
+
226
+ const a = await ctx.resolve(RequestHandler);
227
+ const b = await ctx.resolve(RequestHandler);
228
+ // a !== b — always a fresh instance
229
+ ```
230
+
231
+ ### Scoped
232
+
233
+ One instance per scope. Within the same scope, the same instance is returned. Different scopes get different instances.
234
+
235
+ ```ts
236
+ @injectable({ lifetime: Lifetime.SCOPED })
237
+ class RequestContext {
238
+ requestId = crypto.randomUUID();
239
+ }
240
+ ```
241
+
242
+ Scoped providers require an active scope (via `withScope` or `createScope`). Attempting to resolve a scoped provider without a scope throws `SCOPED_RESOLUTION`. A singleton cannot depend on a scoped provider — this is caught at initialization time.
243
+
244
+ ---
245
+
246
+ ## Scopes
247
+
248
+ Scopes enable request-level isolation. Every HTTP request, WebSocket connection, or job can have its own scope with isolated state.
249
+
250
+ ### withScope
251
+
252
+ The simplest way to create a scope. The scope is automatically disposed when the callback completes:
253
+
254
+ ```ts
255
+ await ctx.withScope(async () => {
256
+ // All resolutions inside this callback share the same scope.
257
+ const reqCtx = await ctx.resolve(RequestContext);
258
+ const logger = await ctx.resolve(RequestLogger);
259
+ // logger.reqCtx === reqCtx (same scope → same instance)
260
+ });
261
+ // Scope disposed here — all scoped instances cleaned up.
262
+ ```
263
+
264
+ `withScope` also disposes scoped instances when the callback throws:
265
+
266
+ ```ts
267
+ await ctx.withScope(async () => {
268
+ await ctx.resolve(DbTransaction);
269
+ throw new Error('failed');
270
+ });
271
+ // DbTransaction.onDispose() still called
272
+ ```
273
+
274
+ ### createScope
275
+
276
+ For manual scope management. Useful when the scope lifetime doesn't align with a single callback:
277
+
278
+ ```ts
279
+ const scope = ctx.createScope();
280
+
281
+ // Run code within the scope
282
+ const svc = await scope.run(() => ctx.resolve(RequestContext));
283
+
284
+ // Same scope → same instance
285
+ const same = await scope.run(() => ctx.resolve(RequestContext));
286
+ assert(svc === same);
287
+
288
+ // Dispose when done
289
+ await scope[Symbol.asyncDispose]();
290
+ ```
291
+
292
+ Or with `await using`:
293
+
294
+ ```ts
295
+ {
296
+ await using scope = ctx.createScope();
297
+ const svc = await scope.run(() => ctx.resolve(RequestContext));
298
+ }
299
+ // Scope auto-disposed at block exit
300
+ ```
301
+
302
+ Scopes nest naturally. Inner scopes get their own instances; outer scopes are unaffected:
303
+
304
+ ```ts
305
+ await ctx.withScope(async () => {
306
+ const outer = await ctx.resolve(ScopedSvc);
307
+
308
+ await ctx.withScope(async () => {
309
+ const inner = await ctx.resolve(ScopedSvc);
310
+ // inner !== outer — different scope
311
+ });
312
+
313
+ const stillOuter = await ctx.resolve(ScopedSvc);
314
+ // stillOuter === outer — outer scope unchanged
315
+ });
316
+ ```
317
+
318
+ ---
319
+
320
+ ## Modules
321
+
322
+ Modules group related providers and define the dependency graph. Every stitchem application has at least one root module.
323
+
324
+ ```ts
325
+ @module({
326
+ imports: [DatabaseModule, AuthModule],
327
+ providers: [UserService, UserRepository],
328
+ exports: [UserService],
329
+ })
330
+ class UserModule {}
331
+ ```
332
+
333
+ ### Imports and Exports
334
+
335
+ Providers are **private to their module** by default. To make a provider available to other modules, export it:
336
+
337
+ ```ts
338
+ // DatabaseModule exports DbConnection
339
+ @module({
340
+ providers: [DbConnection],
341
+ exports: [DbConnection],
342
+ })
343
+ class DatabaseModule {}
344
+
345
+ // UserModule imports DatabaseModule to access DbConnection
346
+ @module({
347
+ imports: [DatabaseModule],
348
+ providers: [UserService],
349
+ })
350
+ class UserModule {}
351
+ ```
352
+
353
+ Only exported tokens are visible to importing modules. This encapsulation prevents accidental coupling.
354
+
355
+ ### Global Modules
356
+
357
+ A global module's exports are available to **all** modules without explicit imports:
358
+
359
+ ```ts
360
+ @module({
361
+ global: true,
362
+ providers: [
363
+ { provide: 'APP_NAME', useValue: 'MyApp' },
364
+ ],
365
+ exports: ['APP_NAME'],
366
+ })
367
+ class ConfigModule {}
368
+
369
+ // No need to import ConfigModule — 'APP_NAME' is available everywhere
370
+ @injectable()
371
+ class AnyService {
372
+ static inject = ['APP_NAME'] as const;
373
+ constructor(public appName: string) {}
374
+ }
375
+ ```
376
+
377
+ Use sparingly. Global modules are convenient for cross-cutting concerns like configuration, logging, and caching.
378
+
379
+ ### Dynamic Modules
380
+
381
+ For configurable modules, use a static factory method that returns a `DynamicModule`:
382
+
383
+ ```ts
384
+ import type { DynamicModule } from '@stitchem/core';
385
+
386
+ @module()
387
+ class CacheModule {
388
+ static forRoot(options: { ttl: number }): DynamicModule {
389
+ return {
390
+ module: CacheModule,
391
+ global: true,
392
+ providers: [
393
+ { provide: 'CACHE_TTL', useValue: options.ttl },
394
+ CacheService,
395
+ ],
396
+ exports: [CacheService],
397
+ };
398
+ }
399
+ }
400
+
401
+ @module({
402
+ imports: [CacheModule.forRoot({ ttl: 300 })],
403
+ })
404
+ class AppModule {}
405
+ ```
406
+
407
+ Dynamic module metadata is merged with the base `@module()` metadata. Array fields (like `providers`) are concatenated.
408
+
409
+ ### Re-exports
410
+
411
+ A module can re-export an imported module, making its exports available to its own consumers:
412
+
413
+ ```ts
414
+ @module({
415
+ imports: [CoreModule],
416
+ exports: [CoreModule], // Re-exports everything CoreModule exports
417
+ })
418
+ class SharedModule {}
419
+
420
+ @module({ imports: [SharedModule] })
421
+ class AppModule {}
422
+ // AppModule can now resolve CoreModule's exports through SharedModule
423
+ ```
424
+
425
+ ---
426
+
427
+ ## Lifecycle Hooks
428
+
429
+ Providers can implement lifecycle hooks for initialization, readiness, and cleanup.
430
+
431
+ ### OnInit
432
+
433
+ Called during instantiation, after the constructor and dependency injection. Supports async.
434
+
435
+ ```ts
436
+ import type { OnInit } from '@stitchem/core';
437
+
438
+ @injectable()
439
+ class DbConnection implements OnInit {
440
+ static inject = ['DB_URL'] as const;
441
+ private pool: any;
442
+
443
+ constructor(private url: string) {}
444
+
445
+ async onInit() {
446
+ this.pool = await createPool(this.url);
447
+ }
448
+ }
449
+ ```
450
+
451
+ If `onInit` throws, `Context.create()` fails — preventing the application from starting with broken dependencies.
452
+
453
+ ### OnReady
454
+
455
+ Called after **all** singleton providers have been created and initialized. Fires once per application lifecycle. Use it for work that depends on the full dependency graph being ready.
456
+
457
+ ```ts
458
+ import type { OnReady } from '@stitchem/core';
459
+
460
+ @injectable()
461
+ class HttpServer implements OnReady {
462
+ onReady() {
463
+ console.log('All services initialized, starting server...');
464
+ this.listen();
465
+ }
466
+ }
467
+ ```
468
+
469
+ ### OnDispose
470
+
471
+ Called when the context (or scope) is disposed. Use it for cleanup — closing connections, releasing resources, flushing buffers.
472
+
473
+ ```ts
474
+ import type { OnDispose } from '@stitchem/core';
475
+
476
+ @injectable()
477
+ class DbConnection implements OnDispose {
478
+ async onDispose() {
479
+ await this.pool.end();
480
+ console.log('Database pool closed');
481
+ }
482
+ }
483
+ ```
484
+
485
+ **Ordering:** `onInit` fires during creation (dependency order). `onReady` fires after all singletons are initialized. `onDispose` fires in reverse module order — dependents are disposed before their dependencies.
486
+
487
+ ---
488
+
489
+ ## Circular Dependencies
490
+
491
+ Circular dependencies (`A → B → A`) are detected at resolution time and throw `CIRCULAR_DEPENDENCY`. To break the cycle, use `lazy()` on one side:
492
+
493
+ ```ts
494
+ import { lazy } from '@stitchem/core';
495
+
496
+ @injectable()
497
+ class AuthService {
498
+ static inject = [lazy(() => UserService)] as const;
499
+ constructor(private users: UserService) {}
500
+ }
501
+
502
+ @injectable()
503
+ class UserService {
504
+ static inject = [AuthService] as const;
505
+ constructor(private auth: AuthService) {}
506
+ }
507
+ ```
508
+
509
+ Only one side of the cycle needs `lazy()`. The lazy side receives a proxy that resolves on first property access. `lazy()` also works in factory `inject` arrays.
510
+
511
+ ---
512
+
513
+ ## Logger
514
+
515
+ Every module gets a `LOGGER` provider automatically. Inject it with the `LOGGER` symbol:
516
+
517
+ ```ts
518
+ import { LOGGER } from '@stitchem/core';
519
+ import type { Logger } from '@stitchem/core';
520
+
521
+ @injectable()
522
+ class UserService {
523
+ static inject = [LOGGER] as const;
524
+ constructor(private logger: Logger) {}
525
+
526
+ create(name: string) {
527
+ this.logger.info(`Creating user: ${name}`);
528
+ }
529
+ }
530
+ ```
531
+
532
+ The `Logger` interface is minimal and compatible with `console`, `pino()`, and `winston.createLogger()`:
533
+
534
+ ```ts
535
+ interface Logger {
536
+ info(message: string, ...args: unknown[]): void;
537
+ warn(message: string, ...args: unknown[]): void;
538
+ error(message: string, ...args: unknown[]): void;
539
+ debug(message: string, ...args: unknown[]): void;
540
+ }
541
+ ```
542
+
543
+ ### Custom Logger
544
+
545
+ Pass your own logger (or disable logging) when creating the context:
546
+
547
+ ```ts
548
+ // Use pino
549
+ const ctx = await Context.create(AppModule, { logger: pino() });
550
+
551
+ // Disable logging
552
+ const ctx = await Context.create(AppModule, { logger: false });
553
+ ```
554
+
555
+ ### ConsoleLogger
556
+
557
+ The built-in `ConsoleLogger` formats output with timestamps and colored symbols:
558
+
559
+ ```
560
+ 12:30:15 [http] ℹ Starting server on port 3000
561
+ 12:30:15 [http] ⚠ TLS certificate expires in 7 days
562
+ 12:30:17 [db] ✗ Connection failed: ECONNREFUSED
563
+ 12:30:18 [db] ◆ Retry succeeded
564
+ ```
565
+
566
+ Control verbosity with `LogLevel`:
567
+
568
+ ```ts
569
+ import { ConsoleLogger, LogLevel } from '@stitchem/core';
570
+
571
+ ConsoleLogger.setLevel(LogLevel.WARN); // Only warn + error
572
+ ```
573
+
574
+ ---
575
+
576
+ ## Components
577
+
578
+ Components are an extension mechanism for framework authors. They allow modules to declare classes (via custom metadata keys) that the core will instantiate with full DI and lifecycle support.
579
+
580
+ ```ts
581
+ // Extend ModuleMetadata in your package
582
+ declare module '@stitchem/core' {
583
+ interface ModuleMetadata {
584
+ routers?: constructor[];
585
+ }
586
+ }
587
+
588
+ // Use the custom key in modules
589
+ @module({
590
+ providers: [DbService],
591
+ routers: [UserRouter, HealthRouter],
592
+ })
593
+ class ApiModule {}
594
+
595
+ // Tell the core to manage the 'routers' key
596
+ const ctx = await Context.create(AppModule, {
597
+ componentKeys: ['routers'],
598
+ });
599
+
600
+ // Retrieve all resolved router instances
601
+ const routers = ctx.getComponents('routers');
602
+ for (const { instance, classRef, moduleRef } of routers) {
603
+ console.log(`${classRef.name} from ${moduleRef.id}`);
604
+ }
605
+ ```
606
+
607
+ Components receive full dependency injection, `onInit`, `onReady`, and `onDispose` lifecycle hooks. They are returned in module dependency order.
608
+
609
+ ---
610
+
611
+ ## ModuleRef
612
+
613
+ `ModuleRef` is automatically registered in every module. Inject it for dynamic resolution and module introspection:
614
+
615
+ ```ts
616
+ import { ModuleRef } from '@stitchem/core';
617
+
618
+ @injectable()
619
+ class PluginLoader {
620
+ static inject = [ModuleRef] as const;
621
+ constructor(private moduleRef: ModuleRef) {}
622
+
623
+ async loadPlugin<T>(token: Token<T>): Promise<T> {
624
+ return this.moduleRef.resolve(token);
625
+ }
626
+ }
627
+ ```
628
+
629
+ `ModuleRef` also supports:
630
+ - `create(ctor)` — creates a new instance bypassing the cache (useful for transient-like behavior on demand)
631
+ - `constructClass(ctor)` — instantiates any class by resolving its dependencies, even if unregistered
632
+ - `select(moduleClass)` — navigates to a different module
633
+ - `getComponents(key)` — gets component instances for the module
634
+
635
+ From the `Context`, use `select()` to navigate to a specific module:
636
+
637
+ ```ts
638
+ const dbRef = ctx.select(DatabaseModule);
639
+ const connection = await dbRef.resolve(DbConnection);
640
+ ```
641
+
642
+ ---
643
+
644
+ ## Error Handling
645
+
646
+ All DI errors are instances of `CoreError` with a machine-readable `code` and structured `context`:
647
+
648
+ ```ts
649
+ import { CoreError, ErrorCode } from '@stitchem/core';
650
+
651
+ try {
652
+ await ctx.resolve(UnknownService);
653
+ } catch (err) {
654
+ if (err instanceof CoreError) {
655
+ switch (err.code) {
656
+ case ErrorCode.PROVIDER_NOT_FOUND:
657
+ console.error('Unknown service:', err.context.token);
658
+ break;
659
+ case ErrorCode.CIRCULAR_DEPENDENCY:
660
+ console.error('Cycle detected:', err.context.path);
661
+ break;
662
+ }
663
+ }
664
+ }
665
+ ```
666
+
667
+ **Error codes:**
668
+
669
+ | Code | When |
670
+ |------|------|
671
+ | `CIRCULAR_DEPENDENCY` | Provider dependency cycle detected |
672
+ | `SCOPED_RESOLUTION` | Scoped provider resolved without a scope |
673
+ | `UNKNOWN_DEPENDENCY` | A dependency token could not be resolved |
674
+ | `INVALID_MODULE` | Class passed to `Context.create()` is not decorated with `@module()` |
675
+ | `CIRCULAR_MODULE_DEPENDENCY` | Module import graph has a cycle |
676
+ | `MODULE_NOT_FOUND` | Module class not found in container |
677
+ | `MODULE_NOT_EXPORTED` | Module listed in `exports` but not `imports` |
678
+ | `NOT_INJECTABLE` | Constructor provider not decorated with `@injectable()` |
679
+ | `INVALID_PROVIDER` | Provider configuration is malformed |
680
+ | `PROVIDER_NOT_FOUND` | Token not registered in any module |
681
+ | `PROVIDER_NOT_VISIBLE` | Provider exists but is not exported to the requesting module |
682
+
683
+ ---
684
+
685
+ ## Testing
686
+
687
+ The `Test` utility provides a builder for creating isolated test modules with provider overrides.
688
+
689
+ ### Basic Setup
690
+
691
+ ```ts
692
+ import { Test } from '@stitchem/core';
693
+ import type { TestModule } from '@stitchem/core';
694
+ import { describe, it, expect, afterEach } from 'vitest';
695
+
696
+ describe('UserService', () => {
697
+ let testModule: TestModule;
698
+
699
+ afterEach(async () => {
700
+ await testModule?.close();
701
+ });
702
+
703
+ it('should return users', async () => {
704
+ testModule = await Test.createModule({
705
+ imports: [UserModule],
706
+ }).compile();
707
+
708
+ const svc = await testModule.resolve(UserService);
709
+ expect(svc.getUsers()).toEqual(['alice', 'bob']);
710
+ });
711
+ });
712
+ ```
713
+
714
+ Or with `await using` for automatic cleanup:
715
+
716
+ ```ts
717
+ it('should return users', async () => {
718
+ await using testModule = await Test.createModule({
719
+ imports: [UserModule],
720
+ }).compile();
721
+
722
+ const svc = await testModule.resolve(UserService);
723
+ expect(svc.getUsers()).toEqual(['alice', 'bob']);
724
+ });
725
+ ```
726
+
727
+ ### Overriding Providers
728
+
729
+ Replace real dependencies with mocks:
730
+
731
+ ```ts
732
+ await using testModule = await Test.createModule({
733
+ imports: [UserModule],
734
+ })
735
+ .overrideProvider(DatabaseService)
736
+ .useValue({ query: vi.fn().mockResolvedValue([]) })
737
+ .compile();
738
+ ```
739
+
740
+ Override methods:
741
+ - `.useValue(value)` — replace with a static value
742
+ - `.useClass(MockClass)` — replace with a different class
743
+ - `.useFactory({ factory, inject? })` — replace with a factory
744
+ - `.useExisting(otherToken)` — alias to another token
745
+
746
+ ### Scoped Testing
747
+
748
+ ```ts
749
+ it('should isolate scoped state', async () => {
750
+ await using testModule = await Test.createModule({
751
+ providers: [ScopedService],
752
+ }).compile();
753
+
754
+ await using scope = testModule.createScope();
755
+ const a = await testModule.resolve(ScopedService, scope);
756
+ const b = await testModule.resolve(ScopedService, scope);
757
+ expect(a).toBe(b); // Same scope → same instance
758
+ });
759
+ ```
760
+
761
+ ### Module Navigation
762
+
763
+ Navigate to a specific module within the test:
764
+
765
+ ```ts
766
+ const ref = testModule.select(DatabaseModule);
767
+ const db = await ref.resolve(DatabaseService);
768
+ ```
769
+
770
+ ---
771
+
772
+ ## API Reference
773
+
774
+ ### Decorators
775
+
776
+ | Decorator | Description |
777
+ |-----------|-------------|
778
+ | `@injectable(options?)` | Marks a class as injectable. Options: `{ lifetime?: Lifetime }` |
779
+ | `@inject(token)` | Accessor decorator for property injection |
780
+ | `@module(metadata?)` | Marks a class as a module |
781
+
782
+ ### Context
783
+
784
+ | Method | Description |
785
+ |--------|-------------|
786
+ | `Context.create(rootModule, options?)` | Creates and initializes a context |
787
+ | `ctx.resolve(token, options?)` | Resolves a provider (async) |
788
+ | `ctx.resolveSync(token, options?)` | Resolves a provider (sync) |
789
+ | `ctx.constructClass(ctor, options?)` | Instantiates any class with DI |
790
+ | `ctx.withScope(fn)` | Runs callback in a new auto-disposed scope |
791
+ | `ctx.createScope()` | Creates a manual disposable scope |
792
+ | `ctx.select(moduleClass)` | Navigates to a specific module |
793
+ | `ctx.getComponents(key)` | Gets component instances for a metadata key |
794
+ | `ctx[Symbol.asyncDispose]()` | Disposes the context and all instances |
795
+
796
+ ### Lifetime
797
+
798
+ | Value | Behavior |
799
+ |-------|----------|
800
+ | `Lifetime.SINGLETON` | One instance for the entire container (default) |
801
+ | `Lifetime.TRANSIENT` | New instance on every resolution |
802
+ | `Lifetime.SCOPED` | One instance per scope |
803
+
804
+ ### Token Types
805
+
806
+ Tokens identify dependencies. Any of these can be used as a token:
807
+
808
+ - **Class constructor** — `UserService`
809
+ - **Symbol** — `Symbol('DB_URL')`
810
+ - **String** — `'CONFIG'`
811
+ - **Lazy token** — `lazy(() => UserService)`
812
+
813
+ ## License
814
+
815
+ MIT