sandly 0.3.1 → 0.4.0

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