@husky-di/module 1.2.2 → 1.3.0

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 (2) hide show
  1. package/README.md +512 -10
  2. package/package.json +2 -2
package/README.md CHANGED
@@ -1,23 +1,525 @@
1
- # Rslib project
1
+ # @husky-di/module
2
2
 
3
- ## Setup
3
+ `@husky-di/module` gives husky-di an ESM-like module system.
4
+ It builds on top of `@husky-di/core`, so groups of services can be declared, imported, exported, and validated like modules.
4
5
 
5
- Install the dependencies:
6
+ If `core` answers "how are services registered and resolved?", `module` answers "how are those services grouped, exposed, and kept from colliding with each other?".
7
+
8
+ ## Is This The Right Package?
9
+
10
+ This package is a good fit when:
11
+
12
+ - a single flat container is no longer enough
13
+ - you want clear declaration, import, and export boundaries
14
+ - you want conflicts and invalid exports to fail during module creation instead of later at runtime
15
+
16
+ If what you currently need is only:
17
+
18
+ - a low-level DI container:
19
+ see `../core/README.md`
20
+ - constructor injection through decorators:
21
+ pair it with `../decorator/README.md`
22
+
23
+ ## What You Get
24
+
25
+ - `createModule()` to create modules
26
+ - `declarations` for local service declarations
27
+ - `imports` for importing exports from other modules
28
+ - `exports` for defining the public boundary of a module
29
+ - `withAliases()` for renaming imported service identifiers
30
+ - creation-time validation for duplicate declarations, import conflicts, invalid exports, circular dependencies, and more
31
+
32
+ ## Installation
6
33
 
7
34
  ```bash
8
- pnpm install
35
+ pnpm add @husky-di/core @husky-di/module
9
36
  ```
10
37
 
11
- ## Get started
38
+ ## Quick Start
12
39
 
13
- Build the library:
40
+ The example below shows the basic idea: `CoreModule` exposes shared capabilities, `UserModule` imports what it needs, then exports its own service.
14
41
 
15
- ```bash
16
- pnpm build
42
+ ```typescript
43
+ import { createServiceIdentifier, resolve } from "@husky-di/core";
44
+ import { createModule } from "@husky-di/module";
45
+
46
+ interface Config {
47
+ apiBaseUrl: string;
48
+ }
49
+
50
+ interface Logger {
51
+ log(message: string): void;
52
+ }
53
+
54
+ interface UserService {
55
+ getUser(id: string): { id: string; name: string };
56
+ }
57
+
58
+ const IConfig = createServiceIdentifier<Config>("IConfig");
59
+ const ILogger = createServiceIdentifier<Logger>("ILogger");
60
+ const IUserService = createServiceIdentifier<UserService>("IUserService");
61
+
62
+ class ConsoleLogger implements Logger {
63
+ log(message: string) {
64
+ console.log(message);
65
+ }
66
+ }
67
+
68
+ class DefaultUserService implements UserService {
69
+ private readonly config = resolve(IConfig);
70
+ private readonly logger = resolve(ILogger);
71
+
72
+ getUser(id: string) {
73
+ this.logger.log(`GET ${this.config.apiBaseUrl}/users/${id}`);
74
+ return { id, name: "Ada" };
75
+ }
76
+ }
77
+
78
+ const CoreModule = createModule({
79
+ name: "CoreModule",
80
+ declarations: [
81
+ {
82
+ serviceIdentifier: IConfig,
83
+ useValue: { apiBaseUrl: "https://api.example.com" },
84
+ },
85
+ {
86
+ serviceIdentifier: ILogger,
87
+ useClass: ConsoleLogger,
88
+ },
89
+ ],
90
+ exports: [IConfig, ILogger],
91
+ });
92
+
93
+ const UserModule = createModule({
94
+ name: "UserModule",
95
+ imports: [CoreModule],
96
+ declarations: [
97
+ {
98
+ serviceIdentifier: IUserService,
99
+ useClass: DefaultUserService,
100
+ },
101
+ ],
102
+ exports: [IUserService],
103
+ });
104
+
105
+ const userService = UserModule.resolve(IUserService);
106
+ console.log(userService.getUser("u-1"));
107
+ ```
108
+
109
+ In this example:
110
+
111
+ - `CoreModule` exposes `IConfig` and `ILogger`
112
+ - `UserModule` imports the exported view of `CoreModule`
113
+ - `DefaultUserService` uses `resolve()` to access dependencies visible inside the module
114
+ - external callers can only resolve services listed in `UserModule.exports`
115
+
116
+ ## Module Mental Model
117
+
118
+ A module has four main parts:
119
+
120
+ ### `name`
121
+
122
+ The display name of the module, used for debugging, error messages, and circular dependency paths.
123
+
124
+ ### `declarations`
125
+
126
+ Local service declarations.
127
+ Each declaration is essentially a `core.register()` configuration plus a `serviceIdentifier`.
128
+
129
+ ```typescript
130
+ const LoggerModule = createModule({
131
+ name: "LoggerModule",
132
+ declarations: [
133
+ {
134
+ serviceIdentifier: "logger",
135
+ useValue: { log: console.log },
136
+ },
137
+ ],
138
+ exports: ["logger"],
139
+ });
140
+ ```
141
+
142
+ `declarations` supports the same provider strategies as `core`:
143
+
144
+ - `useClass`
145
+ - `useFactory`
146
+ - `useValue`
147
+ - `useAlias`
148
+
149
+ ### `imports`
150
+
151
+ Import services exported by other modules.
152
+
153
+ ```typescript
154
+ const AppModule = createModule({
155
+ name: "AppModule",
156
+ imports: [LoggerModule],
157
+ });
158
+ ```
159
+
160
+ What gets imported is not the entire internal state of the other module.
161
+ It is only that module's exported view.
162
+
163
+ ### `exports`
164
+
165
+ Defines which service identifiers this module is willing to expose to the outside.
166
+
167
+ ```typescript
168
+ const LoggerModule = createModule({
169
+ name: "LoggerModule",
170
+ declarations: [
171
+ {
172
+ serviceIdentifier: "logger",
173
+ useValue: { log: console.log },
174
+ },
175
+ {
176
+ serviceIdentifier: "config",
177
+ useValue: { level: "info" },
178
+ },
179
+ ],
180
+ exports: ["logger"],
181
+ });
182
+ ```
183
+
184
+ External callers can resolve `"logger"`, but not `"config"`.
185
+
186
+ ## Declaring Services
187
+
188
+ ### `useClass`
189
+
190
+ ```typescript
191
+ const UserModule = createModule({
192
+ name: "UserModule",
193
+ declarations: [
194
+ {
195
+ serviceIdentifier: IUserService,
196
+ useClass: DefaultUserService,
197
+ },
198
+ ],
199
+ exports: [IUserService],
200
+ });
201
+ ```
202
+
203
+ If the class depends on services inside the module, it is usually best to use `resolve()` as you would in `core`, or to assemble the dependencies explicitly with `useFactory`.
204
+
205
+ ### `useFactory`
206
+
207
+ ```typescript
208
+ const UserModule = createModule({
209
+ name: "UserModule",
210
+ imports: [CoreModule],
211
+ declarations: [
212
+ {
213
+ serviceIdentifier: IUserService,
214
+ useFactory: (container) => {
215
+ const config = container.resolve(IConfig);
216
+ const logger = container.resolve(ILogger);
217
+
218
+ return {
219
+ getUser(id: string) {
220
+ logger.log(`GET ${config.apiBaseUrl}/users/${id}`);
221
+ return { id, name: "Ada" };
222
+ },
223
+ };
224
+ },
225
+ },
226
+ ],
227
+ exports: [IUserService],
228
+ });
229
+ ```
230
+
231
+ ### `useValue`
232
+
233
+ ```typescript
234
+ const ConfigModule = createModule({
235
+ name: "ConfigModule",
236
+ declarations: [
237
+ {
238
+ serviceIdentifier: IConfig,
239
+ useValue: { apiBaseUrl: "https://api.example.com" },
240
+ },
241
+ ],
242
+ exports: [IConfig],
243
+ });
244
+ ```
245
+
246
+ ### `useAlias`
247
+
248
+ You can also create aliases inside module-local declarations:
249
+
250
+ ```typescript
251
+ const LoggerModule = createModule({
252
+ name: "LoggerModule",
253
+ declarations: [
254
+ { serviceIdentifier: ILogger, useClass: ConsoleLogger },
255
+ { serviceIdentifier: "appLogger", useAlias: ILogger },
256
+ ],
257
+ exports: ["appLogger"],
258
+ });
259
+ ```
260
+
261
+ ## Importing And Re-exporting
262
+
263
+ A module can import services and optionally export them again.
264
+
265
+ ```typescript
266
+ const SharedModule = createModule({
267
+ name: "SharedModule",
268
+ imports: [ConfigModule, LoggerModule],
269
+ exports: [IConfig, ILogger],
270
+ });
271
+ ```
272
+
273
+ `SharedModule` has no local declarations here.
274
+ It simply re-exposes services exported by its imported modules.
275
+
276
+ ## `withAliases()`: Renaming Imports
277
+
278
+ If multiple modules export the same identifier, or if you want a different local name in the current module, use `withAliases()`.
279
+
280
+ ```typescript
281
+ const CoreModule = createModule({
282
+ name: "CoreModule",
283
+ declarations: [
284
+ { serviceIdentifier: "logger", useValue: { log: () => "core" } },
285
+ { serviceIdentifier: "config", useValue: { env: "production" } },
286
+ ],
287
+ exports: ["logger", "config"],
288
+ });
289
+
290
+ const AppModule = createModule({
291
+ name: "AppModule",
292
+ imports: [
293
+ CoreModule.withAliases([
294
+ { serviceIdentifier: "logger", as: "appLogger" },
295
+ ]),
296
+ ],
297
+ exports: ["appLogger", "config"],
298
+ });
299
+
300
+ AppModule.resolve("appLogger");
301
+ AppModule.resolve("config");
302
+ ```
303
+
304
+ Important semantics:
305
+
306
+ - an alias renames, it does not filter
307
+ - an aliased service enters the current module scope under the new name
308
+ - exports that are not aliased still enter under their original names
309
+ - once a service has been aliased, the original imported name is no longer visible in the current module scope
310
+
311
+ So in the example above:
312
+
313
+ - `"appLogger"` is visible
314
+ - `"config"` is visible
315
+ - `"logger"` is not visible
316
+
317
+ ## Why Export Boundaries Matter
318
+
319
+ One of the most important behaviors in `module` is strict export-boundary enforcement.
320
+
321
+ ```typescript
322
+ const DatabaseModule = createModule({
323
+ name: "DatabaseModule",
324
+ declarations: [
325
+ { serviceIdentifier: "config", useValue: { host: "localhost" } },
326
+ { serviceIdentifier: "database", useClass: DatabaseService },
327
+ ],
328
+ exports: ["database"],
329
+ });
330
+
331
+ DatabaseModule.resolve("database"); // ok
332
+ DatabaseModule.resolve("config"); // throws
333
+ ```
334
+
335
+ Here, `"config"` is available internally, but it is not public API.
336
+
337
+ There are two layers to keep in mind:
338
+
339
+ - external callers using `module.resolve()` can only get services listed in `exports`
340
+ - the internal resolution flow can still access local declarations and imported internal dependencies
341
+
342
+ That gives you room to compose internal details without exposing them as public surface area.
343
+
344
+ ## What A Module Instance Can Do
345
+
346
+ The object returned by `createModule()` is also a container facade with export-boundary protection.
347
+
348
+ You can call:
349
+
350
+ - `module.resolve()`
351
+ - `module.isRegistered()`
352
+ - `module.getServiceIdentifiers()`
353
+ - `module.use()`
354
+ - `module.unused()`
355
+ - `module.withAliases()`
356
+
357
+ You can also inspect:
358
+
359
+ - `module.container`
360
+ - `module.name`
361
+ - `module.displayName`
362
+ - `module.declarations`
363
+ - `module.imports`
364
+ - `module.exports`
365
+
366
+ ## What Gets Validated At Creation Time
367
+
368
+ `module` tries to surface structural problems as early as possible.
369
+
370
+ ### Duplicate Declarations
371
+
372
+ The same module cannot declare the same `serviceIdentifier` twice.
373
+
374
+ ### Duplicate Module Imports
375
+
376
+ The same module instance cannot appear more than once in `imports`.
377
+
378
+ ### Import Name Conflicts
379
+
380
+ If two imported modules export the same service and you do not resolve the conflict with aliases, module creation fails immediately.
381
+
382
+ ### Local Declaration And Import Conflicts
383
+
384
+ A locally visible imported name cannot collide with one of the module's own declarations.
385
+
386
+ ### Exporting Non-existent Services
387
+
388
+ Every item in `exports` must come from:
389
+
390
+ - a local declaration
391
+ - an imported module export
392
+ - a service that entered the current module scope through aliasing
393
+
394
+ ### Circular Dependencies
395
+
396
+ The module import graph cannot contain cycles.
397
+
398
+ ## How It Relates To `core` And `decorator`
399
+
400
+ `@husky-di/module` does not replace `core`.
401
+ It is built directly on top of it:
402
+
403
+ - provider semantics come from `core`
404
+ - container resolution and lifecycles come from `core`
405
+ - `resolve()` still comes from `core`
406
+
407
+ If you want to keep using constructor decorator injection inside module `useClass` declarations, you can pair it with `@husky-di/decorator` as well.
408
+ The two packages solve different problems:
409
+
410
+ - `module` owns boundaries, imports, and exports
411
+ - `decorator` owns constructor-parameter injection
412
+
413
+ ## Complete Example
414
+
415
+ ```typescript
416
+ import { createServiceIdentifier, resolve } from "@husky-di/core";
417
+ import { createModule } from "@husky-di/module";
418
+
419
+ interface DatabaseConfig {
420
+ type: string;
421
+ host: string;
422
+ port: number;
423
+ }
424
+
425
+ interface Database {
426
+ connect(): string;
427
+ }
428
+
429
+ interface AuthService {
430
+ authenticate(): { authenticated: true; token: string };
431
+ }
432
+
433
+ interface App {
434
+ bootstrap(): string;
435
+ }
436
+
437
+ const IDatabaseConfig =
438
+ createServiceIdentifier<DatabaseConfig>("IDatabaseConfig");
439
+ const IDatabase = createServiceIdentifier<Database>("IDatabase");
440
+ const IAuthService = createServiceIdentifier<AuthService>("IAuthService");
441
+ const IApp = createServiceIdentifier<App>("IApp");
442
+
443
+ class DatabaseService implements Database {
444
+ private readonly config = resolve(IDatabaseConfig);
445
+
446
+ connect() {
447
+ return `Connected to ${this.config.type} at ${this.config.host}:${this.config.port}`;
448
+ }
449
+ }
450
+
451
+ class DefaultAuthService implements AuthService {
452
+ authenticate() {
453
+ return { authenticated: true, token: "test-token" } as const;
454
+ }
455
+ }
456
+
457
+ class AppService implements App {
458
+ private readonly database = resolve(IDatabase);
459
+ private readonly authService = resolve(IAuthService);
460
+
461
+ bootstrap() {
462
+ this.database.connect();
463
+ this.authService.authenticate();
464
+ return "Application bootstrapped successfully";
465
+ }
466
+ }
467
+
468
+ const DatabaseModule = createModule({
469
+ name: "DatabaseModule",
470
+ declarations: [
471
+ {
472
+ serviceIdentifier: IDatabaseConfig,
473
+ useValue: {
474
+ type: "sqlite",
475
+ host: "localhost",
476
+ port: 3306,
477
+ },
478
+ },
479
+ {
480
+ serviceIdentifier: IDatabase,
481
+ useClass: DatabaseService,
482
+ },
483
+ ],
484
+ exports: [IDatabase],
485
+ });
486
+
487
+ const AuthModule = createModule({
488
+ name: "AuthModule",
489
+ declarations: [
490
+ {
491
+ serviceIdentifier: IAuthService,
492
+ useClass: DefaultAuthService,
493
+ },
494
+ ],
495
+ exports: [IAuthService],
496
+ });
497
+
498
+ const AppModule = createModule({
499
+ name: "AppModule",
500
+ imports: [DatabaseModule, AuthModule],
501
+ declarations: [
502
+ {
503
+ serviceIdentifier: IApp,
504
+ useClass: AppService,
505
+ },
506
+ ],
507
+ exports: [IApp],
508
+ });
509
+
510
+ const app = AppModule.resolve(IApp);
511
+ console.log(app.bootstrap());
17
512
  ```
18
513
 
19
- Build the library in watch mode:
514
+ ## Related Docs
515
+
516
+ - container and resolution model: `../core/README.md`
517
+ - module behavior specification: `./docs/SPECIFICATION.md`
518
+ - decorator support: `../decorator/README.md`
519
+
520
+ ## Local Development
20
521
 
21
522
  ```bash
22
- pnpm dev
523
+ pnpm build
524
+ pnpm test
23
525
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@husky-di/module",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -15,7 +15,7 @@
15
15
  "dist"
16
16
  ],
17
17
  "dependencies": {
18
- "@husky-di/core": "1.2.2"
18
+ "@husky-di/core": "1.3.0"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@rslib/core": "^0.20.1",