sandly 1.0.0 → 2.0.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 (4) hide show
  1. package/README.md +314 -2624
  2. package/dist/index.d.ts +645 -1557
  3. package/dist/index.js +522 -921
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -1,35 +1,46 @@
1
1
  # Sandly
2
2
 
3
- Dependency injection for TypeScript that actually uses the type system. No runtime reflection, no experimental decorators, just compile-time type safety that prevents entire classes of bugs before your code ever runs.
4
-
5
- The name **Sandly** comes from **S**ervices **and** **L**a**y**ers - the two core abstractions for organizing dependencies in large applications.
3
+ Type-safe dependency injection for TypeScript. No decorators, no runtime reflection, just compile-time safety that catches errors before your code runs.
6
4
 
7
5
  ## Why Sandly?
8
6
 
9
- Most TypeScript DI libraries rely on experimental decorators and runtime reflection, losing type safety in the process. Sandly takes a different approach: the container tracks every registered dependency at the type level, making it impossible to resolve unregistered dependencies or create circular dependency chains without TypeScript catching it at compile time.
7
+ Most TypeScript DI libraries rely on experimental decorators and runtime reflection, losing type safety in the process. Sandly takes a different approach: the container tracks every registered dependency at the type level, making it impossible to resolve unregistered dependencies at compile time.
10
8
 
11
9
  ```typescript
12
- import { Container, Tag } from 'sandly';
10
+ import { Container, Layer } from 'sandly';
13
11
 
14
- class UserService extends Tag.Service('UserService') {
15
- getUsers() {
16
- return ['alice', 'bob'];
17
- }
12
+ class Database {
13
+ query(sql: string) { return []; }
18
14
  }
19
15
 
20
- const container = Container.empty().register(
21
- UserService,
22
- () => new UserService()
23
- );
16
+ class UserService {
17
+ constructor(private db: Database) {}
18
+ getUsers() { return this.db.query('SELECT * FROM users'); }
19
+ }
24
20
 
25
- // TypeScript knows UserService is registered
21
+ // Define layers
22
+ const dbLayer = Layer.service(Database, []);
23
+ const userLayer = Layer.service(UserService, [Database]);
24
+
25
+ // Compose and create container
26
+ const container = Container.from(userLayer.provide(dbLayer));
27
+
28
+ // TypeScript knows UserService is available
26
29
  const users = await container.resolve(UserService);
27
30
 
28
- // TypeScript error - OrderService not registered
31
+ // TypeScript error - OrderService not registered
29
32
  const orders = await container.resolve(OrderService);
30
- // Error: Argument of type 'typeof OrderService' is not assignable to parameter of type 'typeof UserService'
31
33
  ```
32
34
 
35
+ **Key features:**
36
+
37
+ - **Compile-time safety**: TypeScript catches missing dependencies before runtime
38
+ - **No decorators**: Works with standard TypeScript, no experimental features
39
+ - **Async support**: Factories and cleanup functions can be async
40
+ - **Composable layers**: Organize dependencies into reusable modules
41
+ - **Scoped containers**: Hierarchical dependency management for web servers
42
+ - **Zero dependencies**: Tiny library with no runtime overhead
43
+
33
44
  ## Installation
34
45
 
35
46
  ```bash
@@ -40,2746 +51,425 @@ pnpm add sandly
40
51
  yarn add sandly
41
52
  ```
42
53
 
43
- Recommended version of TypeScript is 5.0+.
54
+ Requires TypeScript 5.0+.
44
55
 
45
56
  ## Quick Start
46
57
 
47
- Here's a complete example showing dependency injection with automatic cleanup:
48
-
49
- ```typescript
50
- import { Container, Tag } from 'sandly';
51
-
52
- // Define services using Tag.Service
53
- class Database extends Tag.Service('Database') {
54
- async query(sql: string) {
55
- console.log(`Executing: ${sql}`);
56
- return [{ id: 1, name: 'Alice' }];
57
- }
58
-
59
- async close() {
60
- console.log('Database connection closed');
61
- }
62
- }
63
-
64
- class UserRepository extends Tag.Service('UserRepository') {
65
- constructor(private db: Database) {
66
- super();
67
- }
68
-
69
- async findAll() {
70
- return this.db.query('SELECT * FROM users');
71
- }
72
- }
73
-
74
- // Register services with their factories
75
- const container = Container.empty()
76
- .register(Database, {
77
- create: () => new Database(),
78
- cleanup: (db) => db.close(), // Cleanup when container is destroyed
79
- })
80
- .register(
81
- UserRepository,
82
- async (ctx) => new UserRepository(await ctx.resolve(Database))
83
- );
84
-
85
- // Use the services
86
- const userRepo = await container.resolve(UserRepository);
87
- const users = await userRepo.findAll();
88
- console.log(users); // [{ id: 1, name: 'Alice' }]
89
-
90
- // Clean up all resources
91
- await container.destroy(); // Calls db.close()
92
- ```
93
-
94
- **Key concepts:**
95
-
96
- - **Tags** identify dependencies. Use `Tag.Service()` for classes or `Tag.of()` for values.
97
- - **Container** manages service instantiation and caching. Each service is created once (singleton).
98
- - **Factories** create service instances and can resolve other dependencies via the resolution context.
99
- - **Finalizers** (optional) clean up resources when the container is destroyed.
100
-
101
- For larger applications, use **Layers** to organize dependencies into composable modules:
102
-
103
- ```typescript
104
- import { layer, autoService, Container } from 'sandly';
105
-
106
- // Layer that provides Database
107
- const databaseLayer = layer<never, typeof Database>((container) =>
108
- container.register(Database, {
109
- create: () => new Database(),
110
- cleanup: (db) => db.close(),
111
- })
112
- );
113
-
114
- // Layer that provides UserRepository (depends on Database)
115
- const userRepositoryLayer = autoService(UserRepository, [Database]);
116
-
117
- // Compose layers - userRepositoryLayer.provide(databaseLayer) creates
118
- // a complete layer with all dependencies satisfied
119
- const appLayer = userRepositoryLayer.provide(databaseLayer);
120
-
121
- // Apply to container
122
- const container = appLayer.register(Container.empty());
123
- const userRepo = await container.resolve(UserRepository);
124
- ```
125
-
126
- Continue reading to learn about all features including value tags, layer composition, and scope management.
127
-
128
- ## Main Features
129
-
130
- ### Type Safety
131
-
132
- The container tracks registered dependencies in its generic type parameters, making it impossible to resolve unregistered dependencies.
133
-
134
58
  ```typescript
135
- import { Container, Tag } from 'sandly';
59
+ import { Container, Layer, Tag } from 'sandly';
136
60
 
137
- class CacheService extends Tag.Service('CacheService') {
138
- get(key: string) {
139
- return null;
140
- }
61
+ // Any class can be a dependency - no special base class needed
62
+ class Database {
63
+ async query(sql: string) {
64
+ return [{ id: 1, name: 'Alice' }];
65
+ }
66
+ async close() {
67
+ console.log('Database closed');
68
+ }
141
69
  }
142
70
 
143
- class EmailService extends Tag.Service('EmailService') {
144
- send(to: string) {}
71
+ class UserRepository {
72
+ constructor(private db: Database) {}
73
+ findAll() {
74
+ return this.db.query('SELECT * FROM users');
75
+ }
145
76
  }
146
77
 
147
- // Container knows exactly what's registered
148
- const container = Container.empty().register(
149
- CacheService,
150
- () => new CacheService()
151
- );
152
- // Type: Container<typeof CacheService>
78
+ // Create layers
79
+ const dbLayer = Layer.service(Database, [], {
80
+ cleanup: (db) => db.close()
81
+ });
153
82
 
154
- // Works - CacheService is registered
155
- const cache = await container.resolve(CacheService);
83
+ const userRepoLayer = Layer.service(UserRepository, [Database]);
156
84
 
157
- // TypeScript error - EmailService not registered
158
- const email = await container.resolve(EmailService);
159
- // Error: Argument of type 'typeof EmailService' is not assignable
160
- // to parameter of type 'typeof CacheService'
161
- ```
85
+ // Compose layers and create container
86
+ const appLayer = userRepoLayer.provide(dbLayer);
87
+ const container = Container.from(appLayer);
162
88
 
163
- Type information is preserved through method chaining:
89
+ // Use services
90
+ const repo = await container.resolve(UserRepository);
91
+ const users = await repo.findAll();
164
92
 
165
- ```typescript
166
- const container = Container.empty()
167
- .register(CacheService, () => new CacheService())
168
- .register(EmailService, () => new EmailService());
169
- // Type: Container<typeof CacheService | typeof EmailService>
170
-
171
- // Now both work
172
- const cache = await container.resolve(CacheService);
173
- const email = await container.resolve(EmailService);
93
+ // Clean up
94
+ await container.destroy();
174
95
  ```
175
96
 
176
- Dependencies are tracked in factory functions too:
177
-
178
- ```typescript
179
- class UserService extends Tag.Service('UserService') {
180
- constructor(
181
- private cache: CacheService,
182
- private email: EmailService
183
- ) {
184
- super();
185
- }
186
- }
97
+ ## Core Concepts
187
98
 
188
- // Factory resolution context only allows registered dependencies
189
- // and must return a value of the same type as the dependency
190
- const container = Container.empty()
191
- .register(CacheService, () => new CacheService())
192
- .register(EmailService, () => new EmailService())
193
- .register(UserService, async (ctx) => {
194
- // ctx.resolve() only accepts CacheService or EmailService
195
- return new UserService(
196
- await ctx.resolve(CacheService),
197
- await ctx.resolve(EmailService)
198
- );
199
- });
200
- ```
99
+ ### Tags
201
100
 
202
- ### Modular Architecture with Layers
101
+ Tags identify dependencies. There are two types:
203
102
 
204
- For large applications, organizing dependencies into layers helps manage complexity and makes dependencies composable.
103
+ **Classes as tags**: Any class constructor can be used directly as a tag:
205
104
 
206
105
  ```typescript
207
- import { layer, service, constant, Tag, Container } from 'sandly';
208
-
209
- // Configuration layer - provides primitive values
210
- const Config = Tag.of('Config')<{ databaseUrl: string }>();
211
-
212
- const configLayer = constant(Config, { databaseUrl: process.env.DATABASE_URL! });
213
-
214
- // Database layer - depends on config
215
- class Database extends Tag.Service('Database') {
216
- constructor(private url: string) {
217
- super();
218
- }
219
-
220
- async query(sql: string) {
221
- console.log(`Querying ${this.url}: ${sql}`);
222
- return [];
223
- }
224
- }
225
-
226
- const databaseLayer = layer<typeof Config, typeof Database>((container) =>
227
- container.register(Database, async (ctx) => {
228
- const config = await ctx.resolve(Config);
229
- return new Database(config.databaseUrl);
230
- })
231
- );
232
-
233
- // Service layer - depends on database
234
- class UserService extends Tag.Service('UserService') {
235
- constructor(private db: Database) {
236
- super();
237
- }
238
-
239
- async getUsers() {
240
- return this.db.query('SELECT * FROM users');
241
- }
106
+ class UserService {
107
+ getUsers() { return []; }
242
108
  }
243
109
 
244
- const userServiceLayer = service(
245
- UserService,
246
- async (ctx) => new UserService(await ctx.resolve(Database))
247
- );
248
-
249
- // Or alternatively, using shorter syntax:
250
- // const userServiceLayer = autoService(UserService, [Database]);
251
-
252
- // Compose into complete application layer
253
- // Dependencies flow: Config -> Database -> UserService
254
- const appLayer = userServiceLayer.provide(databaseLayer).provide(configLayer);
255
-
256
- // Apply to container - all dependencies satisfied
257
- const container = appLayer.register(Container.empty());
258
- const userService = await container.resolve(UserService);
110
+ // UserService is both the class and its tag
111
+ const layer = Layer.service(UserService, []);
259
112
  ```
260
113
 
261
- Don't worry if you don't understand everything yet - keep reading and you'll learn more about layers and how to use them in practice.
262
-
263
- ### Flexible Dependency Values
264
-
265
- Any value can be a dependency, not just class instances:
114
+ **ValueTags for non-class values**: Use `Tag.of()` for primitives, objects, or functions:
266
115
 
267
116
  ```typescript
268
- import { Tag, constant, Container } from 'sandly';
269
-
270
- // Primitive values
271
117
  const PortTag = Tag.of('Port')<number>();
272
- const DebugModeTag = Tag.of('DebugMode')<boolean>();
273
-
274
- // Configuration objects
275
- interface Config {
276
- apiUrl: string;
277
- timeout: number;
278
- retries: number;
279
- }
280
- const ConfigTag = Tag.of('Config')<Config>();
281
-
282
- // Even functions
283
- type Logger = (msg: string) => void;
284
- const LoggerTag = Tag.of('Logger')<Logger>();
285
-
286
- const container = Container.empty()
287
- .register(PortTag, () => 3000)
288
- .register(DebugModeTag, () => process.env.NODE_ENV === 'development')
289
- .register(ConfigTag, () => ({
290
- apiUrl: 'https://api.example.com',
291
- timeout: 5000,
292
- retries: 3,
293
- }))
294
- .register(LoggerTag, () => (msg: string) => console.log(msg));
295
-
296
- const port = await container.resolve(PortTag); // number
297
- const config = await container.resolve(ConfigTag); // Config
298
- ```
299
-
300
- ### Async Lifecycle Management
301
-
302
- Both service creation and cleanup can be asynchronous:
303
-
304
- ```typescript
305
- import { Container, Tag } from 'sandly';
306
-
307
- class DatabaseConnection extends Tag.Service('DatabaseConnection') {
308
- private connection: any = null;
309
-
310
- async connect() {
311
- console.log('Connecting to database...');
312
- await new Promise((resolve) => setTimeout(resolve, 100));
313
- this.connection = {
314
- /* connection object */
315
- };
316
- console.log('Connected!');
317
- }
318
-
319
- async disconnect() {
320
- console.log('Disconnecting from database...');
321
- await new Promise((resolve) => setTimeout(resolve, 50));
322
- this.connection = null;
323
- console.log('Disconnected!');
324
- }
325
-
326
- query(sql: string) {
327
- if (!this.connection) throw new Error('Not connected');
328
- return [];
329
- }
330
- }
331
-
332
- const container = Container.empty().register(DatabaseConnection, {
333
- create: async () => {
334
- const db = new DatabaseConnection();
335
- await db.connect(); // Async initialization
336
- return db;
337
- },
338
- cleanup: async (db) => {
339
- await db.disconnect(); // Async cleanup
340
- },
341
- });
342
-
343
- // Use the service
344
- const db = await container.resolve(DatabaseConnection);
345
- await db.query('SELECT * FROM users');
118
+ const ConfigTag = Tag.of('Config')<{ apiUrl: string }>();
346
119
 
347
- // Clean shutdown
348
- await container.destroy();
349
- // Output:
350
- // Disconnecting from database...
351
- // Disconnected!
120
+ const portLayer = Layer.value(PortTag, 3000);
121
+ const configLayer = Layer.value(ConfigTag, { apiUrl: 'https://api.example.com' });
352
122
  ```
353
123
 
354
- ### Powerful Scope Management
124
+ ### Container
355
125
 
356
- Scoped containers enable hierarchical dependency management - perfect for web servers where some services live for the application lifetime while others are request-specific:
126
+ Containers manage dependency instantiation and lifecycle:
357
127
 
358
128
  ```typescript
359
- import { ScopedContainer, Tag } from 'sandly';
129
+ // Create from layers (recommended)
130
+ const container = Container.from(appLayer);
360
131
 
361
- // Application-level (singleton)
362
- class Database extends Tag.Service('Database') {
363
- query(sql: string) {
364
- return [];
365
- }
366
- }
132
+ // Or build manually
133
+ const container = Container.builder()
134
+ .add(Database, () => new Database())
135
+ .add(UserService, async (ctx) =>
136
+ new UserService(await ctx.resolve(Database))
137
+ )
138
+ .build();
367
139
 
368
- // Request-level
369
- class RequestContext extends Tag.Service('RequestContext') {
370
- constructor(public requestId: string) {
371
- super();
372
- }
373
- }
140
+ // Resolve dependencies
141
+ const db = await container.resolve(Database);
142
+ const [db, users] = await container.resolveAll(Database, UserService);
374
143
 
375
- // Set up application container with shared services
376
- const rootContainer = ScopedContainer.empty('app').register(
377
- Database,
378
- () => new Database()
144
+ // Use and discard pattern - resolves, runs callback, then destroys
145
+ const result = await container.use(UserService, (service) =>
146
+ service.getUsers()
379
147
  );
380
148
 
381
- // For each HTTP request, create a child scope
382
- async function handleRequest(requestId: string) {
383
- const requestContainer = rootContainer.child('request');
384
-
385
- requestContainer.register(
386
- RequestContext,
387
- () => new RequestContext(requestId)
388
- );
389
-
390
- const ctx = await requestContainer.resolve(RequestContext);
391
- const db = await requestContainer.resolve(Database); // From parent scope
392
-
393
- // Clean up request scope only
394
- await requestContainer.destroy();
395
- }
396
-
397
- // Each request gets isolated scope, but shares Database
398
- await handleRequest('req-1');
399
- await handleRequest('req-2');
400
- ```
401
-
402
- ### Performance & Developer Experience
403
-
404
- **Zero runtime overhead for resolution**: Dependency resolution uses a simple `Map` lookup. Services are instantiated once and cached.
405
-
406
- **No third-party dependencies**: The library has zero runtime dependencies, keeping your bundle size small.
407
-
408
- **No experimental decorators**: Works with standard TypeScript - no special compiler flags or deprecated decorator metadata.
409
-
410
- **IntelliSense works perfectly**: Because dependencies are tracked at the type level, your IDE knows exactly what's available:
411
-
412
- ```typescript
413
- const container = Container.empty()
414
- .register(Database, () => new Database())
415
- .register(Cache, () => new Cache());
416
-
417
- // IDE autocomplete shows: Database | Cache
418
- await container.resolve(/* IDE suggests Database and Cache */);
419
- ```
420
-
421
- **Lazy instantiation**: Services are only created when first resolved:
422
-
423
- ```typescript
424
- const container = Container.empty()
425
- .register(ExpensiveService, () => {
426
- console.log('Creating expensive service...');
427
- return new ExpensiveService();
428
- })
429
- .register(CheapService, () => {
430
- console.log('Creating cheap service...');
431
- return new CheapService();
432
- });
433
-
434
- // Nothing instantiated yet
435
- await container.resolve(CheapService);
436
- // Output: "Creating cheap service..."
437
- // ExpensiveService never created unless resolved
438
- ```
439
-
440
- ### Easy Testing
441
-
442
- Create test containers with real or mocked services:
443
-
444
- ```typescript
445
- import { Container, Tag } from 'sandly';
446
-
447
- class EmailService extends Tag.Service('EmailService') {
448
- async send(to: string, body: string) {
449
- /* real implementation */
450
- }
451
- }
452
-
453
- class UserService extends Tag.Service('UserService') {
454
- constructor(private email: EmailService) {
455
- super();
456
- }
457
-
458
- async registerUser(email: string) {
459
- await this.email.send(email, 'Welcome!');
460
- }
461
- }
462
-
463
- // In the main application, create a live container with real EmailService
464
- const liveContainer = Container.empty()
465
- .register(EmailService, () => new EmailService())
466
- .register(
467
- UserService,
468
- async (ctx) => new UserService(await ctx.resolve(EmailService))
469
- );
470
-
471
- // In the test, override EmailService with mock
472
- const mockEmail = { send: vi.fn() };
473
-
474
- const testContainer = liveContainer.register(EmailService, () => mockEmail);
475
-
476
- const userService = await testContainer.resolve(UserService);
477
- await userService.registerUser('test@example.com');
478
-
479
- expect(mockEmail.send).toHaveBeenCalledWith('test@example.com', 'Welcome!');
149
+ // Manual clean up
150
+ await container.destroy();
480
151
  ```
481
152
 
482
- ## Core Concepts
483
-
484
- Before diving into detailed usage, let's understand the four main building blocks of Sandly.
485
-
486
- ### Tags
487
-
488
- Tags are unique tokens that represent dependencies and serve as a way to reference them in the container. They come in two flavors:
153
+ Each dependency is created once (singleton) and cached.
489
154
 
490
- **ServiceTag** - For class-based dependencies. Created by extending `Tag.Service()`:
491
-
492
- ```typescript
493
- class UserRepository extends Tag.Service('UserRepository') {
494
- findUser(id: string) {
495
- return { id, name: 'Alice' };
496
- }
497
- }
498
- ```
499
-
500
- The class itself serves as both the tag and the implementation. The string identifier can be anything you want,
501
- but the best practice is to use a descriptive name that is unique across your application.
155
+ ### Layers
502
156
 
503
- **ValueTag** - For non-class dependencies (primitives, objects, functions). Created with `Tag.of()`:
157
+ Layers are composable units of dependency registrations:
504
158
 
505
159
  ```typescript
506
- const ApiKeyTag = Tag.of('ApiKey')<string>();
507
- const ConfigTag = Tag.of('Config')<{ port: number }>();
508
- ```
509
-
510
- ValueTags separate the identifier from the value type. The string identifier should be unique in order to avoid collisions in TypeScript type error reporting. The main use-case for ValueTags is for injecting configuration values. Be careful with generic names like `'ApiKey'` or `'Config'` - prefer specific identifiers like `'ThirdPartyApiKey'` or `'HttpClientConfig'`.
160
+ // Layer.service for classes with dependencies
161
+ const userLayer = Layer.service(UserService, [Database, Logger]);
511
162
 
512
- ### Container
513
-
514
- The container manages the lifecycle of your dependencies. It handles:
515
-
516
- - **Registration**: Associating tags with factory functions
517
- - **Resolution**: Creating and caching service instances
518
- - **Dependency injection**: Making dependencies available to factories
519
- - **Lifecycle management**: Calling finalizers when destroyed
520
-
521
- ```typescript
522
- const container = Container.empty()
523
- .register(Database, () => new Database())
524
- .register(
525
- UserRepository,
526
- async (ctx) => new UserRepository(await ctx.resolve(Database))
527
- );
163
+ // Layer.value for constants
164
+ const configLayer = Layer.value(ConfigTag, { port: 3000 });
528
165
 
529
- const repo = await container.resolve(UserRepository);
530
- await container.destroy(); // Clean up
166
+ // Layer.create for custom factory logic
167
+ const cacheLayer = Layer.create({
168
+ requires: [ConfigTag],
169
+ apply: (builder) => builder.add(Cache, async (ctx) => {
170
+ const config = await ctx.resolve(ConfigTag);
171
+ return new Cache({ ttl: config.cacheTtl });
172
+ })
173
+ });
531
174
  ```
532
175
 
533
- Each service is instantiated once (singleton pattern). The container tracks what's registered at the type level, preventing resolution of unregistered dependencies at compile time.
534
-
535
- ### Layers
536
-
537
- Layers are composable units of dependency registrations. Think of them as blueprints that can be combined and reused:
176
+ Compose layers with `provide()`, `provideMerge()`, and `merge()`:
538
177
 
539
178
  ```typescript
540
- // A layer is a function that registers dependencies
541
- const databaseLayer = layer<never, typeof Database>((container) =>
542
- container.register(Database, () => new Database())
543
- );
179
+ // provide: satisfy dependencies, expose only this layer's provisions
180
+ const appLayer = userLayer.provide(dbLayer);
544
181
 
545
- // Layers can depend on other layers
546
- const repositoryLayer = layer<typeof Database, typeof UserRepository>(
547
- (container) =>
548
- container.register(
549
- UserRepository,
550
- async (ctx) => new UserRepository(await ctx.resolve(Database))
551
- )
552
- );
182
+ // merge: combine independent layers
183
+ const infraLayer = Layer.merge(dbLayer, loggerLayer);
184
+ // or
185
+ const infraLayer = Layer.mergeAll(dbLayer, loggerLayer, cacheLayer);
553
186
 
554
- // Compose layers to build complete dependency graphs
555
- const appLayer = repositoryLayer.provide(databaseLayer);
187
+ // provideMerge: satisfy dependencies and expose both layers
188
+ const fullLayer = userLayer.provideMerge(dbLayer);
556
189
  ```
557
190
 
558
- Layers have two type parameters: requirements (what they need) and provisions (what they provide). This allows TypeScript to verify that all dependencies are satisfied when composing layers.
191
+ ### Scoped Containers
559
192
 
560
- Layers make it easy to structure code in large applications by grouping related dependencies into composable modules. Instead of registering services one-by-one across your codebase, you can define layers that encapsulate entire subsystems (authentication, database access, API clients) and compose them declaratively. This improves code organization, enables module reusability, and makes it easier to swap implementations (production vs. test layers).
561
- Keep reading to learn more about how to use layers in practice.
562
-
563
- ### Scopes
564
-
565
- Scoped containers enable hierarchical dependency management. They're useful when you have:
566
-
567
- - **Application-level services** that live for the entire app lifetime (database connections, configuration)
568
- - **Request-level services** that should be created and destroyed per request (request context, user session)
569
- - **Other scopes** like transactions, background jobs, or Lambda invocations
193
+ Scoped containers enable hierarchical dependency management:
570
194
 
571
195
  ```typescript
572
- // Root scope with shared services
573
- const rootContainer = ScopedContainer.empty('app').register(
574
- Database,
575
- () => new Database()
576
- );
196
+ // Application scope - use builder to add dependencies
197
+ const appContainer = ScopedContainer.builder('app')
198
+ .add(Database, () => new Database())
199
+ .build();
577
200
 
578
- // Child scope for each request
579
- const requestContainer = rootContainer
580
- .child('request')
581
- .register(RequestContext, () => new RequestContext());
201
+ // Request scope - use child() to create a child builder
202
+ const requestContainer = appContainer.child('request')
203
+ .add(RequestContext, () => new RequestContext())
204
+ .build();
582
205
 
583
- // Child can access parent services
584
- const db = await requestContainer.resolve(Database); // From parent
206
+ // Child can resolve both its own and parent dependencies
207
+ const db = await requestContainer.resolve(Database); // From parent
208
+ const ctx = await requestContainer.resolve(RequestContext); // From child
585
209
 
586
- // Destroying child doesn't affect parent
210
+ // Destroy child without affecting parent
587
211
  await requestContainer.destroy();
588
212
  ```
589
213
 
590
- Child scopes inherit access to parent dependencies but maintain their own cache. This means a request-scoped service gets its own instance, while application-scoped services are shared across all requests.
591
-
592
- ## Working with Containers
593
-
594
- This section covers direct container usage. For larger applications, you'll typically use layers instead (covered in the next section), but understanding containers is essential.
595
-
596
- ### Creating a Container
597
-
598
- Start with an empty container:
214
+ Or use layers with `childFrom`:
599
215
 
600
216
  ```typescript
601
- import { Container } from 'sandly';
602
-
603
- const container = Container.empty();
604
- // Type: Container<never> - no services registered yet
217
+ const appContainer = ScopedContainer.from('app', dbLayer);
218
+ const requestContainer = appContainer.childFrom('request',
219
+ Layer.value(RequestContext, new RequestContext())
220
+ );
605
221
  ```
606
222
 
607
- ### Registering Dependencies
223
+ ### Use and Discard Pattern
608
224
 
609
- #### Service Tags (Classes)
610
-
611
- Register a class by providing a factory function:
225
+ The `use()` method resolves a service, runs a callback, and automatically destroys the container:
612
226
 
613
227
  ```typescript
614
- import { Tag } from 'sandly';
615
-
616
- class Logger extends Tag.Service('Logger') {
617
- log(msg: string) {
618
- console.log(`[${new Date().toISOString()}] ${msg}`);
619
- }
620
- }
621
-
622
- const container = Container.empty().register(Logger, () => new Logger());
623
- // Type: Container<typeof Logger>
228
+ // Perfect for short-lived operations like Lambda handlers or worker jobs
229
+ const result = await appContainer
230
+ .childFrom('request', requestLayer)
231
+ .use(UserService, (service) => service.processEvent(event));
232
+ // Container is automatically destroyed after callback completes
624
233
  ```
625
234
 
626
- The factory receives a resolution context for injecting dependencies:
627
-
628
- ```typescript
629
- class Database extends Tag.Service('Database') {
630
- query(sql: string) {
631
- return [];
632
- }
633
- }
634
-
635
- class UserRepository extends Tag.Service('UserRepository') {
636
- constructor(
637
- private db: Database,
638
- private logger: Logger
639
- ) {
640
- super();
641
- }
642
-
643
- async findAll() {
644
- this.logger.log('Finding all users');
645
- return this.db.query('SELECT * FROM users');
646
- }
647
- }
235
+ This is especially useful for serverless functions or message handlers where the container lifecycle matches a single operation.
648
236
 
649
- const container = Container.empty()
650
- .register(Database, () => new Database())
651
- .register(Logger, () => new Logger())
652
- .register(UserRepository, async (ctx) => {
653
- // ctx provides resolve() and resolveAll()
654
- const [db, logger] = await ctx.resolveAll(Database, Logger);
655
- return new UserRepository(db, logger);
656
- });
657
- ```
237
+ ## Working with Layers
658
238
 
659
- #### Value Tags (Non-Classes)
239
+ ### Creating Layers
660
240
 
661
- Register values using `Tag.of()`:
241
+ **Layer.service**: Class dependencies with automatic injection
662
242
 
663
243
  ```typescript
664
- const PortTag = Tag.of('server.port')<number>();
665
- const DatabaseUrlTag = Tag.of('database.url')<string>();
666
-
667
- interface AppConfig {
668
- apiKey: string;
669
- timeout: number;
244
+ class ApiClient {
245
+ constructor(private config: Config, private logger: Logger) {}
670
246
  }
671
- const ConfigTag = Tag.of('app.config')<AppConfig>();
672
-
673
- const container = Container.empty()
674
- .register(PortTag, () => 3000)
675
- .register(DatabaseUrlTag, () => process.env.DATABASE_URL!)
676
- .register(ConfigTag, () => ({
677
- apiKey: process.env.API_KEY!,
678
- timeout: 5000,
679
- }));
680
- ```
681
-
682
- ### Resolving Dependencies
683
-
684
- Use `resolve()` to get a service instance:
685
-
686
- ```typescript
687
- const logger = await container.resolve(Logger);
688
- logger.log('Hello!');
689
247
 
690
- // TypeScript error - UserRepository not registered
691
- const repo = await container.resolve(UserRepository);
692
- // Error: Argument of type 'typeof UserRepository' is not assignable...
693
- ```
248
+ // Dependencies must match constructor parameters in order
249
+ const apiLayer = Layer.service(ApiClient, [Config, Logger]);
694
250
 
695
- Resolve multiple dependencies at once:
696
-
697
- ```typescript
698
- const [db, logger] = await container.resolveAll(Database, Logger);
699
- // Returns tuple with correct types: [Database, Logger]
251
+ // With cleanup function
252
+ const dbLayer = Layer.service(Database, [], {
253
+ cleanup: (db) => db.close()
254
+ });
700
255
  ```
701
256
 
702
- Services are singletons - always the same instance:
257
+ **Layer.value**: Constant values
703
258
 
704
259
  ```typescript
705
- const logger1 = await container.resolve(Logger);
706
- const logger2 = await container.resolve(Logger);
707
-
708
- console.log(logger1 === logger2); // true
260
+ const ApiKeyTag = Tag.of('apiKey')<string>();
261
+ const configLayer = Layer.value(ApiKeyTag, process.env.API_KEY!);
709
262
  ```
710
263
 
711
- ### Lifecycle Management
712
-
713
- #### Finalizers for Cleanup
714
-
715
- Register finalizers to clean up resources when the container is destroyed. They receive the created instance and should perform any necessary cleanup (closing connections, releasing resources, etc.):
264
+ **Layer.create**: Custom factory logic
716
265
 
717
266
  ```typescript
718
- class DatabaseConnection extends Tag.Service('DatabaseConnection') {
719
- private connected = false;
720
-
721
- async connect() {
722
- this.connected = true;
723
- console.log('Connected');
724
- }
725
-
726
- async disconnect() {
727
- this.connected = false;
728
- console.log('Disconnected');
729
- }
730
-
731
- query(sql: string) {
732
- if (!this.connected) throw new Error('Not connected');
733
- return [];
734
- }
735
- }
736
-
737
- const container = Container.empty().register(DatabaseConnection, {
738
- // Factory
739
- create: async () => {
740
- const db = new DatabaseConnection();
741
- await db.connect();
742
- return db;
743
- },
744
- // Finalizer
745
- cleanup: async (db) => {
746
- await db.disconnect();
747
- },
267
+ const dbLayer = Layer.create({
268
+ requires: [ConfigTag],
269
+ apply: (builder) => builder.add(Database, async (ctx) => {
270
+ const config = await ctx.resolve(ConfigTag);
271
+ const db = new Database(config.dbUrl);
272
+ await db.connect();
273
+ return db;
274
+ })
748
275
  });
749
-
750
- // Use the service
751
- const db = await container.resolve(DatabaseConnection);
752
- await db.query('SELECT 1');
753
-
754
- // Clean up
755
- await container.destroy();
756
- // Output: "Disconnected"
757
- ```
758
-
759
- You can also implement `DependencyLifecycle` as a class for better organization and reuse:
760
-
761
- ```typescript
762
- import {
763
- Container,
764
- Tag,
765
- type DependencyLifecycle,
766
- type ResolutionContext,
767
- } from 'sandly';
768
-
769
- class Logger extends Tag.Service('Logger') {
770
- log(message: string) {
771
- console.log(message);
772
- }
773
- }
774
-
775
- class DatabaseConnection extends Tag.Service('DatabaseConnection') {
776
- constructor(
777
- private logger: Logger,
778
- private url: string
779
- ) {
780
- super();
781
- }
782
- async connect() {
783
- this.logger.log('Connected');
784
- }
785
- async disconnect() {
786
- this.logger.log('Disconnected');
787
- }
788
- }
789
-
790
- class DatabaseLifecycle
791
- implements DependencyLifecycle<DatabaseConnection, typeof Logger>
792
- {
793
- constructor(private url: string) {}
794
-
795
- async create(
796
- ctx: ResolutionContext<typeof Logger>
797
- ): Promise<DatabaseConnection> {
798
- const logger = await ctx.resolve(Logger);
799
- const db = new DatabaseConnection(logger, this.url);
800
- await db.connect();
801
- return db;
802
- }
803
-
804
- async cleanup(db: DatabaseConnection): Promise<void> {
805
- await db.disconnect();
806
- }
807
- }
808
-
809
- const container = Container.empty()
810
- .register(Logger, () => new Logger())
811
- .register(
812
- DatabaseConnection,
813
- new DatabaseLifecycle('postgresql://localhost:5432')
814
- );
815
276
  ```
816
277
 
817
- The `cleanup` method is optional, so you can implement classes with only a `create` method:
278
+ ### Composing Layers
818
279
 
819
280
  ```typescript
820
- import { Container, Tag, type DependencyLifecycle } from 'sandly';
821
-
822
- class SimpleService extends Tag.Service('SimpleService') {}
823
-
824
- class SimpleServiceFactory
825
- implements DependencyLifecycle<SimpleService, never>
826
- {
827
- create(): SimpleService {
828
- return new SimpleService();
829
- }
830
- // cleanup is optional
831
- }
832
-
833
- const container = Container.empty().register(
834
- SimpleService,
835
- new SimpleServiceFactory()
836
- );
837
- ```
281
+ // Build your application layer by layer
282
+ const configLayer = Layer.value(ConfigTag, loadConfig());
283
+ const dbLayer = Layer.service(Database, [ConfigTag]);
284
+ const repoLayer = Layer.service(UserRepository, [Database]);
285
+ const serviceLayer = Layer.service(UserService, [UserRepository, Logger]);
838
286
 
839
- All finalizers run concurrently when you call `destroy()`:
287
+ // Compose into complete application
288
+ const appLayer = serviceLayer
289
+ .provide(repoLayer)
290
+ .provide(dbLayer)
291
+ .provide(configLayer)
292
+ .provide(Layer.service(Logger, []));
840
293
 
841
- ```typescript
842
- const container = Container.empty()
843
- .register(Database, {
844
- create: () => new Database(),
845
- cleanup: (db) => db.close(),
846
- })
847
- .register(Cache, {
848
- create: () => new Cache(),
849
- cleanup: (cache) => cache.clear(),
850
- });
851
-
852
- // Both finalizers run in parallel
853
- await container.destroy();
294
+ // Create container - all dependencies satisfied
295
+ const container = Container.from(appLayer);
854
296
  ```
855
297
 
856
- #### Overriding Registrations
298
+ ### Layer Type Safety
857
299
 
858
- You can override a registration before it's instantiated:
300
+ Layers track their requirements and provisions at the type level:
859
301
 
860
302
  ```typescript
861
- const container = Container.empty()
862
- .register(Logger, () => new ConsoleLogger())
863
- .register(Logger, () => new FileLogger()); // Overrides previous
864
-
865
- const logger = await container.resolve(Logger);
866
- // Gets FileLogger instance
867
- ```
303
+ const dbLayer = Layer.service(Database, []);
304
+ // Type: Layer<never, typeof Database>
868
305
 
869
- But you cannot override after instantiation:
306
+ const userLayer = Layer.service(UserService, [Database]);
307
+ // Type: Layer<typeof Database, typeof UserService>
870
308
 
871
- ```typescript
872
- const container = Container.empty().register(Logger, () => new Logger());
309
+ const appLayer = userLayer.provide(dbLayer);
310
+ // Type: Layer<never, typeof UserService>
873
311
 
874
- const logger = await container.resolve(Logger); // Instantiated
312
+ // Container.from only accepts layers with no requirements
313
+ const container = Container.from(appLayer); // OK
875
314
 
876
- container.register(Logger, () => new Logger()); // Throws!
877
- // DependencyAlreadyInstantiatedError: Cannot register dependency Logger -
878
- // it has already been instantiated
315
+ const incomplete = Layer.service(UserService, [Database]);
316
+ const container = Container.from(incomplete); // Type error!
879
317
  ```
880
318
 
881
- ### Container Methods
882
-
883
- #### has() - Check if Registered
884
-
885
- ```typescript
886
- const container = Container.empty().register(Logger, () => new Logger());
887
-
888
- console.log(container.has(Logger)); // true
889
- console.log(container.has(Database)); // false
890
- ```
319
+ ## Scoped Containers
891
320
 
892
- #### exists() - Check if Instantiated
321
+ ### Request Scoping for Web Servers
893
322
 
894
323
  ```typescript
895
- const container = Container.empty().register(Logger, () => new Logger());
896
-
897
- console.log(container.exists(Logger)); // false - not instantiated yet
898
-
899
- await container.resolve(Logger);
324
+ import { ScopedContainer, Layer } from 'sandly';
900
325
 
901
- console.log(container.exists(Logger)); // true - now instantiated
902
- ```
903
-
904
- ### Error Handling
905
-
906
- #### Unknown Dependency
326
+ // App-level dependencies (shared across requests)
327
+ const appContainer = ScopedContainer.from('app',
328
+ Layer.mergeAll(dbLayer, loggerLayer)
329
+ );
907
330
 
908
- ```typescript
909
- const container = Container.empty();
331
+ // Express middleware
332
+ app.use(async (req, res, next) => {
333
+ // Create request scope with request-specific dependencies
334
+ const requestScope = appContainer.childFrom('request',
335
+ Layer.value(RequestContext, {
336
+ requestId: crypto.randomUUID(),
337
+ userId: req.user?.id
338
+ })
339
+ );
340
+
341
+ res.locals.container = requestScope;
342
+
343
+ res.on('finish', () => requestScope.destroy());
344
+ next();
345
+ });
910
346
 
911
- try {
912
- await container.resolve(Logger);
913
- } catch (error) {
914
- console.log(error instanceof UnknownDependencyError); // true
915
- console.log(error.message); // "No factory registered for dependency Logger"
916
- }
347
+ // Route handler
348
+ app.get('/users', async (req, res) => {
349
+ const userService = await res.locals.container.resolve(UserService);
350
+ res.json(await userService.getUsers());
351
+ });
917
352
  ```
918
353
 
919
- However, thanks to the type system, the code above will produce a type error if you try to resolve a dependency that hasn't been registered, before you even run your code.
920
-
921
- #### Circular Dependencies
354
+ ### Destruction Order
922
355
 
923
- Circular dependencies are detected at runtime:
356
+ When destroying a scoped container:
357
+ 1. Child scopes are destroyed first
358
+ 2. Then the current scope's finalizers run
359
+ 3. Parent scope is unaffected
924
360
 
925
361
  ```typescript
926
- class ServiceA extends Tag.Service('ServiceA') {}
927
- class ServiceB extends Tag.Service('ServiceB') {}
928
-
929
- const container = Container.empty()
930
- .register(ServiceA, async (ctx) => {
931
- await ctx.resolve(ServiceB);
932
- return new ServiceA();
933
- })
934
- .register(ServiceB, async (ctx) => {
935
- await ctx.resolve(ServiceA); // Circular!
936
- return new ServiceB();
937
- });
938
-
939
- try {
940
- await container.resolve(ServiceA);
941
- } catch (error) {
942
- console.log(error instanceof CircularDependencyError); // true
943
- console.log(error.message);
944
- // "Circular dependency detected for ServiceA: ServiceA -> ServiceB -> ServiceA"
945
- }
946
- ```
947
-
948
- Similarly to unknown dependencies, the type system will catch this error before you even run your code.
949
-
950
- #### Creation Errors
951
-
952
- If a factory throws, the error is wrapped in `DependencyCreationError`:
362
+ const parent = ScopedContainer.builder('parent')
363
+ .add(Database, { create: () => new Database(), cleanup: (db) => db.close() })
364
+ .build();
953
365
 
954
- ```typescript
955
- const container = Container.empty().register(Database, () => {
956
- throw new Error('Connection failed');
957
- });
366
+ const child = parent.child('child')
367
+ .add(Cache, { create: () => new Cache(), cleanup: (c) => c.clear() })
368
+ .build();
958
369
 
959
- try {
960
- await container.resolve(Database);
961
- } catch (error) {
962
- console.log(error instanceof DependencyCreationError); // true
963
- console.log(error.cause); // Original Error: Connection failed
964
- }
370
+ await parent.destroy(); // Destroys child first (Cache.clear), then parent (Database.close)
965
371
  ```
966
372
 
967
- **Nested Creation Errors**
373
+ ## Error Handling
968
374
 
969
- When dependencies are nested (A depends on B, B depends on C), and C's factory throws, you get nested `DependencyCreationError`s. Use `getRootCause()` to unwrap all the layers and get the original error:
375
+ Sandly provides specific error types for common issues:
970
376
 
971
377
  ```typescript
972
- class ServiceC extends Tag.Service('ServiceC') {
973
- constructor() {
974
- super();
975
- throw new Error('Database connection failed');
976
- }
977
- }
978
-
979
- class ServiceB extends Tag.Service('ServiceB') {
980
- constructor(private c: ServiceC) {
981
- super();
982
- }
983
- }
984
-
985
- class ServiceA extends Tag.Service('ServiceA') {
986
- constructor(private b: ServiceB) {
987
- super();
988
- }
989
- }
990
-
991
- const container = Container.empty()
992
- .register(ServiceC, () => new ServiceC())
993
- .register(
994
- ServiceB,
995
- async (ctx) => new ServiceB(await ctx.resolve(ServiceC))
996
- )
997
- .register(
998
- ServiceA,
999
- async (ctx) => new ServiceA(await ctx.resolve(ServiceB))
1000
- );
378
+ import {
379
+ UnknownDependencyError,
380
+ CircularDependencyError,
381
+ DependencyCreationError,
382
+ DependencyFinalizationError
383
+ } from 'sandly';
1001
384
 
1002
385
  try {
1003
- await container.resolve(ServiceA);
386
+ const service = await container.resolve(UserService);
1004
387
  } catch (error) {
1005
- if (error instanceof DependencyCreationError) {
1006
- console.log(error.message);
1007
- // "Error creating instance of ServiceA"
1008
-
1009
- // Get the original error that caused the failure
1010
- const rootCause = error.getRootCause();
1011
- console.log(rootCause);
1012
- // Error: Database connection failed
1013
- }
388
+ if (error instanceof CircularDependencyError) {
389
+ console.log(error.message);
390
+ // "Circular dependency detected for UserService: UserService -> Database -> UserService"
391
+ }
392
+
393
+ if (error instanceof DependencyCreationError) {
394
+ // Get the original error that caused the failure
395
+ const rootCause = error.getRootCause();
396
+ console.log(rootCause.message);
397
+ }
1014
398
  }
1015
399
  ```
1016
400
 
1017
- #### Finalization Errors
1018
-
1019
- If any finalizer fails, cleanup continues for others and a `DependencyFinalizationError` is thrown with details of all failures:
401
+ ## API Reference
1020
402
 
1021
- ```typescript
1022
- class Database extends Tag.Service('Database') {
1023
- async close() {
1024
- throw new Error('Database close failed');
1025
- }
1026
- }
403
+ ### Container
1027
404
 
1028
- class Cache extends Tag.Service('Cache') {
1029
- async clear() {
1030
- throw new Error('Cache clear failed');
1031
- }
1032
- }
1033
-
1034
- const container = Container.empty()
1035
- .register(Database, {
1036
- create: () => new Database(),
1037
- cleanup: async (db) => db.close(),
1038
- })
1039
- .register(Cache, {
1040
- create: () => new Cache(),
1041
- cleanup: async (cache) => cache.clear(),
1042
- });
1043
-
1044
- await container.resolve(Database);
1045
- await container.resolve(Cache);
1046
-
1047
- try {
1048
- await container.destroy();
1049
- } catch (error) {
1050
- if (error instanceof DependencyFinalizationError) {
1051
- // Get all original errors that caused the finalization failure
1052
- const rootCauses = error.getRootCauses();
1053
- console.error('Finalization failures:', rootCauses);
1054
- // [
1055
- // Error: Database close failed,
1056
- // Error: Cache clear failed
1057
- // ]
1058
- }
1059
- }
1060
- ```
1061
-
1062
- ### Type Safety in Action
1063
-
1064
- The container's type parameter tracks all registered dependencies:
1065
-
1066
- ```typescript
1067
- const c1 = Container.empty();
1068
- // Type: Container<never>
1069
-
1070
- const c2 = c1.register(Database, () => new Database());
1071
- // Type: Container<typeof Database>
1072
-
1073
- const c3 = c2.register(Logger, () => new Logger());
1074
- // Type: Container<typeof Database | typeof Logger>
1075
-
1076
- // TypeScript knows what's available
1077
- await c3.resolve(Database); // ✅ OK
1078
- await c3.resolve(Logger); // ✅ OK
1079
- await c3.resolve(Cache); // ❌ Type error
1080
- ```
1081
-
1082
- Factory functions have typed resolution contexts:
1083
-
1084
- ```typescript
1085
- const container = Container.empty()
1086
- .register(Database, () => new Database())
1087
- .register(Logger, () => new Logger())
1088
- .register(UserService, async (ctx) => {
1089
- // ctx can only resolve Database or Logger
1090
- await ctx.resolve(Database); // ✅ OK
1091
- await ctx.resolve(Logger); // ✅ OK
1092
- await ctx.resolve(Cache); // ❌ Type error
1093
-
1094
- return new UserService();
1095
- });
1096
- ```
1097
-
1098
- ### Best Practices
1099
-
1100
- **Use method chaining** - Each `register()` returns the container with updated types:
1101
-
1102
- ```typescript
1103
- // ✅ Good - types flow through chain
1104
- const container = Container.empty()
1105
- .register(Database, () => new Database())
1106
- .register(Logger, () => new Logger())
1107
- .register(
1108
- UserService,
1109
- async (ctx) =>
1110
- new UserService(
1111
- await ctx.resolve(Database),
1112
- await ctx.resolve(Logger)
1113
- )
1114
- );
1115
-
1116
- // ❌ Bad - lose type information
1117
- const container = Container.empty();
1118
- container.register(Database, () => new Database());
1119
- container.register(Logger, () => new Logger());
1120
- // TypeScript doesn't track these registrations
1121
- ```
1122
-
1123
- **Prefer layers for multiple dependencies** - Once you have larger numbers of services and more complex dependency graphs, layers become cleaner. See the next section for more details.
1124
-
1125
- **Handle cleanup errors** - Finalizers can fail:
1126
-
1127
- ```typescript
1128
- try {
1129
- await container.destroy();
1130
- } catch (error) {
1131
- if (error instanceof DependencyFinalizationError) {
1132
- console.error('Cleanup failed:', error.detail.errors);
1133
- // Continue with shutdown anyway
1134
- }
1135
- }
1136
- ```
1137
-
1138
- **Avoid resolving during registration if possible** - Once you resolve a dependency, the container will cache it and you won't be able to override the registration. This might become problematic in case you're composing layers and multiple layers reference the same layer in their provisions (see more on layers below). It's better to keep registration and resolution separate:
1139
-
1140
- ```typescript
1141
- // ❌ Bad - resolving during setup creates timing issues
1142
- const container = Container.empty().register(Logger, () => new Logger());
1143
-
1144
- const logger = await container.resolve(Logger); // During setup!
1145
-
1146
- container.register(Database, () => new Database());
1147
-
1148
- // ✅ Good - register everything first, then resolve
1149
- const container = Container.empty()
1150
- .register(Logger, () => new Logger())
1151
- .register(Database, () => new Database());
1152
-
1153
- // Now use services
1154
- const logger = await container.resolve(Logger);
1155
- ```
1156
-
1157
- However, it's perfectly fine to resolve and even use dependencies inside another dependency factory function.
1158
-
1159
- ```typescript
1160
- // ✅ Also good - resolve dependency inside factory function during the registration
1161
- const container = Container.empty()
1162
- .register(Logger, () => new Logger())
1163
- .register(Database, (ctx) => {
1164
- const db = new Database();
1165
- const logger = await ctx.resolve(Logger);
1166
- logger.log('Database created successfully');
1167
- return db;
1168
- });
1169
- ```
1170
-
1171
- ## Working with Layers
1172
-
1173
- Layers are the recommended approach for organizing dependencies in larger applications. While direct container registration works well for small projects, layers provide better code organization, reusability, and developer experience as your application grows.
1174
-
1175
- ### Why Use Layers?
1176
-
1177
- Layers solve three key problems with manual container registration: repetitive boilerplate, lack of reusability across entry points, and leakage of implementation details.
1178
-
1179
- #### Problem 1: Repetitive Factory Boilerplate
1180
-
1181
- With direct container registration, you must write factory functions repeatedly:
1182
-
1183
- ```typescript
1184
- // user-repository.ts
1185
- export class UserRepository extends Tag.Service('UserRepository') {
1186
- constructor(
1187
- private db: Database,
1188
- private logger: Logger
1189
- ) {
1190
- super();
1191
- }
1192
- // ... implementation
1193
- }
1194
-
1195
- // app.ts - Far away from the implementation!
1196
- const container = Container.empty()
1197
- .register(Database, () => new Database())
1198
- .register(Logger, () => new Logger())
1199
- .register(UserRepository, async (ctx) => {
1200
- // Manually specify what the constructor needs
1201
- const [db, logger] = await ctx.resolveAll(Database, Logger);
1202
- return new UserRepository(db, logger);
1203
- });
1204
- ```
1205
-
1206
- Every service requires manually writing a factory that resolves its dependencies and calls the constructor. This is **repetitive and error-prone** - if you add a dependency to the constructor, you must remember to update the factory too.
1207
-
1208
- **Solution:** Layers provide shorthand helpers (`service`, `autoService`) that eliminate boilerplate and keep the layer definition next to the implementation:
1209
-
1210
- ```typescript
1211
- // user-repository.ts
1212
- export class UserRepository extends Tag.Service('UserRepository') {
1213
- constructor(
1214
- private db: Database,
1215
- private logger: Logger
1216
- ) {
1217
- super();
1218
- }
1219
- // ... implementation
1220
- }
1221
-
1222
- // Layer defined right next to the class
1223
- export const userRepositoryLayer = autoService(UserRepository, [
1224
- Database,
1225
- Logger,
1226
- ]);
1227
-
1228
- // app.ts - Just compose the layers
1229
- const appLayer = userRepositoryLayer.provide(
1230
- Layer.mergeAll(databaseLayer, loggerLayer)
1231
- );
1232
- const container = appLayer.register(Container.empty());
1233
- ```
1234
-
1235
- #### Problem 2: No Reusability Across Entry Points
1236
-
1237
- Applications with multiple entry points (multiple Lambda functions, CLI commands, background workers) need to wire up dependencies separately for each entry point. Without layers, you must duplicate the registration logic:
1238
-
1239
- ```typescript
1240
- // functions/create-user.ts - Lambda that creates users
1241
- export async function handler(event: APIGatewayEvent) {
1242
- // Duplicate ALL the registration logic
1243
- const container = Container.empty()
1244
- .register(Config, () => loadConfig())
1245
- .register(
1246
- Database,
1247
- async (ctx) => new Database(await ctx.resolve(Config))
1248
- )
1249
- .register(Logger, () => new Logger())
1250
- .register(
1251
- UserRepository,
1252
- async (ctx) =>
1253
- new UserRepository(
1254
- await ctx.resolve(Database),
1255
- await ctx.resolve(Logger)
1256
- )
1257
- )
1258
- .register(
1259
- UserService,
1260
- async (ctx) =>
1261
- new UserService(
1262
- await ctx.resolve(UserRepository),
1263
- await ctx.resolve(Logger)
1264
- )
1265
- );
1266
-
1267
- const userService = await container.resolve(UserService);
1268
- // ... handle request
1269
- }
1270
-
1271
- // functions/get-orders.ts - Lambda that fetches orders
1272
- export async function handler(event: APIGatewayEvent) {
1273
- // Duplicate the SAME registration logic AGAIN
1274
- const container = Container.empty()
1275
- .register(Config, () => loadConfig())
1276
- .register(
1277
- Database,
1278
- async (ctx) => new Database(await ctx.resolve(Config))
1279
- )
1280
- .register(Logger, () => new Logger())
1281
- .register(
1282
- OrderRepository,
1283
- async (ctx) =>
1284
- new OrderRepository(
1285
- await ctx.resolve(Database),
1286
- await ctx.resolve(Logger)
1287
- )
1288
- )
1289
- .register(
1290
- OrderService,
1291
- async (ctx) =>
1292
- new OrderService(
1293
- await ctx.resolve(OrderRepository),
1294
- await ctx.resolve(Logger)
1295
- )
1296
- );
1297
- // Uses OrderService but had to register Database, Logger, etc again
1298
-
1299
- const orderService = await container.resolve(OrderService);
1300
- // ... handle request
1301
- }
1302
- ```
1303
-
1304
- This has major problems:
1305
-
1306
- - **Massive duplication**: Registration logic is copy-pasted across entry points
1307
- - **Maintenance nightmare**: When you change `UserRepository`'s dependencies, you must update every Lambda that uses it
1308
- - **Can't compose selectively**: Each entry point must register ALL dependencies, even those it doesn't need
1309
- - **Configuration inconsistency**: Each entry point might configure services differently by accident
1310
-
1311
- **Solution:** Define layers once, compose them differently for each entry point:
1312
-
1313
- ```typescript
1314
- // Shared infrastructure - defined once
1315
- // database.ts
1316
- export const databaseLayer = autoService(Database, [ConfigTag]);
1317
-
1318
- // logger.ts
1319
- export const loggerLayer = autoService(Logger, []);
1320
-
1321
- // config.ts
1322
- export const configLayer = constant(ConfigTag, loadConfig());
1323
-
1324
- // Infrastructure layer combining all base services
1325
- export const infraLayer = Layer.mergeAll(
1326
- databaseLayer,
1327
- loggerLayer,
1328
- configLayer
1329
- );
1330
-
1331
- // Domain layers - defined once
1332
- // user-repository.ts
1333
- export const userRepositoryLayer = autoService(UserRepository, [
1334
- Database,
1335
- Logger,
1336
- ]);
1337
-
1338
- // user-service.ts
1339
- export const userServiceLayer = autoService(UserService, [
1340
- UserRepository,
1341
- Logger,
1342
- ]);
1343
-
1344
- // order-repository.ts
1345
- export const orderRepositoryLayer = autoService(OrderRepository, [
1346
- Database,
1347
- Logger,
1348
- ]);
1349
-
1350
- // order-service.ts
1351
- export const orderServiceLayer = autoService(OrderService, [
1352
- OrderRepository,
1353
- Logger,
1354
- ]);
1355
-
1356
- // Now compose differently for each Lambda
1357
- // functions/create-user.ts
1358
- export async function handler(event: APIGatewayEvent) {
1359
- // Only UserService and its dependencies - no Order code!
1360
- const appLayer = userServiceLayer
1361
- .provide(userRepositoryLayer)
1362
- .provide(infraLayer);
1363
-
1364
- const container = appLayer.register(Container.empty());
1365
- const userService = await container.resolve(UserService);
1366
- // ... handle request
1367
- }
1368
-
1369
- // functions/get-orders.ts
1370
- export async function handler(event: APIGatewayEvent) {
1371
- // Only OrderService and its dependencies - no User code!
1372
- const appLayer = orderServiceLayer
1373
- .provide(orderRepositoryLayer)
1374
- .provide(infraLayer);
1375
-
1376
- const container = appLayer.register(Container.empty());
1377
- const orderService = await container.resolve(OrderService);
1378
- // ... handle request
1379
- }
1380
-
1381
- // functions/admin-dashboard.ts - Needs both!
1382
- export async function handler(event: APIGatewayEvent) {
1383
- // Compose BOTH user and order services
1384
- const appLayer = Layer.mergeAll(
1385
- userServiceLayer.provide(userRepositoryLayer),
1386
- orderServiceLayer.provide(orderRepositoryLayer)
1387
- ).provide(infraLayer);
1388
-
1389
- const container = appLayer.register(Container.empty());
1390
- const userService = await container.resolve(UserService);
1391
- const orderService = await container.resolve(OrderService);
1392
- // ... handle request
1393
- }
1394
- ```
1395
-
1396
- Benefits:
1397
-
1398
- - **Zero duplication**: Each layer is defined once, reused everywhere
1399
- - **Easy maintenance**: Change `UserRepository`'s constructor once, all entry points automatically use the new version
1400
- - **Compose exactly what you need**: Each Lambda only includes the services it actually uses
1401
- - **Consistent configuration**: Infrastructure like Database is configured once in `infraLayer`
1402
-
1403
- #### Problem 3: Requirement Leakage
1404
-
1405
- Without layers, internal implementation details leak into your API. Consider a `UserService` that depends on `UserValidator` and `UserNotifier` internally:
1406
-
1407
- ```typescript
1408
- // Without layers - internal dependencies leak
1409
- export class UserService {
1410
- constructor(
1411
- private validator: UserValidator,
1412
- private notifier: UserNotifier,
1413
- private db: Database
1414
- ) {}
1415
- }
1416
-
1417
- // Consumers must know about internal dependencies
1418
- const container = Container.empty()
1419
- .register(UserValidator, () => new UserValidator())
1420
- .register(UserNotifier, () => new UserNotifier())
1421
- .register(Database, () => new Database())
1422
- .register(
1423
- UserService,
1424
- async (ctx) =>
1425
- new UserService(
1426
- await ctx.resolve(UserValidator),
1427
- await ctx.resolve(UserNotifier),
1428
- await ctx.resolve(Database)
1429
- )
1430
- );
1431
- ```
1432
-
1433
- Consumers need to know about `UserValidator` and `UserNotifier`, even though they're internal implementation details. If you refactor UserService's internals, consumers must update their code.
1434
-
1435
- #### Solution: Encapsulated Requirements
1436
-
1437
- Layers can hide internal dependencies:
1438
-
1439
- ```typescript
1440
- // user-service.ts
1441
- export class UserService extends Tag.Service('UserService') {
1442
- constructor(
1443
- private validator: UserValidator,
1444
- private notifier: UserNotifier,
1445
- private db: Database
1446
- ) {
1447
- super();
1448
- }
1449
- }
1450
-
1451
- // Internal dependencies provided inline
1452
- export const userServiceLayer = autoService(UserService, [
1453
- UserValidator,
1454
- UserNotifier,
1455
- Database,
1456
- ]).provide(Layer.mergeAll(userValidatorLayer, userNotifierLayer));
1457
-
1458
- // Type: Layer<typeof Database, typeof UserService>
1459
- // Only requires Database externally!
1460
-
1461
- // app.ts - Consumers don't see internal dependencies
1462
- const appLayer = userServiceLayer.provide(databaseLayer);
1463
- // Just provide Database, internal details are hidden
1464
- ```
1465
-
1466
- The layer hides `UserValidator` and `UserNotifier` as implementation details. Consumers only need to provide `Database`. You can refactor internals freely without affecting consumers.
1467
-
1468
- ### Benefits Summary
1469
-
1470
- Layers provide:
1471
-
1472
- - **Cleaner syntax**: `autoService()` and `service()` eliminate repetitive factory boilerplate
1473
- - **Reusability**: Define layers once, compose them differently across multiple entry points (Lambda functions, CLI commands, workers)
1474
- - **Selective composition**: Each entry point only includes the dependencies it actually needs
1475
- - **Better organization**: Dependency construction logic lives next to the implementation (code that changes together stays together)
1476
- - **Encapsulation**: Hide internal dependencies from consumers
1477
- - **Type safety**: Requirements and provisions tracked at the type level
1478
-
1479
- ### Creating Layers
1480
-
1481
- #### layer() - Manual Layer Creation
1482
-
1483
- The `layer()` function creates a layer by providing a registration function:
1484
-
1485
- ```typescript
1486
- import { layer, Container } from 'sandly';
1487
-
1488
- class Database extends Tag.Service('Database') {
1489
- query(sql: string) {
1490
- return [];
1491
- }
1492
- }
1493
-
1494
- // Must annotate layer type parameters manually
1495
- const databaseLayer = layer<never, typeof Database>((container) =>
1496
- container.register(Database, () => new Database())
1497
- );
1498
-
1499
- // Apply to container
1500
- const container = databaseLayer.register(Container.empty());
1501
- const db = await container.resolve(Database);
1502
- ```
1503
-
1504
- **Type parameters:**
1505
-
1506
- - First: Requirements (what the layer needs) - `never` means no requirements
1507
- - Second: Provisions (what the layer provides) - `typeof Database`
1508
-
1509
- With dependencies:
1510
-
1511
- ```typescript
1512
- class Logger extends Tag.Service('Logger') {
1513
- log(msg: string) {
1514
- console.log(msg);
1515
- }
1516
- }
1517
-
1518
- class UserRepository extends Tag.Service('UserRepository') {
1519
- constructor(
1520
- private db: Database,
1521
- private logger: Logger
1522
- ) {
1523
- super();
1524
- }
1525
-
1526
- async findAll() {
1527
- this.logger.log('Finding all users');
1528
- return this.db.query('SELECT * FROM users');
1529
- }
1530
- }
1531
-
1532
- // Requires Database and Logger, provides UserRepository
1533
- const userRepositoryLayer = layer<
1534
- typeof Database | typeof Logger,
1535
- typeof UserRepository
1536
- >((container) =>
1537
- container.register(UserRepository, async (ctx) => {
1538
- const [db, logger] = await ctx.resolveAll(Database, Logger);
1539
- return new UserRepository(db, logger);
1540
- })
1541
- );
1542
- ```
1543
-
1544
- #### service() - Service Layer Helper
1545
-
1546
- The `service()` function is a convenience wrapper for creating service layers:
1547
-
1548
- ```typescript
1549
- import { service } from 'sandly';
1550
-
1551
- // Simpler than layer() - infers types from the factory
1552
- const userRepositoryLayer = service(UserRepository, async (ctx) => {
1553
- const [db, logger] = await ctx.resolveAll(Database, Logger);
1554
- return new UserRepository(db, logger);
1555
- });
1556
-
1557
- // With finalizer
1558
- const databaseLayer = service(Database, {
1559
- create: async () => {
1560
- const db = new Database();
1561
- await db.connect();
1562
- return db;
1563
- },
1564
- cleanup: (db) => db.disconnect(),
1565
- });
1566
- ```
1567
-
1568
- The dependencies are automatically inferred from the factory's resolution context.
1569
-
1570
- #### autoService() - Automatic Constructor Injection
1571
-
1572
- The `autoService()` function automatically injects dependencies based on constructor parameters:
1573
-
1574
- ```typescript
1575
- import { autoService } from 'sandly';
1576
-
1577
- class UserRepository extends Tag.Service('UserRepository') {
1578
- constructor(
1579
- private db: Database,
1580
- private logger: Logger
1581
- ) {
1582
- super();
1583
- }
1584
-
1585
- async findAll() {
1586
- this.logger.log('Finding all users');
1587
- return this.db.query('SELECT * FROM users');
1588
- }
1589
- }
1590
-
1591
- // Automatically resolves Database and Logger from constructor
1592
- const userRepositoryLayer = autoService(UserRepository, [Database, Logger]);
1593
- ```
1594
-
1595
- Mix ServiceTag dependencies, ValueTag dependencies, and static values:
1596
-
1597
- ```typescript
1598
- const ApiKeyTag = Tag.of('ApiKey')<string>();
1599
- const TimeoutTag = Tag.of('Timeout')<number>();
1600
-
1601
- class ApiClient extends Tag.Service('ApiClient') {
1602
- constructor(
1603
- private logger: Logger, // ServiceTag - works automatically
1604
- private apiKey: Inject<typeof ApiKeyTag>, // ValueTag - needs Inject<>
1605
- private timeout: Inject<typeof TimeoutTag>, // ValueTag - needs Inject<>
1606
- private baseUrl: string // Static value
1607
- ) {
1608
- super();
1609
- }
1610
- }
1611
-
1612
- // Order matters - must match constructor parameter order
1613
- const apiClientLayer = autoService(ApiClient, [
1614
- Logger, // ServiceTag - resolved from container
1615
- ApiKeyTag, // ValueTag - resolved from container
1616
- TimeoutTag, // ValueTag - resolved from container
1617
- 'https://api.example.com', // Static value - passed directly
1618
- ]);
1619
- ```
1620
-
1621
- **Important**: ValueTag dependencies in constructors must be annotated with `Inject<typeof YourTag>`. This preserves type information for `service()` and `autoService()` to infer the dependency. Without `Inject<>`, TypeScript sees it as a regular value and `service()` and `autoService()` won't know to resolve it from the container.
1622
-
1623
- With cleanup:
1624
-
1625
- ```typescript
1626
- const databaseLayer = autoService(Database, {
1627
- dependencies: ['postgresql://localhost:5432/mydb'],
1628
- cleanup: (db) => db.disconnect(),
1629
- });
1630
- ```
1631
-
1632
- #### dependency() - Generic Dependency Layer
1633
-
1634
- The `dependency()` function creates a layer for any tag type (ServiceTag or ValueTag) with fully inferred types. Unlike `service()` and `autoService()`, it doesn't require extending `Tag.Service()`:
1635
-
1636
- ```typescript
1637
- import { dependency, Tag } from 'sandly';
1638
-
1639
- // Simple dependency without requirements
1640
- const Config = Tag.of('Config')<{ apiUrl: string }>();
1641
-
1642
- const configDep = dependency(Config, () => ({
1643
- apiUrl: process.env.API_URL!,
1644
- }));
1645
-
1646
- // Dependency with requirements - pass them as the last argument
1647
- const Database = Tag.of('Database')<DatabaseConnection>();
1648
-
1649
- const databaseDep = dependency(
1650
- Database,
1651
- async (ctx) => {
1652
- const config = await ctx.resolve(Config);
1653
- return createConnection(config.apiUrl);
1654
- },
1655
- [Config] // Requirements array - enables type inference
1656
- );
1657
- ```
1658
-
1659
- With lifecycle (create + cleanup):
1660
-
1661
- ```typescript
1662
- const databaseDep = dependency(
1663
- Database,
1664
- {
1665
- create: async (ctx) => {
1666
- const config = await ctx.resolve(Config);
1667
- return await createConnection(config.apiUrl);
1668
- },
1669
- cleanup: async (db) => {
1670
- await db.disconnect();
1671
- },
1672
- },
1673
- [Config]
1674
- );
1675
- ```
1676
-
1677
- The `dependency()` function is useful when:
1678
-
1679
- - Working with ValueTags that need dependencies
1680
- - Using third-party classes that can't extend `Tag.Service()`
1681
- - Wanting cleaner syntax than `layer()` without explicit type parameters
1682
-
1683
- #### constant() - Constant Value Layer Helper
1684
-
1685
- The `constant()` function creates a layer that provides a constant value:
1686
-
1687
- ```typescript
1688
- import { constant, Tag } from 'sandly';
1689
-
1690
- const ApiKeyTag = Tag.of('ApiKey')<string>();
1691
- const PortTag = Tag.of('Port')<number>();
1692
-
1693
- const apiKeyLayer = constant(ApiKeyTag, 'my-secret-key');
1694
- const portLayer = constant(PortTag, 3000);
1695
-
1696
- // Combine constant layers
1697
- const configLayer = Layer.mergeAll(
1698
- apiKeyLayer,
1699
- portLayer,
1700
- constant(Tag.of('Debug')<boolean>(), true)
1701
- );
1702
- ```
1703
-
1704
- ### Using Inject<> for ValueTag Dependencies
1705
-
1706
- When using ValueTags as constructor parameters with `autoService()`, you must annotate them with `Inject<>`:
1707
-
1708
- ```typescript
1709
- import { Tag, Inject, autoService } from 'sandly';
1710
-
1711
- const ApiKeyTag = Tag.of('ApiKey')<string>();
1712
- const TimeoutTag = Tag.of('Timeout')<number>();
1713
-
1714
- class ApiClient extends Tag.Service('ApiClient') {
1715
- constructor(
1716
- private logger: Logger, // ServiceTag - works automatically
1717
- private apiKey: Inject<typeof ApiKeyTag>, // ValueTag - needs Inject<>
1718
- private timeout: Inject<typeof TimeoutTag> // ValueTag - needs Inject<>
1719
- ) {
1720
- super();
1721
- }
1722
-
1723
- async get(endpoint: string) {
1724
- // this.apiKey is typed as string (the actual value type)
1725
- // this.timeout is typed as number
1726
- return fetch(endpoint, {
1727
- headers: { Authorization: `Bearer ${this.apiKey}` },
1728
- signal: AbortSignal.timeout(this.timeout),
1729
- });
1730
- }
1731
- }
1732
-
1733
- // autoService infers dependencies from constructor
1734
- const apiClientLayer = autoService(ApiClient, [
1735
- Logger, // ServiceTag
1736
- ApiKeyTag, // ValueTag - resolved from container
1737
- TimeoutTag, // ValueTag - resolved from container
1738
- ]);
1739
- ```
1740
-
1741
- `Inject<>` is a type-level marker that:
1742
-
1743
- - Keeps the actual value type (string, number, etc.)
1744
- - Allows dependency inference for `autoService()`
1745
- - Has no runtime overhead
1746
-
1747
- ### Composing Layers
1748
-
1749
- Layers can be combined in three ways: **provide**, **provideMerge**, and **merge**.
1750
-
1751
- #### .provide() - Sequential Composition
1752
-
1753
- Provides dependencies to a layer, hiding the dependency layer's provisions in the result:
1754
-
1755
- ```typescript
1756
- const configLayer = layer<never, typeof ConfigTag>((container) =>
1757
- container.register(ConfigTag, () => loadConfig())
1758
- );
1759
-
1760
- const databaseLayer = layer<typeof ConfigTag, typeof Database>((container) =>
1761
- container.register(Database, async (ctx) => {
1762
- const config = await ctx.resolve(ConfigTag);
1763
- return new Database(config);
1764
- })
1765
- );
1766
-
1767
- // Database layer needs ConfigTag, which configLayer provides
1768
- const infraLayer = databaseLayer.provide(configLayer);
1769
- // Type: Layer<never, typeof Database>
1770
- // Note: ConfigTag is hidden - it's an internal detail
1771
- ```
1772
-
1773
- The type signature:
1774
-
1775
- ```typescript
1776
- Layer<TRequires, TProvides>.provide(
1777
- dependency: Layer<TDepReq, TDepProv>
1778
- ) => Layer<TDepReq | Exclude<TRequires, TDepProv>, TProvides>
1779
- ```
1780
-
1781
- Reading left-to-right (natural flow):
1782
-
1783
- ```typescript
1784
- const appLayer = serviceLayer // needs: Database, Logger
1785
- .provide(infraLayer) // needs: Config, provides: Database, Logger
1786
- .provide(configLayer); // needs: nothing, provides: Config
1787
- // Result: Layer<never, typeof UserService>
1788
- ```
1789
-
1790
- #### .provideMerge() - Composition with Merged Provisions
1791
-
1792
- Like `.provide()` but includes both layers' provisions in the result:
1793
-
1794
- ```typescript
1795
- const infraLayer = databaseLayer.provideMerge(configLayer);
1796
- // Type: Layer<never, typeof ConfigTag | typeof Database>
1797
- // Both ConfigTag and Database are available
1798
- ```
1799
-
1800
- Use when you want to expose multiple layers' services:
1801
-
1802
- ```typescript
1803
- const AppConfigTag = Tag.of('AppConfig')<AppConfig>();
1804
-
1805
- const configLayer = constant(AppConfigTag, loadConfig());
1806
- const databaseLayer = layer<typeof AppConfigTag, typeof Database>((container) =>
1807
- container.register(
1808
- Database,
1809
- async (ctx) => new Database(await ctx.resolve(AppConfigTag))
1810
- )
1811
- );
1812
-
1813
- // Expose both config and database
1814
- const infraLayer = databaseLayer.provideMerge(configLayer);
1815
- // Type: Layer<never, typeof AppConfigTag | typeof Database>
1816
-
1817
- // Services can use both
1818
- const container = infraLayer.register(Container.empty());
1819
- const config = await container.resolve(AppConfigTag); // Available!
1820
- const db = await container.resolve(Database); // Available!
1821
- ```
1822
-
1823
- #### .merge() - Parallel Combination
1824
-
1825
- Merges two independent layers (no dependency relationship):
1826
-
1827
- ```typescript
1828
- const databaseLayer = layer<never, typeof Database>((container) =>
1829
- container.register(Database, () => new Database())
1830
- );
1831
-
1832
- const loggerLayer = layer<never, typeof Logger>((container) =>
1833
- container.register(Logger, () => new Logger())
1834
- );
1835
-
1836
- // Combine independent layers
1837
- const infraLayer = databaseLayer.merge(loggerLayer);
1838
- // Type: Layer<never, typeof Database | typeof Logger>
1839
- ```
1840
-
1841
- For multiple layers, use `Layer.mergeAll()`:
1842
-
1843
- ```typescript
1844
- const infraLayer = Layer.mergeAll(
1845
- databaseLayer,
1846
- loggerLayer,
1847
- cacheLayer,
1848
- metricsLayer
1849
- );
1850
- ```
1851
-
1852
- ### Static Layer Methods
1853
-
1854
- #### Layer.empty()
1855
-
1856
- Creates an empty layer (no requirements, no provisions):
1857
-
1858
- ```typescript
1859
- import { Layer } from 'sandly';
1860
-
1861
- const emptyLayer = Layer.empty();
1862
- // Type: Layer<never, never>
1863
- ```
1864
-
1865
- #### Layer.merge()
1866
-
1867
- Merges exactly two layers:
1868
-
1869
- ```typescript
1870
- const combined = Layer.merge(databaseLayer, loggerLayer);
1871
- // Equivalent to: databaseLayer.merge(loggerLayer)
1872
- ```
1873
-
1874
- #### Layer.mergeAll()
1875
-
1876
- Merges multiple layers at once:
1877
-
1878
- ```typescript
1879
- const infraLayer = Layer.mergeAll(
1880
- constant(ApiKeyTag, 'key'),
1881
- constant(PortTag, 3000),
1882
- databaseLayer,
1883
- loggerLayer
1884
- );
1885
- // Type: Layer<Requirements, Provisions> with all merged
1886
- ```
1887
-
1888
- Requires at least 2 layers.
1889
-
1890
- ### Applying Layers to Containers
1891
-
1892
- Use the `.register()` method to apply a layer to a container:
1893
-
1894
- ```typescript
1895
- const appLayer = userServiceLayer.provide(databaseLayer).provide(configLayer);
1896
-
1897
- // Apply to container
1898
- const container = appLayer.register(Container.empty());
1899
-
1900
- // Now resolve services
1901
- const userService = await container.resolve(UserService);
1902
- ```
1903
-
1904
- Layers can be applied to containers that already have services:
1905
-
1906
- ```typescript
1907
- const baseContainer = Container.empty().register(Logger, () => new Logger());
1908
-
1909
- // Apply layer to container with existing services
1910
- const container = databaseLayer.register(baseContainer);
1911
- // Container now has both Logger and Database
1912
- ```
1913
-
1914
- #### Type Safety: Requirements Must Be Satisfied
1915
-
1916
- TypeScript ensures that a layer can only be registered on a container that satisfies all of the layer's requirements. This prevents runtime errors from missing dependencies.
1917
-
1918
- ```typescript
1919
- // Layer that requires Database
1920
- const userServiceLayer = autoService(UserService, [Database, Logger]);
1921
-
1922
- // ✅ Works - Container.empty() can be used because layer has no requirements
1923
- // (userServiceLayer was composed with all dependencies via .provide())
1924
- const completeLayer = userServiceLayer
1925
- .provide(userRepositoryLayer)
1926
- .provide(infraLayer);
1927
- // Type: Layer<never, typeof UserService> - no requirements!
1928
-
1929
- const container = completeLayer.register(Container.empty());
1930
- // ✅ TypeScript allows this because completeLayer has no requirements
1931
-
1932
- // ❌ Type error - Layer still has requirements
1933
- const incompleteLayer = userServiceLayer.provide(userRepositoryLayer);
1934
- // Type: Layer<typeof Logger, typeof UserService> - still needs Logger!
1935
-
1936
- const container2 = incompleteLayer.register(Container.empty());
1937
- // ❌ Error: Argument of type 'Container<never>' is not assignable to parameter of type 'IContainer<ServiceTag<"Logger", Logger>>'.
1938
- ```
1939
-
1940
- When applying a layer to an existing container, the container must already have all the layer's requirements:
1941
-
1942
- ```typescript
1943
- // Layer requires Database
1944
- const userRepositoryLayer = autoService(UserRepository, [Database, Logger]);
1945
-
1946
- // ✅ Works - baseContainer has Logger, and we provide Database via layer
1947
- const baseContainer = Container.empty().register(Logger, () => new Logger());
1948
- const container = userRepositoryLayer
1949
- .provide(databaseLayer)
1950
- .register(baseContainer);
1951
-
1952
- // ❌ Type error - baseContainer doesn't have Database
1953
- const baseContainer2 = Container.empty().register(Logger, () => new Logger());
1954
- const container2 = userRepositoryLayer.register(baseContainer2);
1955
- // ❌ Error: Argument of type 'Conainer<ttypeof Logger>' is not assignable to parameter of type 'IContainer<ServiceTag<"Database", Database> | ServiceTag<"Logger", Logger>>'.
1956
- ```
1957
-
1958
- This compile-time checking ensures that all dependencies are satisfied before your code runs, preventing `UnknownDependencyError` at runtime.
1959
-
1960
- ### Best Practices
1961
-
1962
- **Always annotate layer<> type parameters manually:**
1963
-
1964
- ```typescript
1965
- // ✅ Good - explicit types
1966
- const myLayer = layer<typeof Requirement, typeof Provision>((container) =>
1967
- container.register(Provision, async (ctx) => {
1968
- const req = await ctx.resolve(Requirement);
1969
- return new Provision(req);
1970
- })
1971
- );
1972
-
1973
- // ❌ Bad - inference is difficult/impossible
1974
- const myLayer = layer((container) =>
1975
- container.register(Provision, async (ctx) => {
1976
- const req = await ctx.resolve(Requirement);
1977
- return new Provision(req);
1978
- })
1979
- );
1980
- ```
1981
-
1982
- **Follow the types when composing layers:**
1983
-
1984
- Start with the target layer, inspect its type to see requirements, then chain `.provide()` calls:
1985
-
1986
- ```typescript
1987
- // Start with what you need
1988
- const userServiceLayer = service(UserService, ...);
1989
- // Type: Layer<typeof Database | typeof Logger, typeof UserService>
1990
- // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ requirements
1991
-
1992
- // Provide those requirements
1993
- const appLayer = userServiceLayer
1994
- .provide(Layer.mergeAll(databaseLayer, loggerLayer));
1995
- ```
1996
-
1997
- **Define layers in the same file as the service class:**
1998
-
1999
- ```typescript
2000
- // user-repository.ts
2001
- export class UserRepository extends Tag.Service('UserRepository') {
2002
- constructor(private db: Database) {
2003
- super();
2004
- }
2005
-
2006
- async findAll() {
2007
- return this.db.query('SELECT * FROM users');
2008
- }
2009
- }
2010
-
2011
- // Layer definition stays with the class
2012
- export const userRepositoryLayer = autoService(UserRepository, [Database]);
2013
- ```
2014
-
2015
- This keeps related code together while keeping the service class decoupled from DI details.
2016
-
2017
- **Resolve dependencies locally:**
2018
-
2019
- When a module has internal dependencies, provide them within the module's layer to avoid leaking implementation details:
2020
-
2021
- ```typescript
2022
- // user-module/user-validator.ts
2023
- export class UserValidator extends Tag.Service('UserValidator') {
2024
- validate(user: User) {
2025
- // Validation logic
2026
- }
2027
- }
2028
-
2029
- export const userValidatorLayer = autoService(UserValidator, []);
2030
- ```
2031
-
2032
- ```typescript
2033
- // user-module/user-notifier.ts
2034
- export class UserNotifier extends Tag.Service('UserNotifier') {
2035
- notify(user: User) {
2036
- // Notification logic
2037
- }
2038
- }
2039
-
2040
- export const userNotifierLayer = autoService(UserNotifier, []);
2041
- ```
2042
-
2043
- ```typescript
2044
- // user-module/user-service.ts
2045
- import { UserValidator, userValidatorLayer } from './user-validator.js';
2046
- import { UserNotifier, userNotifierLayer } from './user-notifier.js';
2047
-
2048
- // Public service - external consumers only see this
2049
- export class UserService extends Tag.Service('UserService') {
2050
- constructor(
2051
- private validator: UserValidator, // Internal dependency
2052
- private notifier: UserNotifier, // Internal dependency
2053
- private db: Database // External dependency
2054
- ) {
2055
- super();
2056
- }
2057
-
2058
- async createUser(user: User) {
2059
- this.validator.validate(user);
2060
- await this.db.save(user);
2061
- this.notifier.notify(user);
2062
- }
2063
- }
2064
-
2065
- // Public layer - provides internal dependencies inline
2066
- export const userServiceLayer = autoService(UserService, [
2067
- UserValidator,
2068
- UserNotifier,
2069
- Database,
2070
- ]).provide(Layer.mergeAll(userValidatorLayer, userNotifierLayer));
2071
- // Type: Layer<typeof Database, typeof UserService>
2072
-
2073
- // Consumers of this module only need to provide Database
2074
- // UserValidator and UserNotifier are internal details
2075
- ```
2076
-
2077
- ```typescript
2078
- // app.ts
2079
- import { userServiceLayer } from './user-module/user-service.js';
2080
-
2081
- // Only need to provide Database - internal dependencies already resolved
2082
- const appLayer = userServiceLayer.provide(databaseLayer);
2083
- ```
2084
-
2085
- This pattern:
2086
-
2087
- - **Encapsulates internal dependencies**: Consumers don't need to know about `UserValidator` or `UserNotifier`
2088
- - **Reduces coupling**: Changes to internal dependencies don't affect consumers
2089
- - **Simplifies usage**: Consumers only provide what the module actually needs externally
2090
-
2091
- **Use provideMerge when you need access to intermediate services:**
2092
-
2093
- ```typescript
2094
- // Need both config and database in final container
2095
- const infraLayer = databaseLayer.provideMerge(configLayer);
2096
- // Type: Layer<never, typeof ConfigTag | typeof Database>
2097
-
2098
- // vs. provide hides config
2099
- const infraLayer = databaseLayer.provide(configLayer);
2100
- // Type: Layer<never, typeof Database> - ConfigTag not accessible
2101
- ```
2102
-
2103
- **Prefer autoService for simple cases:**
2104
-
2105
- ```typescript
2106
- // ✅ Simple and clear
2107
- const userServiceLayer = autoService(UserService, [Database, Logger]);
2108
-
2109
- // ❌ Verbose for simple case
2110
- const userServiceLayer = service(UserService, async (ctx) => {
2111
- const [db, logger] = await ctx.resolveAll(Database, Logger);
2112
- return new UserService(db, logger);
2113
- });
2114
- ```
2115
-
2116
- But use `service()` when you need custom logic:
2117
-
2118
- ```typescript
2119
- // ✅ Good - custom initialization logic
2120
- const databaseLayer = service(Database, {
2121
- create: async () => {
2122
- const db = new Database();
2123
- await db.connect();
2124
- await db.runMigrations();
2125
- return db;
2126
- },
2127
- cleanup: (db) => db.disconnect(),
2128
- });
2129
- ```
2130
-
2131
- ## Scope Management
2132
-
2133
- Scoped containers enable hierarchical dependency management where some services live for different durations. This is essential for applications that handle multiple contexts (HTTP requests, database transactions, background jobs, etc.).
2134
-
2135
- ### When to Use Scopes
2136
-
2137
- Use scoped containers when you have dependencies with different lifecycles:
2138
-
2139
- **Web servers**: Application-level services (database pool, config) vs. request-level services (request context, user session)
2140
-
2141
- **Serverless functions**: Function-level services (logger, metrics) vs. invocation-level services (event context, request ID)
2142
-
2143
- **Background jobs**: Worker-level services (job queue, database) vs. job-level services (job context, transaction)
2144
-
2145
- ### Creating Scoped Containers
2146
-
2147
- Use `ScopedContainer.empty()` to create a root scope:
2148
-
2149
- ```typescript
2150
- import { ScopedContainer, Tag } from 'sandly';
2151
-
2152
- class Database extends Tag.Service('Database') {
2153
- query(sql: string) {
2154
- return [];
2155
- }
2156
- }
2157
-
2158
- // Create root scope with application-level services
2159
- const appContainer = ScopedContainer.empty('app').register(
2160
- Database,
2161
- () => new Database()
2162
- );
2163
- ```
2164
-
2165
- The scope identifier (`'app'`) is used for debugging and has no runtime behavior.
2166
-
2167
- ### Child Scopes
2168
-
2169
- Create child scopes using `.child()`:
2170
-
2171
- ```typescript
2172
- class RequestContext extends Tag.Service('RequestContext') {
2173
- constructor(public requestId: string, public userId: string) {
2174
- super();
2175
- }
2176
- }
2177
-
2178
- function handleRequest(requestId: string, userId: string) {
2179
- // Create child scope for each request
2180
- const requestScope = appContainer.child('request')
2181
- // Register request-specific services
2182
- .register(RequestContext, () =>
2183
- new RequestContext(requestId, userId)
2184
- )
2185
- );
2186
-
2187
- // Child can access parent services
2188
- const db = await requestScope.resolve(Database); // From parent
2189
- const ctx = await requestScope.resolve(RequestContext); // From child
2190
-
2191
- // Clean up request scope when done
2192
- await requestScope.destroy();
2193
- }
2194
- ```
2195
-
2196
- ### Scope Resolution Rules
2197
-
2198
- When resolving a dependency, scoped containers follow these rules:
2199
-
2200
- 1. **Check current scope cache**: If already instantiated in this scope, return it
2201
- 2. **Check current scope factory**: If registered in this scope, create and cache it here
2202
- 3. **Delegate to parent**: If not in current scope, try parent scope
2203
- 4. **Throw error**: If not found in any scope, throw `UnknownDependencyError`
2204
-
2205
- ```typescript
2206
- const appScope = ScopedContainer.empty('app').register(
2207
- Database,
2208
- () => new Database()
2209
- );
2210
-
2211
- const requestScope = appScope
2212
- .child('request')
2213
- .register(RequestContext, () => new RequestContext());
2214
-
2215
- // Resolving Database from requestScope:
2216
- // 1. Not in requestScope cache
2217
- // 2. Not in requestScope factory
2218
- // 3. Delegate to appScope -> found and cached in appScope
2219
- await requestScope.resolve(Database); // Returns Database from appScope
2220
-
2221
- // Resolving RequestContext from requestScope:
2222
- // 1. Not in requestScope cache
2223
- // 2. Found in requestScope factory -> create and cache in requestScope
2224
- await requestScope.resolve(RequestContext); // Returns RequestContext from requestScope
2225
- ```
2226
-
2227
- ### Complete Web Server Example
2228
-
2229
- Here's a realistic Express.js application with scoped containers:
2230
-
2231
- ```typescript
2232
- import express from 'express';
2233
- import { ScopedContainer, Tag, autoService } from 'sandly';
2234
-
2235
- // ============ Application-Level Services ============
2236
- class Database extends Tag.Service('Database') {
2237
- async query(sql: string) {
2238
- // Real database query
2239
- return [];
2240
- }
2241
- }
2242
-
2243
- class Logger extends Tag.Service('Logger') {
2244
- log(message: string) {
2245
- console.log(`[${new Date().toISOString()}] ${message}`);
2246
- }
2247
- }
2248
-
2249
- // ============ Request-Level Services ============
2250
- class RequestContext extends Tag.Service('RequestContext') {
2251
- constructor(
2252
- public requestId: string,
2253
- public userId: string | null,
2254
- public startTime: number
2255
- ) {
2256
- super();
2257
- }
2258
-
2259
- getDuration() {
2260
- return Date.now() - this.startTime;
2261
- }
2262
- }
2263
-
2264
- class UserSession extends Tag.Service('UserSession') {
2265
- constructor(
2266
- private ctx: RequestContext,
2267
- private db: Database,
2268
- private logger: Logger
2269
- ) {
2270
- super();
2271
- }
2272
-
2273
- async getCurrentUser() {
2274
- if (!this.ctx.userId) {
2275
- return null;
2276
- }
2277
-
2278
- this.logger.log(`Fetching user ${this.ctx.userId}`);
2279
- const users = await this.db.query(
2280
- `SELECT * FROM users WHERE id = '${this.ctx.userId}'`
2281
- );
2282
- return users[0] || null;
2283
- }
2284
- }
2285
-
2286
- // ============ Setup Application Container ============
2287
- const appContainer = ScopedContainer.empty('app')
2288
- .register(Database, () => new Database())
2289
- .register(Logger, () => new Logger());
2290
-
2291
- // ============ Express Middleware ============
2292
- const app = express();
2293
-
2294
- // Store request scope in res.locals
2295
- app.use((req, res, next) => {
2296
- const requestId = crypto.randomUUID();
2297
- const userId = req.headers['user-id'] as string | undefined;
2298
-
2299
- // Create child scope for this request
2300
- const requestScope = appContainer.child(`request-${requestId}`);
2301
-
2302
- // Register request-specific services
2303
- requestScope
2304
- .register(
2305
- RequestContext,
2306
- () => new RequestContext(requestId, userId || null, Date.now())
2307
- )
2308
- .register(
2309
- UserSession,
2310
- async (ctx) =>
2311
- new UserSession(
2312
- await ctx.resolve(RequestContext),
2313
- await ctx.resolve(Database),
2314
- await ctx.resolve(Logger)
2315
- )
2316
- );
2317
-
2318
- // Store scope for use in route handlers
2319
- res.locals.scope = requestScope;
2320
-
2321
- // Clean up scope when response finishes
2322
- res.on('finish', async () => {
2323
- await requestScope.destroy();
2324
- });
2325
-
2326
- next();
2327
- });
2328
-
2329
- // ============ Route Handlers ============
2330
- app.get('/api/user', async (req, res) => {
2331
- const scope: ScopedContainer<typeof UserSession> = res.locals.scope;
2332
-
2333
- const session = await scope.resolve(UserSession);
2334
- const user = await session.getCurrentUser();
2335
-
2336
- if (!user) {
2337
- res.status(401).json({ error: 'Unauthorized' });
2338
- return;
2339
- }
2340
-
2341
- res.json({ user });
2342
- });
2343
-
2344
- app.get('/api/stats', async (req, res) => {
2345
- const scope: ScopedContainer<typeof RequestContext | typeof Database> =
2346
- res.locals.scope;
2347
-
2348
- const ctx = await scope.resolve(RequestContext);
2349
- const db = await scope.resolve(Database);
2350
-
2351
- const stats = await db.query('SELECT COUNT(*) FROM users');
2352
-
2353
- res.json({
2354
- stats,
2355
- requestId: ctx.requestId,
2356
- duration: ctx.getDuration(),
2357
- });
2358
- });
2359
-
2360
- // ============ Start Server ============
2361
- const PORT = 3000;
2362
- app.listen(PORT, () => {
2363
- console.log(`Server running on port ${PORT}`);
2364
- });
2365
-
2366
- // ============ Graceful Shutdown ============
2367
- process.on('SIGTERM', async () => {
2368
- console.log('Shutting down...');
2369
- await appContainer.destroy();
2370
- process.exit(0);
2371
- });
2372
- ```
2373
-
2374
- ### Serverless Function Example
2375
-
2376
- Scoped containers work perfectly for serverless functions where each invocation should have isolated state:
2377
-
2378
- ```typescript
2379
- import { ScopedContainer, Tag } from 'sandly';
2380
- import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
2381
-
2382
- // ============ Function-Level Services (shared across invocations) ============
2383
- class Logger extends Tag.Service('Logger') {
2384
- log(level: string, message: string) {
2385
- console.log(JSON.stringify({ level, message, timestamp: Date.now() }));
2386
- }
2387
- }
2388
-
2389
- class DynamoDB extends Tag.Service('DynamoDB') {
2390
- async get(table: string, key: string) {
2391
- // AWS SDK call
2392
- return {};
2393
- }
2394
- }
2395
-
2396
- // ============ Invocation-Level Services (per Lambda invocation) ============
2397
- const EventContextTag = Tag.of('EventContext')<APIGatewayProxyEvent>();
2398
- const InvocationIdTag = Tag.of('InvocationId')<string>();
2399
-
2400
- class RequestProcessor extends Tag.Service('RequestProcessor') {
2401
- constructor(
2402
- private event: Inject<typeof EventContextTag>,
2403
- private invocationId: Inject<typeof InvocationIdTag>,
2404
- private db: DynamoDB,
2405
- private logger: Logger
2406
- ) {
2407
- super();
2408
- }
2409
-
2410
- async process() {
2411
- this.logger.log('info', `Processing ${this.invocationId}`);
2412
-
2413
- const userId = this.event.pathParameters?.userId;
2414
- if (!userId) {
2415
- return { statusCode: 400, body: 'Missing userId' };
2416
- }
2417
-
2418
- const user = await this.db.get('users', userId);
2419
- return { statusCode: 200, body: JSON.stringify(user) };
2420
- }
2421
- }
2422
-
2423
- // ============ Initialize Function-Level Container (cold start) ============
2424
- const functionContainer = ScopedContainer.empty('function')
2425
- .register(Logger, () => new Logger())
2426
- .register(DynamoDB, () => new DynamoDB());
2427
-
2428
- // ============ Lambda Handler ============
2429
- export async function handler(
2430
- event: APIGatewayProxyEvent
2431
- ): Promise<APIGatewayProxyResult> {
2432
- const invocationId = crypto.randomUUID();
2433
-
2434
- // Create invocation scope
2435
- const invocationScope = functionContainer.child(
2436
- `invocation-${invocationId}`
2437
- );
2438
-
2439
- try {
2440
- // Register invocation-specific context
2441
- invocationScope
2442
- .register(EventContextTag, () => event)
2443
- .register(InvocationIdTag, () => invocationId)
2444
- .register(
2445
- RequestProcessor,
2446
- async (ctx) =>
2447
- new RequestProcessor(
2448
- await ctx.resolve(EventContextTag),
2449
- await ctx.resolve(InvocationIdTag),
2450
- await ctx.resolve(DynamoDB),
2451
- await ctx.resolve(Logger)
2452
- )
2453
- );
2454
-
2455
- // Process request
2456
- const processor = await invocationScope.resolve(RequestProcessor);
2457
- const result = await processor.process();
2458
-
2459
- return result;
2460
- } finally {
2461
- // Clean up invocation scope
2462
- await invocationScope.destroy();
2463
- }
2464
- }
2465
- ```
2466
-
2467
- ### Scope Destruction Order
2468
-
2469
- When a scope is destroyed, finalizers run in this order:
2470
-
2471
- 1. **Child scopes first**: All child scopes are destroyed before the parent
2472
- 2. **Concurrent finalizers**: Within a scope, finalizers run concurrently
2473
- 3. **Parent scope last**: Parent finalizers run after all children are cleaned up
2474
-
2475
- ```typescript
2476
- const appScope = ScopedContainer.empty('app').register(Database, {
2477
- create: () => new Database(),
2478
- cleanup: (db) => {
2479
- console.log('Closing database');
2480
- return db.close();
2481
- },
2482
- });
2483
-
2484
- const request1 = appScope.child('request-1').register(RequestContext, {
2485
- create: () => new RequestContext('req-1'),
2486
- cleanup: (ctx) => {
2487
- console.log('Cleaning up request-1');
2488
- },
2489
- });
2490
-
2491
- const request2 = appScope.child('request-2').register(RequestContext, {
2492
- create: () => new RequestContext('req-2'),
2493
- cleanup: (ctx) => {
2494
- console.log('Cleaning up request-2');
2495
- },
2496
- });
2497
-
2498
- // Destroy parent scope
2499
- await appScope.destroy();
2500
- // Output:
2501
- // Cleaning up request-1
2502
- // Cleaning up request-2
2503
- // Closing database
2504
- ```
2505
-
2506
- ### Scope Lifecycle Best Practices
2507
-
2508
- **Always destroy child scopes**: Failing to destroy child scopes causes memory leaks:
2509
-
2510
- ```typescript
2511
- // ❌ Bad - memory leak
2512
- app.use((req, res, next) => {
2513
- const requestScope = appContainer.child('request');
2514
- res.locals.scope = requestScope;
2515
- next();
2516
- // Scope never destroyed!
2517
- });
2518
-
2519
- // ✅ Good - proper cleanup
2520
- app.use((req, res, next) => {
2521
- const requestScope = appContainer.child('request');
2522
- res.locals.scope = requestScope;
2523
-
2524
- res.on('finish', async () => {
2525
- await requestScope.destroy();
2526
- });
2527
-
2528
- next();
2529
- });
2530
- ```
2531
-
2532
- **Use try-finally for cleanup**: Ensure scopes are destroyed even if errors occur:
2533
-
2534
- ```typescript
2535
- // ✅ Good - cleanup guaranteed
2536
- async function processRequest() {
2537
- const requestScope = appContainer.child('request');
2538
-
2539
- try {
2540
- // Process request
2541
- const result = await requestScope.resolve(RequestProcessor);
2542
- return await result.process();
2543
- } finally {
2544
- // Always cleanup, even on error
2545
- await requestScope.destroy();
2546
- }
2547
- }
2548
- ```
2549
-
2550
- **Don't share scopes across async boundaries**: Each context should have its own scope:
2551
-
2552
- ```typescript
2553
- // ❌ Bad - scope shared across requests
2554
- const sharedScope = appContainer.child('shared');
2555
-
2556
- app.get('/api/user', async (req, res) => {
2557
- const service = await sharedScope.resolve(UserService);
2558
- // Multiple requests share the same scope - potential data leaks!
2559
- });
2560
-
2561
- // ✅ Good - scope per request
2562
- app.get('/api/user', async (req, res) => {
2563
- const requestScope = appContainer.child('request');
2564
- const service = await requestScope.resolve(UserService);
2565
- // Each request gets isolated scope
2566
- await requestScope.destroy();
2567
- });
2568
- ```
2569
-
2570
- **Register request-scoped services in parent scope when possible**: If services don't need request-specific data, register them once:
2571
-
2572
- ```typescript
2573
- // ❌ Suboptimal - registering service definition per request
2574
- app.use((req, res, next) => {
2575
- const requestScope = appContainer.child('request');
2576
-
2577
- // UserService factory defined repeatedly
2578
- requestScope.register(
2579
- UserService,
2580
- async (ctx) => new UserService(await ctx.resolve(Database))
2581
- );
2582
-
2583
- next();
2584
- });
2585
-
2586
- // ✅ Better - register service definition once, instantiate per request
2587
- const appContainer = ScopedContainer.empty('app')
2588
- .register(Database, () => new Database())
2589
- .register(
2590
- UserService,
2591
- async (ctx) => new UserService(await ctx.resolve(Database))
2592
- );
2593
-
2594
- app.use((req, res, next) => {
2595
- const requestScope = appContainer.child('request');
2596
- // UserService factory already registered in parent
2597
- // First resolve in requestScope will create instance
2598
- next();
2599
- });
2600
- ```
2601
-
2602
- **Use weak references for child tracking**: ScopedContainer uses WeakRef internally for child scope tracking, so destroyed child scopes can be garbage collected even if parent scope is still alive.
2603
-
2604
- ### Combining Scopes with Layers
2605
-
2606
- You can apply layers to scoped containers just like regular containers:
2607
-
2608
- ```typescript
2609
- import { ScopedContainer, Layer, autoService } from 'sandly';
2610
-
2611
- // Define layers
2612
- const databaseLayer = autoService(Database, []);
2613
- const loggerLayer = autoService(Logger, []);
2614
- const infraLayer = Layer.mergeAll(databaseLayer, loggerLayer);
2615
-
2616
- // Apply layers to scoped container
2617
- const appContainer = infraLayer.register(ScopedContainer.empty('app'));
2618
-
2619
- // Create child scopes as needed
2620
- const requestScope = appContainer.child('request');
2621
- ```
2622
-
2623
- This combines the benefits of:
2624
-
2625
- - **Layers**: Composable, reusable dependency definitions
2626
- - **Scopes**: Hierarchical lifetime management
405
+ | Method | Description |
406
+ |--------|-------------|
407
+ | `Container.from(layer)` | Create container from a fully resolved layer |
408
+ | `Container.builder()` | Create a container builder |
409
+ | `Container.empty()` | Create an empty container |
410
+ | `Container.scoped(scope)` | Create an empty scoped container |
411
+ | `container.resolve(tag)` | Get a dependency instance |
412
+ | `container.resolveAll(...tags)` | Get multiple dependencies |
413
+ | `container.use(tag, fn)` | Resolve, run callback, then destroy container |
414
+ | `container.destroy()` | Run finalizers and clean up |
415
+
416
+ ### ContainerBuilder
417
+
418
+ | Method | Description |
419
+ |--------|-------------|
420
+ | `builder.add(tag, spec)` | Register a dependency |
421
+ | `builder.build()` | Create the container |
422
+
423
+ ### Layer
424
+
425
+ | Method | Description |
426
+ |--------|-------------|
427
+ | `Layer.service(class, deps, options?)` | Create layer for a class |
428
+ | `Layer.value(tag, value)` | Create layer for a constant value |
429
+ | `Layer.create({ requires, apply })` | Create custom layer |
430
+ | `Layer.empty()` | Create empty layer |
431
+ | `Layer.merge(a, b)` | Merge two layers |
432
+ | `Layer.mergeAll(...layers)` | Merge multiple layers |
433
+ | `layer.provide(dep)` | Satisfy dependencies |
434
+ | `layer.provideMerge(dep)` | Satisfy and merge provisions |
435
+ | `layer.merge(other)` | Merge with another layer |
436
+
437
+ ### ScopedContainer
438
+
439
+ | Method | Description |
440
+ |--------|-------------|
441
+ | `ScopedContainer.builder(scope)` | Create a new scoped container builder |
442
+ | `ScopedContainer.empty(scope)` | Create empty scoped container |
443
+ | `ScopedContainer.from(scope, layer)` | Create from layer |
444
+ | `container.child(scope)` | Create child scope builder |
445
+ | `container.childFrom(scope, layer)` | Create child scope from layer (convenience) |
446
+
447
+ ### Tag
448
+
449
+ | Method | Description |
450
+ |--------|-------------|
451
+ | `Tag.of(id)<T>()` | Create a ValueTag |
452
+ | `Tag.id(tag)` | Get tag's string identifier |
453
+ | `Tag.isTag(value)` | Check if value is a tag |
2627
454
 
2628
455
  ## Comparison with Alternatives
2629
456
 
2630
- ### vs NestJS
2631
-
2632
- **NestJS**:
2633
-
2634
- - **No Type Safety**: Relies on string tokens and runtime reflection. TypeScript can't validate your dependency graph at compile time. This results in common runtime errors like "Unknown dependency" or "Dependency not found" when NestJS app is run.
2635
- - **Decorator-Based**: Uses experimental decorators which are being deprecated in favor of the new TypeScript standard.
2636
- - **Framework Lock-In**: Tightly coupled to the NestJS framework. You can't use the DI system independently.
2637
- - **Heavy**: Pulls in many dependencies and runtime overhead.
2638
-
2639
- **Sandly**:
2640
-
2641
- - **Full Type Safety**: Compile-time validation of your entire dependency graph.
2642
- - **No Decorators**: Uses standard TypeScript without experimental features.
2643
- - **Framework-Agnostic**: Works with any TypeScript project (Express, Fastify, plain Node.js, serverless, etc.).
2644
- - **Lightweight**: Zero runtime dependencies, minimal overhead.
2645
-
2646
- ### vs InversifyJS
2647
-
2648
- **InversifyJS**:
2649
-
2650
- - **Complex API**: Requires learning container binding DSL, identifiers, and numerous decorators.
2651
- - **Decorator-heavy**: Relies heavily on experimental decorators.
2652
- - **No async factory support**: Doesn't support async dependency creation out of the box.
2653
- - **Weak type inference**: Type safety requires manual type annotations everywhere.
2654
-
2655
- **Sandly**:
2656
-
2657
- - **Simple API**: Clean, minimal API surface. Tags, containers, and layers.
2658
- - **No decorators**: Standard TypeScript classes and functions.
2659
- - **Async first**: Native support for async factories and finalizers.
2660
- - **Strong type inference**: Types are automatically inferred from your code.
2661
-
2662
- ```typescript
2663
- // InversifyJS - Complex and decorator-heavy
2664
- const TYPES = {
2665
- Database: Symbol.for('Database'),
2666
- UserService: Symbol.for('UserService'),
2667
- };
2668
-
2669
- @injectable()
2670
- class UserService {
2671
- constructor(@inject(TYPES.Database) private db: Database) {}
2672
- }
2673
-
2674
- container.bind<Database>(TYPES.Database).to(Database).inSingletonScope();
2675
- container.bind<UserService>(TYPES.UserService).to(UserService);
2676
-
2677
- // Sandly - Simple and type-safe
2678
- class UserService extends Tag.Service('UserService') {
2679
- constructor(private db: Database) {
2680
- super();
2681
- }
2682
- }
2683
-
2684
- const container = Container.empty()
2685
- .register(Database, () => new Database())
2686
- .register(
2687
- UserService,
2688
- async (ctx) => new UserService(await ctx.resolve(Database))
2689
- );
2690
- ```
2691
-
2692
- ### vs TSyringe
2693
-
2694
- **TSyringe**:
2695
-
2696
- - **Decorator-based**: Uses experimental `reflect-metadata` and decorators.
2697
- - **No type-safe container**: The container doesn't track what's registered. Easy to request unregistered dependencies and only find out at runtime.
2698
- - **No async support**: Factories must be synchronous.
2699
- - **Global container**: Relies on a global container which makes testing harder.
2700
-
2701
- **Sandly**:
2702
-
2703
- - **No decorators**: Standard TypeScript, no experimental features.
2704
- - **Type-Safe container**: Container tracks all registered services. TypeScript prevents requesting unregistered dependencies.
2705
- - **Full async support**: Factories and finalizers can be async.
2706
- - **Explicit containers**: Create and manage containers explicitly for better testability and scope management.
2707
-
2708
- ```typescript
2709
- // TSyringe - Global container, no compile-time safety
2710
- @injectable()
2711
- class UserService {
2712
- constructor(@inject('Database') private db: Database) {}
2713
- }
2714
-
2715
- container.register('Database', { useClass: Database });
2716
- container.register('UserService', { useClass: UserService });
2717
-
2718
- // Will compile but fail at runtime if 'Database' wasn't registered
2719
- const service = container.resolve('UserService');
2720
-
2721
- // Sandly - Type-safe, explicit
2722
- const container = Container.empty()
2723
- .register(Database, () => new Database())
2724
- .register(
2725
- UserService,
2726
- async (ctx) => new UserService(await ctx.resolve(Database))
2727
- );
2728
-
2729
- // Won't compile if Database isn't registered
2730
- const service = await container.resolve(UserService); // Type-safe
2731
- ```
2732
-
2733
- ### vs Effect-TS
2734
-
2735
- **Effect-TS**:
2736
-
2737
- - **Steep learning curve**: Requires learning functional programming concepts, Effect type, generators, and extensive API.
2738
- - **All-or-nothing**: Designed as a complete effect system. Hard to adopt incrementally.
2739
- - **Functional programming**: Uses FP paradigms which may not fit all teams or codebases.
2740
- - **Large bundle**: Comprehensive framework with significant bundle size.
2741
-
2742
- **Sandly**:
2743
-
2744
- - **Easy to learn**: Simple, familiar API. If you know TypeScript classes, you're ready to use Sandly.
2745
- - **Incremental adoption**: Add DI to existing codebases without major refactoring.
2746
- - **Pragmatic**: Works with standard OOP and functional styles.
2747
- - **Minimal size**: Tiny library focused on DI only.
2748
-
2749
- **Similarities with Effect**:
2750
-
2751
- - Both provide full type safety for dependency management
2752
- - Both use the concept of layers for composable dependency graphs
2753
- - Both support complete async lifecycle management and scope management
2754
-
2755
- **When to choose Effect**: If you want a complete effect system with error handling, concurrency, streams, and are comfortable with FP paradigms.
2756
-
2757
- **When to choose Sandly**: If you want just dependency injection with great type safety, without the learning curve or the need to adopt an entire effect system.
2758
-
2759
- ### Feature Comparison Table
2760
-
2761
- | Feature | Sandly | NestJS | InversifyJS | TSyringe | Effect-TS |
2762
- | -------------------------- | ------- | ---------- | --------------------- | -------- | --------- |
2763
- | Compile-time type safety | ✅ Full | ❌ None | ⚠️ Partial | ❌ None | ✅ Full |
2764
- | No experimental decorators | ✅ | ❌ | ❌ | ❌ | ✅ |
2765
- | Async lifecycle methods | ✅ | ✅ | ❌ | ❌ | ✅ |
2766
- | Framework-agnostic | ✅ | ❌ | ✅ | ✅ | ✅ |
2767
- | Learning curve | Low | Medium | Medium | Low | Very High |
2768
- | Bundle size | Small | Large | Medium | Small | Large |
2769
- | Custom scopes | ✅ | ⚠️ Limited | ⚠️ Request scope only | ❌ | ✅ |
2770
- | Layer composition | ✅ | ❌ | ❌ | ❌ | ✅ |
2771
- | Zero dependencies | ✅ | ❌ | ❌ | ❌ | ❌ |
2772
-
2773
- ### Why Choose Sandly?
2774
-
2775
- Choose Sandly if you want:
2776
-
2777
- - **Type safety** without sacrificing developer experience
2778
- - **Dependency injection** without the need for experimental features that won't be supported in the future
2779
- - **Clean architecture** with layers and composable modules
2780
- - **Async support** for real-world scenarios (database connections, API clients, etc.)
2781
- - **Testing-friendly** design with easy mocking and isolation
2782
- - **Incremental adoption** in existing codebases
2783
- - **Zero runtime dependencies** and minimal overhead
2784
-
2785
- Sandly takes inspiration from Effect-TS's excellent type safety and layer composition, while keeping the API simple and accessible for teams that don't need a full effect system.
457
+ | Feature | Sandly | NestJS | InversifyJS | TSyringe |
458
+ |---------|--------|--------|-------------|----------|
459
+ | Compile-time type safety | ✅ | ❌ | ⚠️ Partial | ❌ |
460
+ | No experimental decorators | ✅ | ❌ | ❌ | ❌ |
461
+ | Async factories | | | | |
462
+ | Framework-agnostic | | | | |
463
+ | Layer composition | | | | |
464
+ | Zero dependencies | | | ❌ | ❌ |
465
+
466
+ **Choose Sandly when you want:**
467
+ - Type safety without sacrificing simplicity
468
+ - DI without experimental decorators
469
+ - Composable, reusable dependency modules
470
+ - Easy testing with mock injection
471
+ - Minimal bundle size and zero dependencies
472
+
473
+ ## License
474
+
475
+ MIT