sandly 1.0.1 → 2.0.1

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 +331 -2609
  2. package/dist/index.d.ts +654 -1554
  3. package/dist/index.js +522 -921
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -1,35 +1,50 @@
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
+ Sandly ("Services And Layers") is a type-safe dependency injection library 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';
11
+
12
+ class Database {
13
+ query(sql: string) {
14
+ return [];
15
+ }
16
+ }
13
17
 
14
- class UserService extends Tag.Service('UserService') {
18
+ class UserService {
19
+ constructor(private db: Database) {}
15
20
  getUsers() {
16
- return ['alice', 'bob'];
21
+ return this.db.query('SELECT * FROM users');
17
22
  }
18
23
  }
19
24
 
20
- const container = Container.empty().register(
21
- UserService,
22
- () => new UserService()
23
- );
25
+ // Define layers
26
+ const dbLayer = Layer.service(Database, []);
27
+ const userLayer = Layer.service(UserService, [Database]);
24
28
 
25
- // TypeScript knows UserService is registered
29
+ // Compose and create container
30
+ const container = Container.from(userLayer.provide(dbLayer));
31
+
32
+ // TypeScript knows UserService is available
26
33
  const users = await container.resolve(UserService);
27
34
 
28
- // TypeScript error - OrderService not registered
35
+ // TypeScript error - OrderService not registered
29
36
  const orders = await container.resolve(OrderService);
30
- // Error: Argument of type 'typeof OrderService' is not assignable to parameter of type 'typeof UserService'
31
37
  ```
32
38
 
39
+ **Key features:**
40
+
41
+ - **Compile-time safety**: TypeScript catches missing dependencies before runtime
42
+ - **No decorators**: Works with standard TypeScript, no experimental features
43
+ - **Async support**: Factories and cleanup functions can be async
44
+ - **Composable layers**: Organize dependencies into reusable modules
45
+ - **Scoped containers**: Hierarchical dependency management for web servers
46
+ - **Zero dependencies**: Tiny library with no runtime overhead
47
+
33
48
  ## Installation
34
49
 
35
50
  ```bash
@@ -40,2746 +55,453 @@ pnpm add sandly
40
55
  yarn add sandly
41
56
  ```
42
57
 
43
- Recommended version of TypeScript is 5.0+.
58
+ Requires TypeScript 5.0+.
44
59
 
45
60
  ## Quick Start
46
61
 
47
- Here's a complete example showing dependency injection with automatic cleanup:
48
-
49
62
  ```typescript
50
- import { Container, Tag } from 'sandly';
63
+ import { Container, Layer, Tag } from 'sandly';
51
64
 
52
- // Define services using Tag.Service
53
- class Database extends Tag.Service('Database') {
65
+ // Any class can be a dependency - no special base class needed
66
+ class Database {
54
67
  async query(sql: string) {
55
- console.log(`Executing: ${sql}`);
56
68
  return [{ id: 1, name: 'Alice' }];
57
69
  }
58
-
59
70
  async close() {
60
- console.log('Database connection closed');
71
+ console.log('Database closed');
61
72
  }
62
73
  }
63
74
 
64
- class UserRepository extends Tag.Service('UserRepository') {
65
- constructor(private db: Database) {
66
- super();
67
- }
68
-
69
- async findAll() {
75
+ class UserRepository {
76
+ constructor(private db: Database) {}
77
+ findAll() {
70
78
  return this.db.query('SELECT * FROM users');
71
79
  }
72
80
  }
73
81
 
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';
82
+ // Create layers
83
+ const dbLayer = Layer.service(Database, [], {
84
+ cleanup: (db) => db.close(),
85
+ });
105
86
 
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
- );
87
+ const userRepoLayer = Layer.service(UserRepository, [Database]);
113
88
 
114
- // Layer that provides UserRepository (depends on Database)
115
- const userRepositoryLayer = autoService(UserRepository, [Database]);
89
+ // Compose layers and create container
90
+ const appLayer = userRepoLayer.provide(dbLayer);
91
+ const container = Container.from(appLayer);
116
92
 
117
- // Compose layers - userRepositoryLayer.provide(databaseLayer) creates
118
- // a complete layer with all dependencies satisfied
119
- const appLayer = userRepositoryLayer.provide(databaseLayer);
93
+ // Use services
94
+ const repo = await container.resolve(UserRepository);
95
+ const users = await repo.findAll();
120
96
 
121
- // Apply to container
122
- const container = appLayer.register(Container.empty());
123
- const userRepo = await container.resolve(UserRepository);
97
+ // Clean up
98
+ await container.destroy();
124
99
  ```
125
100
 
126
- Continue reading to learn about all features including value tags, layer composition, and scope management.
101
+ ## Core Concepts
127
102
 
128
- ## Main Features
103
+ ### Tags
129
104
 
130
- ### Type Safety
105
+ Tags identify dependencies. There are two types:
131
106
 
132
- The container tracks registered dependencies in its generic type parameters, making it impossible to resolve unregistered dependencies.
107
+ **Classes as tags**: Any class constructor can be used directly as a tag:
133
108
 
134
109
  ```typescript
135
- import { Container, Tag } from 'sandly';
136
-
137
- class CacheService extends Tag.Service('CacheService') {
138
- get(key: string) {
139
- return null;
110
+ class UserService {
111
+ getUsers() {
112
+ return [];
140
113
  }
141
114
  }
142
115
 
143
- class EmailService extends Tag.Service('EmailService') {
144
- send(to: string) {}
145
- }
146
-
147
- // Container knows exactly what's registered
148
- const container = Container.empty().register(
149
- CacheService,
150
- () => new CacheService()
151
- );
152
- // Type: Container<typeof CacheService>
153
-
154
- // ✅ Works - CacheService is registered
155
- const cache = await container.resolve(CacheService);
156
-
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
- ```
162
-
163
- Type information is preserved through method chaining:
164
-
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);
116
+ // UserService is both the class and its tag
117
+ const layer = Layer.service(UserService, []);
174
118
  ```
175
119
 
176
- Dependencies are tracked in factory functions too:
120
+ **ValueTags for non-class values**: Use `Tag.of()` for primitives, objects, or functions:
177
121
 
178
122
  ```typescript
179
- class UserService extends Tag.Service('UserService') {
180
- constructor(
181
- private cache: CacheService,
182
- private email: EmailService
183
- ) {
184
- super();
185
- }
186
- }
123
+ const PortTag = Tag.of('Port')<number>();
124
+ const ConfigTag = Tag.of('Config')<{ apiUrl: string }>();
187
125
 
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
- });
126
+ const portLayer = Layer.value(PortTag, 3000);
127
+ const configLayer = Layer.value(ConfigTag, {
128
+ apiUrl: 'https://api.example.com',
129
+ });
200
130
  ```
201
131
 
202
- ### Modular Architecture with Layers
132
+ ### Container
203
133
 
204
- For large applications, organizing dependencies into layers helps manage complexity and makes dependencies composable.
134
+ Containers manage dependency instantiation and lifecycle:
205
135
 
206
136
  ```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
- );
137
+ // Create from layers (recommended)
138
+ const container = Container.from(appLayer);
232
139
 
233
- // Service layer - depends on database
234
- class UserService extends Tag.Service('UserService') {
235
- constructor(private db: Database) {
236
- super();
237
- }
140
+ // Or build manually
141
+ const container = Container.builder()
142
+ .add(Database, () => new Database())
143
+ .add(
144
+ UserService,
145
+ async (ctx) => new UserService(await ctx.resolve(Database))
146
+ )
147
+ .build();
238
148
 
239
- async getUsers() {
240
- return this.db.query('SELECT * FROM users');
241
- }
242
- }
149
+ // Resolve dependencies
150
+ const db = await container.resolve(Database);
151
+ const [db, users] = await container.resolveAll(Database, UserService);
243
152
 
244
- const userServiceLayer = service(
245
- UserService,
246
- async (ctx) => new UserService(await ctx.resolve(Database))
153
+ // Use and discard pattern - resolves, runs callback, then destroys
154
+ const result = await container.use(UserService, (service) =>
155
+ service.getUsers()
247
156
  );
248
157
 
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);
158
+ // Manual clean up
159
+ await container.destroy();
259
160
  ```
260
161
 
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.
162
+ Each dependency is created once (singleton) and cached.
262
163
 
263
- ### Flexible Dependency Values
264
-
265
- Any value can be a dependency, not just class instances:
266
-
267
- ```typescript
268
- import { Tag, constant, Container } from 'sandly';
269
-
270
- // Primitive values
271
- 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
164
+ ### Layers
301
165
 
302
- Both service creation and cleanup can be asynchronous:
166
+ Layers are composable units of dependency registrations:
303
167
 
304
168
  ```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
- }
169
+ // Layer.service for classes with dependencies
170
+ const userLayer = Layer.service(UserService, [Database, Logger]);
325
171
 
326
- query(sql: string) {
327
- if (!this.connection) throw new Error('Not connected');
328
- return [];
329
- }
330
- }
172
+ // Layer.value for constants
173
+ const configLayer = Layer.value(ConfigTag, { port: 3000 });
331
174
 
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
- },
175
+ // Layer.create for custom factory logic
176
+ const cacheLayer = Layer.create({
177
+ requires: [ConfigTag],
178
+ apply: (builder) =>
179
+ builder.add(Cache, async (ctx) => {
180
+ const config = await ctx.resolve(ConfigTag);
181
+ return new Cache({ ttl: config.cacheTtl });
182
+ }),
341
183
  });
342
-
343
- // Use the service
344
- const db = await container.resolve(DatabaseConnection);
345
- await db.query('SELECT * FROM users');
346
-
347
- // Clean shutdown
348
- await container.destroy();
349
- // Output:
350
- // Disconnecting from database...
351
- // Disconnected!
352
184
  ```
353
185
 
354
- ### Powerful Scope Management
355
-
356
- Scoped containers enable hierarchical dependency management - perfect for web servers where some services live for the application lifetime while others are request-specific:
186
+ Compose layers with `provide()`, `provideMerge()`, and `merge()`:
357
187
 
358
188
  ```typescript
359
- import { ScopedContainer, Tag } from 'sandly';
360
-
361
- // Application-level (singleton)
362
- class Database extends Tag.Service('Database') {
363
- query(sql: string) {
364
- return [];
365
- }
366
- }
367
-
368
- // Request-level
369
- class RequestContext extends Tag.Service('RequestContext') {
370
- constructor(public requestId: string) {
371
- super();
372
- }
373
- }
374
-
375
- // Set up application container with shared services
376
- const rootContainer = ScopedContainer.empty('app').register(
377
- Database,
378
- () => new Database()
379
- );
380
-
381
- // For each HTTP request, create a child scope
382
- async function handleRequest(requestId: string) {
383
- const requestContainer = rootContainer.child('request');
189
+ // provide: satisfy dependencies, expose only this layer's provisions
190
+ const appLayer = userLayer.provide(dbLayer);
384
191
 
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
- }
192
+ // merge: combine independent layers
193
+ const infraLayer = Layer.merge(dbLayer, loggerLayer);
194
+ // or
195
+ const infraLayer = Layer.mergeAll(dbLayer, loggerLayer, cacheLayer);
396
196
 
397
- // Each request gets isolated scope, but shares Database
398
- await handleRequest('req-1');
399
- await handleRequest('req-2');
197
+ // provideMerge: satisfy dependencies and expose both layers
198
+ const fullLayer = userLayer.provideMerge(dbLayer);
400
199
  ```
401
200
 
402
- ### Performance & Developer Experience
201
+ ### Scoped Containers
403
202
 
404
- **Zero runtime overhead for resolution**: Dependency resolution uses a simple `Map` lookup. Services are instantiated once and cached.
203
+ Scoped containers enable hierarchical dependency management:
405
204
 
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.
205
+ ```typescript
206
+ // Application scope - use builder to add dependencies
207
+ const appContainer = ScopedContainer.builder('app')
208
+ .add(Database, () => new Database())
209
+ .build();
409
210
 
410
- **IntelliSense works perfectly**: Because dependencies are tracked at the type level, your IDE knows exactly what's available:
211
+ // Request scope - use child() to create a child builder
212
+ const requestContainer = appContainer
213
+ .child('request')
214
+ .add(RequestContext, () => new RequestContext())
215
+ .build();
411
216
 
412
- ```typescript
413
- const container = Container.empty()
414
- .register(Database, () => new Database())
415
- .register(Cache, () => new Cache());
217
+ // Child can resolve both its own and parent dependencies
218
+ const db = await requestContainer.resolve(Database); // From parent
219
+ const ctx = await requestContainer.resolve(RequestContext); // From child
416
220
 
417
- // IDE autocomplete shows: Database | Cache
418
- await container.resolve(/* IDE suggests Database and Cache */);
221
+ // Destroy child without affecting parent
222
+ await requestContainer.destroy();
419
223
  ```
420
224
 
421
- **Lazy instantiation**: Services are only created when first resolved:
225
+ Or use layers with `childFrom`:
422
226
 
423
227
  ```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
228
+ const appContainer = ScopedContainer.from('app', dbLayer);
229
+ const requestContainer = appContainer.childFrom(
230
+ 'request',
231
+ Layer.value(RequestContext, new RequestContext())
232
+ );
438
233
  ```
439
234
 
440
- ### Easy Testing
235
+ ### Use and Discard Pattern
441
236
 
442
- Create test containers with real or mocked services:
237
+ The `use()` method resolves a service, runs a callback, and automatically destroys the container:
443
238
 
444
239
  ```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!');
240
+ // Perfect for short-lived operations like Lambda handlers or worker jobs
241
+ const result = await appContainer
242
+ .childFrom('request', requestLayer)
243
+ .use(UserService, (service) => service.processEvent(event));
244
+ // Container is automatically destroyed after callback completes
480
245
  ```
481
246
 
482
- ## Core Concepts
483
-
484
- Before diving into detailed usage, let's understand the four main building blocks of Sandly.
247
+ This is especially useful for serverless functions or message handlers where the container lifecycle matches a single operation.
485
248
 
486
- ### Tags
249
+ ## Working with Layers
487
250
 
488
- Tags are unique tokens that represent dependencies and serve as a way to reference them in the container. They come in two flavors:
251
+ ### Creating Layers
489
252
 
490
- **ServiceTag** - For class-based dependencies. Created by extending `Tag.Service()`:
253
+ **Layer.service**: Class dependencies with automatic injection
491
254
 
492
255
  ```typescript
493
- class UserRepository extends Tag.Service('UserRepository') {
494
- findUser(id: string) {
495
- return { id, name: 'Alice' };
496
- }
256
+ class ApiClient {
257
+ constructor(
258
+ private config: Config,
259
+ private logger: Logger
260
+ ) {}
497
261
  }
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.
502
262
 
503
- **ValueTag** - For non-class dependencies (primitives, objects, functions). Created with `Tag.of()`:
263
+ // Dependencies must match constructor parameters in order
264
+ const apiLayer = Layer.service(ApiClient, [Config, Logger]);
504
265
 
505
- ```typescript
506
- const ApiKeyTag = Tag.of('ApiKey')<string>();
507
- const ConfigTag = Tag.of('Config')<{ port: number }>();
266
+ // With cleanup function
267
+ const dbLayer = Layer.service(Database, [], {
268
+ cleanup: (db) => db.close(),
269
+ });
508
270
  ```
509
271
 
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'`.
511
-
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
272
+ **Layer.value**: Constant values or pre-instantiated instances
520
273
 
521
274
  ```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
- );
275
+ // ValueTag (constants)
276
+ const ApiKeyTag = Tag.of('apiKey')<string>();
277
+ const configLayer = Layer.value(ApiKeyTag, process.env.API_KEY!);
528
278
 
529
- const repo = await container.resolve(UserRepository);
530
- await container.destroy(); // Clean up
279
+ // ServiceTag (pre-instantiated instances, useful for testing)
280
+ class UserService {
281
+ getUsers() { return []; }
282
+ }
283
+ const mockUserService = new UserService();
284
+ const testLayer = Layer.value(UserService, mockUserService);
531
285
  ```
532
286
 
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:
287
+ **Layer.create**: Custom factory logic
538
288
 
539
289
  ```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
- );
544
-
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
- );
553
-
554
- // Compose layers to build complete dependency graphs
555
- const appLayer = repositoryLayer.provide(databaseLayer);
290
+ const dbLayer = Layer.create({
291
+ requires: [ConfigTag],
292
+ apply: (builder) =>
293
+ builder.add(Database, async (ctx) => {
294
+ const config = await ctx.resolve(ConfigTag);
295
+ const db = new Database(config.dbUrl);
296
+ await db.connect();
297
+ return db;
298
+ }),
299
+ });
556
300
  ```
557
301
 
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.
559
-
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
302
+ ### Composing Layers
570
303
 
571
304
  ```typescript
572
- // Root scope with shared services
573
- const rootContainer = ScopedContainer.empty('app').register(
574
- Database,
575
- () => new Database()
576
- );
577
-
578
- // Child scope for each request
579
- const requestContainer = rootContainer
580
- .child('request')
581
- .register(RequestContext, () => new RequestContext());
305
+ // Build your application layer by layer
306
+ const configLayer = Layer.value(ConfigTag, loadConfig());
307
+ const dbLayer = Layer.service(Database, [ConfigTag]);
308
+ const repoLayer = Layer.service(UserRepository, [Database]);
309
+ const serviceLayer = Layer.service(UserService, [UserRepository, Logger]);
582
310
 
583
- // Child can access parent services
584
- const db = await requestContainer.resolve(Database); // From parent
585
-
586
- // Destroying child doesn't affect parent
587
- await requestContainer.destroy();
588
- ```
589
-
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
311
+ // Compose into complete application
312
+ const appLayer = serviceLayer
313
+ .provide(repoLayer)
314
+ .provide(dbLayer)
315
+ .provide(configLayer)
316
+ .provide(Layer.service(Logger, []));
597
317
 
598
- Start with an empty container:
599
-
600
- ```typescript
601
- import { Container } from 'sandly';
602
-
603
- const container = Container.empty();
604
- // Type: Container<never> - no services registered yet
318
+ // Create container - all dependencies satisfied
319
+ const container = Container.from(appLayer);
605
320
  ```
606
321
 
607
- ### Registering Dependencies
608
-
609
- #### Service Tags (Classes)
322
+ ### Layer Type Safety
610
323
 
611
- Register a class by providing a factory function:
324
+ Layers track their requirements and provisions at the type level:
612
325
 
613
326
  ```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>
624
- ```
625
-
626
- The factory receives a resolution context for injecting dependencies:
327
+ const dbLayer = Layer.service(Database, []);
328
+ // Type: Layer<never, typeof Database>
627
329
 
628
- ```typescript
629
- class Database extends Tag.Service('Database') {
630
- query(sql: string) {
631
- return [];
632
- }
633
- }
330
+ const userLayer = Layer.service(UserService, [Database]);
331
+ // Type: Layer<typeof Database, typeof UserService>
634
332
 
635
- class UserRepository extends Tag.Service('UserRepository') {
636
- constructor(
637
- private db: Database,
638
- private logger: Logger
639
- ) {
640
- super();
641
- }
333
+ const appLayer = userLayer.provide(dbLayer);
334
+ // Type: Layer<never, typeof UserService>
642
335
 
643
- async findAll() {
644
- this.logger.log('Finding all users');
645
- return this.db.query('SELECT * FROM users');
646
- }
647
- }
336
+ // Container.from only accepts layers with no requirements
337
+ const container = Container.from(appLayer); // OK
648
338
 
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
- });
339
+ const incomplete = Layer.service(UserService, [Database]);
340
+ const container = Container.from(incomplete); // Type error!
657
341
  ```
658
342
 
659
- #### Value Tags (Non-Classes)
343
+ ## Scoped Containers
660
344
 
661
- Register values using `Tag.of()`:
345
+ ### Request Scoping for Web Servers
662
346
 
663
347
  ```typescript
664
- const PortTag = Tag.of('server.port')<number>();
665
- const DatabaseUrlTag = Tag.of('database.url')<string>();
348
+ import { ScopedContainer, Layer } from 'sandly';
666
349
 
667
- interface AppConfig {
668
- apiKey: string;
669
- timeout: number;
670
- }
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
- ```
350
+ // App-level dependencies (shared across requests)
351
+ const appContainer = ScopedContainer.from(
352
+ 'app',
353
+ Layer.mergeAll(dbLayer, loggerLayer)
354
+ );
681
355
 
682
- ### Resolving Dependencies
356
+ // Express middleware
357
+ app.use(async (req, res, next) => {
358
+ // Create request scope with request-specific dependencies
359
+ const requestScope = appContainer.childFrom(
360
+ 'request',
361
+ Layer.value(RequestContext, {
362
+ requestId: crypto.randomUUID(),
363
+ userId: req.user?.id,
364
+ })
365
+ );
683
366
 
684
- Use `resolve()` to get a service instance:
367
+ res.locals.container = requestScope;
685
368
 
686
- ```typescript
687
- const logger = await container.resolve(Logger);
688
- logger.log('Hello!');
369
+ res.on('finish', () => requestScope.destroy());
370
+ next();
371
+ });
689
372
 
690
- // TypeScript error - UserRepository not registered
691
- const repo = await container.resolve(UserRepository);
692
- // Error: Argument of type 'typeof UserRepository' is not assignable...
373
+ // Route handler
374
+ app.get('/users', async (req, res) => {
375
+ const userService = await res.locals.container.resolve(UserService);
376
+ res.json(await userService.getUsers());
377
+ });
693
378
  ```
694
379
 
695
- Resolve multiple dependencies at once:
380
+ ### Destruction Order
696
381
 
697
- ```typescript
698
- const [db, logger] = await container.resolveAll(Database, Logger);
699
- // Returns tuple with correct types: [Database, Logger]
700
- ```
382
+ When destroying a scoped container:
701
383
 
702
- Services are singletons - always the same instance:
384
+ 1. Child scopes are destroyed first
385
+ 2. Then the current scope's finalizers run
386
+ 3. Parent scope is unaffected
703
387
 
704
388
  ```typescript
705
- const logger1 = await container.resolve(Logger);
706
- const logger2 = await container.resolve(Logger);
389
+ const parent = ScopedContainer.builder('parent')
390
+ .add(Database, {
391
+ create: () => new Database(),
392
+ cleanup: (db) => db.close(),
393
+ })
394
+ .build();
707
395
 
708
- console.log(logger1 === logger2); // true
709
- ```
396
+ const child = parent
397
+ .child('child')
398
+ .add(Cache, { create: () => new Cache(), cleanup: (c) => c.clear() })
399
+ .build();
710
400
 
711
- ### Lifecycle Management
401
+ await parent.destroy(); // Destroys child first (Cache.clear), then parent (Database.close)
402
+ ```
712
403
 
713
- #### Finalizers for Cleanup
404
+ ## Error Handling
714
405
 
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.):
406
+ Sandly provides specific error types for common issues:
716
407
 
717
408
  ```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
- }
409
+ import {
410
+ UnknownDependencyError,
411
+ CircularDependencyError,
412
+ DependencyCreationError,
413
+ DependencyFinalizationError,
414
+ } from 'sandly';
725
415
 
726
- async disconnect() {
727
- this.connected = false;
728
- console.log('Disconnected');
416
+ try {
417
+ const service = await container.resolve(UserService);
418
+ } catch (error) {
419
+ if (error instanceof CircularDependencyError) {
420
+ console.log(error.message);
421
+ // "Circular dependency detected for UserService: UserService -> Database -> UserService"
729
422
  }
730
423
 
731
- query(sql: string) {
732
- if (!this.connected) throw new Error('Not connected');
733
- return [];
424
+ if (error instanceof DependencyCreationError) {
425
+ // Get the original error that caused the failure
426
+ const rootCause = error.getRootCause();
427
+ console.log(rootCause.message);
734
428
  }
735
429
  }
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
- },
748
- });
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
430
  ```
758
431
 
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
- }
432
+ ## API Reference
774
433
 
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
- }
434
+ ### Container
789
435
 
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
- ```
816
-
817
- The `cleanup` method is optional, so you can implement classes with only a `create` method:
818
-
819
- ```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
- ```
838
-
839
- All finalizers run concurrently when you call `destroy()`:
840
-
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();
854
- ```
855
-
856
- #### Overriding Registrations
857
-
858
- You can override a registration before it's instantiated:
859
-
860
- ```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
- ```
868
-
869
- But you cannot override after instantiation:
870
-
871
- ```typescript
872
- const container = Container.empty().register(Logger, () => new Logger());
873
-
874
- const logger = await container.resolve(Logger); // Instantiated
875
-
876
- container.register(Logger, () => new Logger()); // Throws!
877
- // DependencyAlreadyInstantiatedError: Cannot register dependency Logger -
878
- // it has already been instantiated
879
- ```
880
-
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
- ```
891
-
892
- #### exists() - Check if Instantiated
893
-
894
- ```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);
900
-
901
- console.log(container.exists(Logger)); // true - now instantiated
902
- ```
903
-
904
- ### Error Handling
905
-
906
- #### Unknown Dependency
907
-
908
- ```typescript
909
- const container = Container.empty();
910
-
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
- }
917
- ```
918
-
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
922
-
923
- Circular dependencies are detected at runtime:
924
-
925
- ```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`:
953
-
954
- ```typescript
955
- const container = Container.empty().register(Database, () => {
956
- throw new Error('Connection failed');
957
- });
958
-
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
- }
965
- ```
966
-
967
- **Nested Creation Errors**
968
-
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:
970
-
971
- ```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
- );
1001
-
1002
- try {
1003
- await container.resolve(ServiceA);
1004
- } 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
- }
1014
- }
1015
- ```
1016
-
1017
- #### Finalization Errors
1018
-
1019
- If any finalizer fails, cleanup continues for others and a `DependencyFinalizationError` is thrown with details of all failures:
1020
-
1021
- ```typescript
1022
- class Database extends Tag.Service('Database') {
1023
- async close() {
1024
- throw new Error('Database close failed');
1025
- }
1026
- }
1027
-
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
436
+ | Method | Description |
437
+ | ------------------------------- | --------------------------------------------- |
438
+ | `Container.from(layer)` | Create container from a fully resolved layer |
439
+ | `Container.builder()` | Create a container builder |
440
+ | `Container.empty()` | Create an empty container |
441
+ | `Container.scoped(scope)` | Create an empty scoped container |
442
+ | `container.resolve(tag)` | Get a dependency instance |
443
+ | `container.resolveAll(...tags)` | Get multiple dependencies |
444
+ | `container.use(tag, fn)` | Resolve, run callback, then destroy container |
445
+ | `container.destroy()` | Run finalizers and clean up |
446
+
447
+ ### ContainerBuilder
448
+
449
+ | Method | Description |
450
+ | ------------------------ | --------------------- |
451
+ | `builder.add(tag, spec)` | Register a dependency |
452
+ | `builder.build()` | Create the container |
453
+
454
+ ### Layer
455
+
456
+ | Method | Description |
457
+ | -------------------------------------- | --------------------------------- |
458
+ | `Layer.service(class, deps, options?)` | Create layer for a class |
459
+ | `Layer.value(tag, value)` | Create layer for a constant value |
460
+ | `Layer.create({ requires, apply })` | Create custom layer |
461
+ | `Layer.empty()` | Create empty layer |
462
+ | `Layer.merge(a, b)` | Merge two layers |
463
+ | `Layer.mergeAll(...layers)` | Merge multiple layers |
464
+ | `layer.provide(dep)` | Satisfy dependencies |
465
+ | `layer.provideMerge(dep)` | Satisfy and merge provisions |
466
+ | `layer.merge(other)` | Merge with another layer |
467
+
468
+ ### ScopedContainer
469
+
470
+ | Method | Description |
471
+ | ------------------------------------ | ------------------------------------------- |
472
+ | `ScopedContainer.builder(scope)` | Create a new scoped container builder |
473
+ | `ScopedContainer.empty(scope)` | Create empty scoped container |
474
+ | `ScopedContainer.from(scope, layer)` | Create from layer |
475
+ | `container.child(scope)` | Create child scope builder |
476
+ | `container.childFrom(scope, layer)` | Create child scope from layer (convenience) |
477
+
478
+ ### Tag
479
+
480
+ | Method | Description |
481
+ | ------------------ | --------------------------- |
482
+ | `Tag.of(id)<T>()` | Create a ValueTag |
483
+ | `Tag.id(tag)` | Get tag's string identifier |
484
+ | `Tag.isTag(value)` | Check if value is a tag |
2627
485
 
2628
486
  ## Comparison with Alternatives
2629
487
 
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 | ✅ | ❌ | ❌ | ❌ | ❌ |
488
+ | Feature | Sandly | NestJS | InversifyJS | TSyringe |
489
+ | -------------------------- | ------ | ------ | ----------- | -------- |
490
+ | Compile-time type safety | ✅ | ❌ | ⚠️ Partial | ❌ |
491
+ | No experimental decorators | ✅ | ❌ | ❌ | ❌ |
492
+ | Async factories | ✅ | ✅ | ❌ | ❌ |
493
+ | Framework-agnostic | ✅ | ❌ | ✅ | ✅ |
494
+ | Layer composition | ✅ | ❌ | ❌ | ❌ |
495
+ | Zero dependencies | ✅ | ❌ | ❌ | ❌ |
2772
496
 
2773
- ### Why Choose Sandly?
497
+ **Choose Sandly when you want:**
2774
498
 
2775
- Choose Sandly if you want:
499
+ - Type safety without sacrificing simplicity
500
+ - DI without experimental decorators
501
+ - Composable, reusable dependency modules
502
+ - Easy testing with mock injection
503
+ - Minimal bundle size and zero dependencies
2776
504
 
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
505
+ ## License
2784
506
 
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.
507
+ MIT