sandly 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +2646 -123
  2. package/package.json +1 -2
package/README.md CHANGED
@@ -1,211 +1,2734 @@
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
+ create: () => new Database(),
78
+ cleanup: (db) => db.close(), // Cleanup when container is destroyed
79
+ })
80
+ .register(
81
+ UserRepository,
82
+ async (ctx) => new UserRepository(await ctx.resolve(Database))
83
+ );
84
+
85
+ // Use the services
86
+ const userRepo = await container.resolve(UserRepository);
87
+ const users = await userRepo.findAll();
88
+ console.log(users); // [{ id: 1, name: 'Alice' }]
89
+
90
+ // Clean up all resources
91
+ await container.destroy(); // Calls db.close()
92
+ ```
93
+
94
+ **Key concepts:**
95
+
96
+ - **Tags** identify dependencies. Use `Tag.Service()` for classes or `Tag.of()` for values.
97
+ - **Container** manages service instantiation and caching. Each service is created once (singleton).
98
+ - **Factories** create service instances and can resolve other dependencies via the resolution context.
99
+ - **Finalizers** (optional) clean up resources when the container is destroyed.
100
+
101
+ For larger applications, use **Layers** to organize dependencies into composable modules:
102
+
103
+ ```typescript
104
+ import { layer, autoService, Container } from 'sandly';
105
+
106
+ // Layer that provides Database
107
+ const databaseLayer = layer<never, typeof Database>((container) =>
108
+ container.register(Database, {
109
+ create: () => new Database(),
110
+ cleanup: (db) => db.close(),
111
+ })
112
+ );
113
+
114
+ // Layer that provides UserRepository (depends on Database)
115
+ const userRepositoryLayer = autoService(UserRepository, [Database]);
116
+
117
+ // Compose layers - userRepositoryLayer.provide(databaseLayer) creates
118
+ // a complete layer with all dependencies satisfied
119
+ const appLayer = userRepositoryLayer.provide(databaseLayer);
120
+
121
+ // Apply to container
122
+ const container = appLayer.register(Container.empty());
123
+ const userRepo = await container.resolve(UserRepository);
124
+ ```
125
+
126
+ Continue reading to learn about all features including value tags, layer composition, and scope management.
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]);
251
+
252
+ // Compose into complete application layer
253
+ // Dependencies flow: Config -> Database -> UserService
254
+ const appLayer = userServiceLayer.provide(databaseLayer).provide(configLayer);
255
+
256
+ // Apply to container - all dependencies satisfied
257
+ const container = appLayer.register(Container.empty());
258
+ const userService = await container.resolve(UserService);
259
+ ```
260
+
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.
56
262
 
57
- const app = appLayer.register(container());
263
+ ### Flexible Dependency Values
264
+
265
+ Any value can be a dependency, not just class instances:
266
+
267
+ ```typescript
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
58
298
  ```
59
299
 
60
- ### Advanced Scope Management
300
+ ### Async Lifecycle Management
61
301
 
62
- Built-in support for request/runtime scopes with automatic cleanup:
302
+ Both service creation and cleanup can be asynchronous:
63
303
 
64
304
  ```typescript
65
- // Runtime-scoped dependencies (shared across requests)
66
- const runtime = scopedContainer('runtime').register(DatabaseService, {
67
- factory: () => new DatabaseService(),
68
- finalizer: (db) => db.disconnect(),
305
+ import { Container, Tag } from 'sandly';
306
+
307
+ class DatabaseConnection extends Tag.Service('DatabaseConnection') {
308
+ private connection: any = null;
309
+
310
+ async connect() {
311
+ console.log('Connecting to database...');
312
+ await new Promise((resolve) => setTimeout(resolve, 100));
313
+ this.connection = {
314
+ /* connection object */
315
+ };
316
+ console.log('Connected!');
317
+ }
318
+
319
+ async disconnect() {
320
+ console.log('Disconnecting from database...');
321
+ await new Promise((resolve) => setTimeout(resolve, 50));
322
+ this.connection = null;
323
+ console.log('Disconnected!');
324
+ }
325
+
326
+ query(sql: string) {
327
+ if (!this.connection) throw new Error('Not connected');
328
+ return [];
329
+ }
330
+ }
331
+
332
+ const container = Container.empty().register(DatabaseConnection, {
333
+ create: async () => {
334
+ const db = new DatabaseConnection();
335
+ await db.connect(); // Async initialization
336
+ return db;
337
+ },
338
+ cleanup: async (db) => {
339
+ await db.disconnect(); // Async cleanup
340
+ },
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 tokens that represent dependencies and serve as a way to reference them in the container. 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 should be unique in order to avoid collisions in TypeScript type error reporting. The main use-case for ValueTags is for injecting configuration values. Be careful with generic names like `'ApiKey'` or `'Config'` - prefer specific identifiers like `'ThirdPartyApiKey'` or `'HttpClientConfig'`.
511
+
512
+ ### Container
513
+
514
+ The container manages the lifecycle of your dependencies. It handles:
515
+
516
+ - **Registration**: Associating tags with factory functions
517
+ - **Resolution**: Creating and caching service instances
518
+ - **Dependency injection**: Making dependencies available to factories
519
+ - **Lifecycle management**: Calling finalizers when destroyed
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
683
+
684
+ Use `resolve()` to get a service instance:
196
685
 
197
- const app = fullApplication.register(container());
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. They receive the created instance and should perform any necessary cleanup (closing connections, releasing resources, etc.):
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
739
+ create: async () => {
740
+ const db = new DatabaseConnection();
741
+ await db.connect();
742
+ return db;
743
+ },
744
+ // Finalizer
745
+ cleanup: async (db) => {
746
+ await db.disconnect();
747
+ },
748
+ });
749
+
750
+ // Use the service
751
+ const db = await container.resolve(DatabaseConnection);
752
+ await db.query('SELECT 1');
753
+
754
+ // Clean up
755
+ await container.destroy();
756
+ // Output: "Disconnected"
757
+ ```
758
+
759
+ You can also implement `DependencyLifecycle` as a class for better organization and reuse:
760
+
761
+ ```typescript
762
+ import {
763
+ Container,
764
+ Tag,
765
+ type DependencyLifecycle,
766
+ type ResolutionContext,
767
+ } from 'sandly';
768
+
769
+ class Logger extends Tag.Service('Logger') {
770
+ log(message: string) {
771
+ console.log(message);
772
+ }
773
+ }
774
+
775
+ class DatabaseConnection extends Tag.Service('DatabaseConnection') {
776
+ constructor(
777
+ private logger: Logger,
778
+ private url: string
779
+ ) {
780
+ super();
781
+ }
782
+ async connect() {
783
+ this.logger.log('Connected');
784
+ }
785
+ async disconnect() {
786
+ this.logger.log('Disconnected');
787
+ }
788
+ }
789
+
790
+ class DatabaseLifecycle
791
+ implements DependencyLifecycle<DatabaseConnection, typeof Logger>
792
+ {
793
+ constructor(private url: string) {}
794
+
795
+ async create(
796
+ ctx: ResolutionContext<typeof Logger>
797
+ ): Promise<DatabaseConnection> {
798
+ const logger = await ctx.resolve(Logger);
799
+ const db = new DatabaseConnection(logger, this.url);
800
+ await db.connect();
801
+ return db;
802
+ }
803
+
804
+ async cleanup(db: DatabaseConnection): Promise<void> {
805
+ await db.disconnect();
806
+ }
807
+ }
808
+
809
+ const container = Container.empty()
810
+ .register(Logger, () => new Logger())
811
+ .register(
812
+ DatabaseConnection,
813
+ new DatabaseLifecycle('postgresql://localhost:5432')
814
+ );
815
+ ```
816
+
817
+ The `cleanup` method is optional, so you can implement classes with only a `create` method:
818
+
819
+ ```typescript
820
+ import { Container, Tag, type DependencyLifecycle } from 'sandly';
821
+
822
+ class SimpleService extends Tag.Service('SimpleService') {}
823
+
824
+ class SimpleServiceFactory
825
+ implements DependencyLifecycle<SimpleService, never>
826
+ {
827
+ create(): SimpleService {
828
+ return new SimpleService();
829
+ }
830
+ // cleanup is optional
831
+ }
832
+
833
+ const container = Container.empty().register(
834
+ SimpleService,
835
+ new SimpleServiceFactory()
836
+ );
837
+ ```
838
+
839
+ All finalizers run concurrently when you call `destroy()`:
840
+
841
+ ```typescript
842
+ const container = Container.empty()
843
+ .register(Database, {
844
+ create: () => new Database(),
845
+ cleanup: (db) => db.close(),
846
+ })
847
+ .register(Cache, {
848
+ create: () => new Cache(),
849
+ cleanup: (cache) => cache.clear(),
850
+ });
851
+
852
+ // Both finalizers run in parallel
853
+ await container.destroy();
854
+ ```
855
+
856
+ #### Overriding Registrations
857
+
858
+ You can override a registration before it's instantiated:
859
+
860
+ ```typescript
861
+ const container = Container.empty()
862
+ .register(Logger, () => new ConsoleLogger())
863
+ .register(Logger, () => new FileLogger()); // Overrides previous
864
+
865
+ const logger = await container.resolve(Logger);
866
+ // Gets FileLogger instance
867
+ ```
868
+
869
+ But you cannot override after instantiation:
870
+
871
+ ```typescript
872
+ const container = Container.empty().register(Logger, () => new Logger());
873
+
874
+ const logger = await container.resolve(Logger); // Instantiated
875
+
876
+ container.register(Logger, () => new Logger()); // Throws!
877
+ // DependencyAlreadyInstantiatedError: Cannot register dependency Logger -
878
+ // it has already been instantiated
879
+ ```
880
+
881
+ ### Container Methods
882
+
883
+ #### has() - Check if Registered
884
+
885
+ ```typescript
886
+ const container = Container.empty().register(Logger, () => new Logger());
887
+
888
+ console.log(container.has(Logger)); // true
889
+ console.log(container.has(Database)); // false
890
+ ```
891
+
892
+ #### exists() - Check if Instantiated
893
+
894
+ ```typescript
895
+ const container = Container.empty().register(Logger, () => new Logger());
896
+
897
+ console.log(container.exists(Logger)); // false - not instantiated yet
898
+
899
+ await container.resolve(Logger);
900
+
901
+ console.log(container.exists(Logger)); // true - now instantiated
902
+ ```
903
+
904
+ ### Error Handling
905
+
906
+ #### Unknown Dependency
907
+
908
+ ```typescript
909
+ const container = Container.empty();
910
+
911
+ try {
912
+ await container.resolve(Logger);
913
+ } catch (error) {
914
+ console.log(error instanceof UnknownDependencyError); // true
915
+ console.log(error.message); // "No factory registered for dependency Logger"
916
+ }
917
+ ```
918
+
919
+ However, thanks to the type system, the code above will produce a type error if you try to resolve a dependency that hasn't been registered, before you even run your code.
920
+
921
+ #### Circular Dependencies
922
+
923
+ Circular dependencies are detected at runtime:
924
+
925
+ ```typescript
926
+ class ServiceA extends Tag.Service('ServiceA') {}
927
+ class ServiceB extends Tag.Service('ServiceB') {}
928
+
929
+ const container = Container.empty()
930
+ .register(ServiceA, async (ctx) => {
931
+ await ctx.resolve(ServiceB);
932
+ return new ServiceA();
933
+ })
934
+ .register(ServiceB, async (ctx) => {
935
+ await ctx.resolve(ServiceA); // Circular!
936
+ return new ServiceB();
937
+ });
938
+
939
+ try {
940
+ await container.resolve(ServiceA);
941
+ } catch (error) {
942
+ console.log(error instanceof CircularDependencyError); // true
943
+ console.log(error.message);
944
+ // "Circular dependency detected for ServiceA: ServiceA -> ServiceB -> ServiceA"
945
+ }
946
+ ```
947
+
948
+ Similarly to unknown dependencies, the type system will catch this error before you even run your code.
949
+
950
+ #### Creation Errors
951
+
952
+ If a factory throws, the error is wrapped in `DependencyCreationError`:
953
+
954
+ ```typescript
955
+ const container = Container.empty().register(Database, () => {
956
+ throw new Error('Connection failed');
957
+ });
958
+
959
+ try {
960
+ await container.resolve(Database);
961
+ } catch (error) {
962
+ console.log(error instanceof DependencyCreationError); // true
963
+ console.log(error.cause); // Original Error: Connection failed
964
+ }
965
+ ```
966
+
967
+ **Nested Creation Errors**
968
+
969
+ When dependencies are nested (A depends on B, B depends on C), and C's factory throws, you get nested `DependencyCreationError`s. Use `getRootCause()` to unwrap all the layers and get the original error:
970
+
971
+ ```typescript
972
+ class ServiceC extends Tag.Service('ServiceC') {
973
+ constructor() {
974
+ super();
975
+ throw new Error('Database connection failed');
976
+ }
977
+ }
978
+
979
+ class ServiceB extends Tag.Service('ServiceB') {
980
+ constructor(private c: ServiceC) {
981
+ super();
982
+ }
983
+ }
984
+
985
+ class ServiceA extends Tag.Service('ServiceA') {
986
+ constructor(private b: ServiceB) {
987
+ super();
988
+ }
989
+ }
990
+
991
+ const container = Container.empty()
992
+ .register(ServiceC, () => new ServiceC())
993
+ .register(
994
+ ServiceB,
995
+ async (ctx) => new ServiceB(await ctx.resolve(ServiceC))
996
+ )
997
+ .register(
998
+ ServiceA,
999
+ async (ctx) => new ServiceA(await ctx.resolve(ServiceB))
1000
+ );
1001
+
1002
+ try {
1003
+ await container.resolve(ServiceA);
1004
+ } catch (error) {
1005
+ if (error instanceof DependencyCreationError) {
1006
+ console.log(error.message);
1007
+ // "Error creating instance of ServiceA"
1008
+
1009
+ // Get the original error that caused the failure
1010
+ const rootCause = error.getRootCause();
1011
+ console.log(rootCause);
1012
+ // Error: Database connection failed
1013
+ }
1014
+ }
1015
+ ```
1016
+
1017
+ #### Finalization Errors
1018
+
1019
+ If any finalizer fails, cleanup continues for others and a `DependencyFinalizationError` is thrown with details of all failures:
1020
+
1021
+ ```typescript
1022
+ class Database extends Tag.Service('Database') {
1023
+ async close() {
1024
+ throw new Error('Database close failed');
1025
+ }
1026
+ }
1027
+
1028
+ class Cache extends Tag.Service('Cache') {
1029
+ async clear() {
1030
+ throw new Error('Cache clear failed');
1031
+ }
1032
+ }
1033
+
1034
+ const container = Container.empty()
1035
+ .register(Database, {
1036
+ create: () => new Database(),
1037
+ cleanup: async (db) => db.close(),
1038
+ })
1039
+ .register(Cache, {
1040
+ create: () => new Cache(),
1041
+ cleanup: async (cache) => cache.clear(),
1042
+ });
1043
+
1044
+ await container.resolve(Database);
1045
+ await container.resolve(Cache);
1046
+
1047
+ try {
1048
+ await container.destroy();
1049
+ } catch (error) {
1050
+ if (error instanceof DependencyFinalizationError) {
1051
+ // Get all original errors that caused the finalization failure
1052
+ const rootCauses = error.getRootCauses();
1053
+ console.error('Finalization failures:', rootCauses);
1054
+ // [
1055
+ // Error: Database close failed,
1056
+ // Error: Cache clear failed
1057
+ // ]
1058
+ }
1059
+ }
1060
+ ```
1061
+
1062
+ ### Type Safety in Action
1063
+
1064
+ The container's type parameter tracks all registered dependencies:
1065
+
1066
+ ```typescript
1067
+ const c1 = Container.empty();
1068
+ // Type: Container<never>
1069
+
1070
+ const c2 = c1.register(Database, () => new Database());
1071
+ // Type: Container<typeof Database>
1072
+
1073
+ const c3 = c2.register(Logger, () => new Logger());
1074
+ // Type: Container<typeof Database | typeof Logger>
1075
+
1076
+ // TypeScript knows what's available
1077
+ await c3.resolve(Database); // ✅ OK
1078
+ await c3.resolve(Logger); // ✅ OK
1079
+ await c3.resolve(Cache); // ❌ Type error
1080
+ ```
1081
+
1082
+ Factory functions have typed resolution contexts:
1083
+
1084
+ ```typescript
1085
+ const container = Container.empty()
1086
+ .register(Database, () => new Database())
1087
+ .register(Logger, () => new Logger())
1088
+ .register(UserService, async (ctx) => {
1089
+ // ctx can only resolve Database or Logger
1090
+ await ctx.resolve(Database); // ✅ OK
1091
+ await ctx.resolve(Logger); // ✅ OK
1092
+ await ctx.resolve(Cache); // ❌ Type error
1093
+
1094
+ return new UserService();
1095
+ });
1096
+ ```
1097
+
1098
+ ### Best Practices
1099
+
1100
+ **Use method chaining** - Each `register()` returns the container with updated types:
1101
+
1102
+ ```typescript
1103
+ // ✅ Good - types flow through chain
1104
+ const container = Container.empty()
1105
+ .register(Database, () => new Database())
1106
+ .register(Logger, () => new Logger())
1107
+ .register(
1108
+ UserService,
1109
+ async (ctx) =>
1110
+ new UserService(
1111
+ await ctx.resolve(Database),
1112
+ await ctx.resolve(Logger)
1113
+ )
1114
+ );
1115
+
1116
+ // ❌ Bad - lose type information
1117
+ const container = Container.empty();
1118
+ container.register(Database, () => new Database());
1119
+ container.register(Logger, () => new Logger());
1120
+ // TypeScript doesn't track these registrations
1121
+ ```
1122
+
1123
+ **Prefer layers for multiple dependencies** - Once you have larger numbers of services and more complex dependency graphs, layers become cleaner. See the next section for more details.
1124
+
1125
+ **Handle cleanup errors** - Finalizers can fail:
1126
+
1127
+ ```typescript
1128
+ try {
1129
+ await container.destroy();
1130
+ } catch (error) {
1131
+ if (error instanceof DependencyFinalizationError) {
1132
+ console.error('Cleanup failed:', error.detail.errors);
1133
+ // Continue with shutdown anyway
1134
+ }
1135
+ }
1136
+ ```
1137
+
1138
+ **Avoid resolving during registration if possible** - Once you resolve a dependency, the container will cache it and you won't be able to override the registration. This might become problematic in case you're composing layers and multiple layers reference the same layer in their provisions (see more on layers below). It's better to keep registration and resolution separate:
1139
+
1140
+ ```typescript
1141
+ // ❌ Bad - resolving during setup creates timing issues
1142
+ const container = Container.empty().register(Logger, () => new Logger());
1143
+
1144
+ const logger = await container.resolve(Logger); // During setup!
1145
+
1146
+ container.register(Database, () => new Database());
1147
+
1148
+ // ✅ Good - register everything first, then resolve
1149
+ const container = Container.empty()
1150
+ .register(Logger, () => new Logger())
1151
+ .register(Database, () => new Database());
1152
+
1153
+ // Now use services
1154
+ const logger = await container.resolve(Logger);
1155
+ ```
1156
+
1157
+ However, it's perfectly fine to resolve and even use dependencies inside another dependency factory function.
1158
+
1159
+ ```typescript
1160
+ // ✅ Also good - resolve dependency inside factory function during the registration
1161
+ const container = Container.empty()
1162
+ .register(Logger, () => new Logger())
1163
+ .register(Database, (ctx) => {
1164
+ const db = new Database();
1165
+ const logger = await ctx.resolve(Logger);
1166
+ logger.log('Database created successfully');
1167
+ return db;
1168
+ });
1169
+ ```
1170
+
1171
+ ## Working with Layers
1172
+
1173
+ Layers are the recommended approach for organizing dependencies in larger applications. While direct container registration works well for small projects, layers provide better code organization, reusability, and developer experience as your application grows.
1174
+
1175
+ ### Why Use Layers?
1176
+
1177
+ Layers solve three key problems with manual container registration: repetitive boilerplate, lack of reusability across entry points, and leakage of implementation details.
1178
+
1179
+ #### Problem 1: Repetitive Factory Boilerplate
1180
+
1181
+ With direct container registration, you must write factory functions repeatedly:
1182
+
1183
+ ```typescript
1184
+ // user-repository.ts
1185
+ export class UserRepository extends Tag.Service('UserRepository') {
1186
+ constructor(
1187
+ private db: Database,
1188
+ private logger: Logger
1189
+ ) {
1190
+ super();
1191
+ }
1192
+ // ... implementation
1193
+ }
1194
+
1195
+ // app.ts - Far away from the implementation!
1196
+ const container = Container.empty()
1197
+ .register(Database, () => new Database())
1198
+ .register(Logger, () => new Logger())
1199
+ .register(UserRepository, async (ctx) => {
1200
+ // Manually specify what the constructor needs
1201
+ const [db, logger] = await ctx.resolveAll(Database, Logger);
1202
+ return new UserRepository(db, logger);
1203
+ });
1204
+ ```
1205
+
1206
+ Every service requires manually writing a factory that resolves its dependencies and calls the constructor. This is **repetitive and error-prone** - if you add a dependency to the constructor, you must remember to update the factory too.
1207
+
1208
+ **Solution:** Layers provide shorthand helpers (`service`, `autoService`) that eliminate boilerplate and keep the layer definition next to the implementation:
1209
+
1210
+ ```typescript
1211
+ // user-repository.ts
1212
+ export class UserRepository extends Tag.Service('UserRepository') {
1213
+ constructor(
1214
+ private db: Database,
1215
+ private logger: Logger
1216
+ ) {
1217
+ super();
1218
+ }
1219
+ // ... implementation
1220
+ }
1221
+
1222
+ // Layer defined right next to the class
1223
+ export const userRepositoryLayer = autoService(UserRepository, [
1224
+ Database,
1225
+ Logger,
1226
+ ]);
1227
+
1228
+ // app.ts - Just compose the layers
1229
+ const appLayer = userRepositoryLayer.provide(
1230
+ Layer.mergeAll(databaseLayer, loggerLayer)
1231
+ );
1232
+ const container = appLayer.register(Container.empty());
1233
+ ```
1234
+
1235
+ #### Problem 2: No Reusability Across Entry Points
1236
+
1237
+ Applications with multiple entry points (multiple Lambda functions, CLI commands, background workers) need to wire up dependencies separately for each entry point. Without layers, you must duplicate the registration logic:
1238
+
1239
+ ```typescript
1240
+ // functions/create-user.ts - Lambda that creates users
1241
+ export async function handler(event: APIGatewayEvent) {
1242
+ // Duplicate ALL the registration logic
1243
+ const container = Container.empty()
1244
+ .register(Config, () => loadConfig())
1245
+ .register(
1246
+ Database,
1247
+ async (ctx) => new Database(await ctx.resolve(Config))
1248
+ )
1249
+ .register(Logger, () => new Logger())
1250
+ .register(
1251
+ UserRepository,
1252
+ async (ctx) =>
1253
+ new UserRepository(
1254
+ await ctx.resolve(Database),
1255
+ await ctx.resolve(Logger)
1256
+ )
1257
+ )
1258
+ .register(
1259
+ UserService,
1260
+ async (ctx) =>
1261
+ new UserService(
1262
+ await ctx.resolve(UserRepository),
1263
+ await ctx.resolve(Logger)
1264
+ )
1265
+ );
1266
+
1267
+ const userService = await container.resolve(UserService);
1268
+ // ... handle request
1269
+ }
1270
+
1271
+ // functions/get-orders.ts - Lambda that fetches orders
1272
+ export async function handler(event: APIGatewayEvent) {
1273
+ // Duplicate the SAME registration logic AGAIN
1274
+ const container = Container.empty()
1275
+ .register(Config, () => loadConfig())
1276
+ .register(
1277
+ Database,
1278
+ async (ctx) => new Database(await ctx.resolve(Config))
1279
+ )
1280
+ .register(Logger, () => new Logger())
1281
+ .register(
1282
+ OrderRepository,
1283
+ async (ctx) =>
1284
+ new OrderRepository(
1285
+ await ctx.resolve(Database),
1286
+ await ctx.resolve(Logger)
1287
+ )
1288
+ )
1289
+ .register(
1290
+ OrderService,
1291
+ async (ctx) =>
1292
+ new OrderService(
1293
+ await ctx.resolve(OrderRepository),
1294
+ await ctx.resolve(Logger)
1295
+ )
1296
+ );
1297
+ // Uses OrderService but had to register Database, Logger, etc again
1298
+
1299
+ const orderService = await container.resolve(OrderService);
1300
+ // ... handle request
1301
+ }
1302
+ ```
1303
+
1304
+ This has major problems:
1305
+
1306
+ - **Massive duplication**: Registration logic is copy-pasted across entry points
1307
+ - **Maintenance nightmare**: When you change `UserRepository`'s dependencies, you must update every Lambda that uses it
1308
+ - **Can't compose selectively**: Each entry point must register ALL dependencies, even those it doesn't need
1309
+ - **Configuration inconsistency**: Each entry point might configure services differently by accident
1310
+
1311
+ **Solution:** Define layers once, compose them differently for each entry point:
1312
+
1313
+ ```typescript
1314
+ // Shared infrastructure - defined once
1315
+ // database.ts
1316
+ export const databaseLayer = autoService(Database, [ConfigTag]);
1317
+
1318
+ // logger.ts
1319
+ export const loggerLayer = autoService(Logger, []);
1320
+
1321
+ // config.ts
1322
+ export const configLayer = value(ConfigTag, loadConfig());
1323
+
1324
+ // Infrastructure layer combining all base services
1325
+ export const infraLayer = Layer.mergeAll(
1326
+ databaseLayer,
1327
+ loggerLayer,
1328
+ configLayer
1329
+ );
1330
+
1331
+ // Domain layers - defined once
1332
+ // user-repository.ts
1333
+ export const userRepositoryLayer = autoService(UserRepository, [
1334
+ Database,
1335
+ Logger,
1336
+ ]);
1337
+
1338
+ // user-service.ts
1339
+ export const userServiceLayer = autoService(UserService, [
1340
+ UserRepository,
1341
+ Logger,
1342
+ ]);
1343
+
1344
+ // order-repository.ts
1345
+ export const orderRepositoryLayer = autoService(OrderRepository, [
1346
+ Database,
1347
+ Logger,
1348
+ ]);
1349
+
1350
+ // order-service.ts
1351
+ export const orderServiceLayer = autoService(OrderService, [
1352
+ OrderRepository,
1353
+ Logger,
1354
+ ]);
1355
+
1356
+ // Now compose differently for each Lambda
1357
+ // functions/create-user.ts
1358
+ export async function handler(event: APIGatewayEvent) {
1359
+ // Only UserService and its dependencies - no Order code!
1360
+ const appLayer = userServiceLayer
1361
+ .provide(userRepositoryLayer)
1362
+ .provide(infraLayer);
1363
+
1364
+ const container = appLayer.register(Container.empty());
1365
+ const userService = await container.resolve(UserService);
1366
+ // ... handle request
1367
+ }
1368
+
1369
+ // functions/get-orders.ts
1370
+ export async function handler(event: APIGatewayEvent) {
1371
+ // Only OrderService and its dependencies - no User code!
1372
+ const appLayer = orderServiceLayer
1373
+ .provide(orderRepositoryLayer)
1374
+ .provide(infraLayer);
1375
+
1376
+ const container = appLayer.register(Container.empty());
1377
+ const orderService = await container.resolve(OrderService);
1378
+ // ... handle request
1379
+ }
1380
+
1381
+ // functions/admin-dashboard.ts - Needs both!
1382
+ export async function handler(event: APIGatewayEvent) {
1383
+ // Compose BOTH user and order services
1384
+ const appLayer = Layer.mergeAll(
1385
+ userServiceLayer.provide(userRepositoryLayer),
1386
+ orderServiceLayer.provide(orderRepositoryLayer)
1387
+ ).provide(infraLayer);
1388
+
1389
+ const container = appLayer.register(Container.empty());
1390
+ const userService = await container.resolve(UserService);
1391
+ const orderService = await container.resolve(OrderService);
1392
+ // ... handle request
1393
+ }
1394
+ ```
1395
+
1396
+ Benefits:
1397
+
1398
+ - **Zero duplication**: Each layer is defined once, reused everywhere
1399
+ - **Easy maintenance**: Change `UserRepository`'s constructor once, all entry points automatically use the new version
1400
+ - **Compose exactly what you need**: Each Lambda only includes the services it actually uses
1401
+ - **Consistent configuration**: Infrastructure like Database is configured once in `infraLayer`
1402
+
1403
+ #### Problem 3: Requirement Leakage
1404
+
1405
+ Without layers, internal implementation details leak into your API. Consider a `UserService` that depends on `UserValidator` and `UserNotifier` internally:
1406
+
1407
+ ```typescript
1408
+ // Without layers - internal dependencies leak
1409
+ export class UserService {
1410
+ constructor(
1411
+ private validator: UserValidator,
1412
+ private notifier: UserNotifier,
1413
+ private db: Database
1414
+ ) {}
1415
+ }
1416
+
1417
+ // Consumers must know about internal dependencies
1418
+ const container = Container.empty()
1419
+ .register(UserValidator, () => new UserValidator())
1420
+ .register(UserNotifier, () => new UserNotifier())
1421
+ .register(Database, () => new Database())
1422
+ .register(
1423
+ UserService,
1424
+ async (ctx) =>
1425
+ new UserService(
1426
+ await ctx.resolve(UserValidator),
1427
+ await ctx.resolve(UserNotifier),
1428
+ await ctx.resolve(Database)
1429
+ )
1430
+ );
1431
+ ```
1432
+
1433
+ Consumers need to know about `UserValidator` and `UserNotifier`, even though they're internal implementation details. If you refactor UserService's internals, consumers must update their code.
1434
+
1435
+ #### Solution: Encapsulated Requirements
1436
+
1437
+ Layers can hide internal dependencies:
1438
+
1439
+ ```typescript
1440
+ // user-service.ts
1441
+ export class UserService extends Tag.Service('UserService') {
1442
+ constructor(
1443
+ private validator: UserValidator,
1444
+ private notifier: UserNotifier,
1445
+ private db: Database
1446
+ ) {
1447
+ super();
1448
+ }
1449
+ }
1450
+
1451
+ // Internal dependencies provided inline
1452
+ export const userServiceLayer = autoService(UserService, [
1453
+ UserValidator,
1454
+ UserNotifier,
1455
+ Database,
1456
+ ]).provide(Layer.mergeAll(userValidatorLayer, userNotifierLayer));
1457
+
1458
+ // Type: Layer<typeof Database, typeof UserService>
1459
+ // Only requires Database externally!
1460
+
1461
+ // app.ts - Consumers don't see internal dependencies
1462
+ const appLayer = userServiceLayer.provide(databaseLayer);
1463
+ // Just provide Database, internal details are hidden
1464
+ ```
1465
+
1466
+ The layer hides `UserValidator` and `UserNotifier` as implementation details. Consumers only need to provide `Database`. You can refactor internals freely without affecting consumers.
1467
+
1468
+ ### Benefits Summary
1469
+
1470
+ Layers provide:
1471
+
1472
+ - **Cleaner syntax**: `autoService()` and `service()` eliminate repetitive factory boilerplate
1473
+ - **Reusability**: Define layers once, compose them differently across multiple entry points (Lambda functions, CLI commands, workers)
1474
+ - **Selective composition**: Each entry point only includes the dependencies it actually needs
1475
+ - **Better organization**: Dependency construction logic lives next to the implementation (code that changes together stays together)
1476
+ - **Encapsulation**: Hide internal dependencies from consumers
1477
+ - **Type safety**: Requirements and provisions tracked at the type level
1478
+
1479
+ ### Creating Layers
1480
+
1481
+ #### layer() - Manual Layer Creation
1482
+
1483
+ The `layer()` function creates a layer by providing a registration function:
1484
+
1485
+ ```typescript
1486
+ import { layer, Container } from 'sandly';
1487
+
1488
+ class Database extends Tag.Service('Database') {
1489
+ query(sql: string) {
1490
+ return [];
1491
+ }
1492
+ }
1493
+
1494
+ // Must annotate layer type parameters manually
1495
+ const databaseLayer = layer<never, typeof Database>((container) =>
1496
+ container.register(Database, () => new Database())
1497
+ );
1498
+
1499
+ // Apply to container
1500
+ const container = databaseLayer.register(Container.empty());
1501
+ const db = await container.resolve(Database);
1502
+ ```
1503
+
1504
+ **Type parameters:**
1505
+
1506
+ - First: Requirements (what the layer needs) - `never` means no requirements
1507
+ - Second: Provisions (what the layer provides) - `typeof Database`
1508
+
1509
+ With dependencies:
1510
+
1511
+ ```typescript
1512
+ class Logger extends Tag.Service('Logger') {
1513
+ log(msg: string) {
1514
+ console.log(msg);
1515
+ }
1516
+ }
1517
+
1518
+ class UserRepository extends Tag.Service('UserRepository') {
1519
+ constructor(
1520
+ private db: Database,
1521
+ private logger: Logger
1522
+ ) {
1523
+ super();
1524
+ }
1525
+
1526
+ async findAll() {
1527
+ this.logger.log('Finding all users');
1528
+ return this.db.query('SELECT * FROM users');
1529
+ }
1530
+ }
1531
+
1532
+ // Requires Database and Logger, provides UserRepository
1533
+ const userRepositoryLayer = layer<
1534
+ typeof Database | typeof Logger,
1535
+ typeof UserRepository
1536
+ >((container) =>
1537
+ container.register(UserRepository, async (ctx) => {
1538
+ const [db, logger] = await ctx.resolveAll(Database, Logger);
1539
+ return new UserRepository(db, logger);
1540
+ })
1541
+ );
1542
+ ```
1543
+
1544
+ #### service() - Service Layer Helper
1545
+
1546
+ The `service()` function is a convenience wrapper for creating service layers:
1547
+
1548
+ ```typescript
1549
+ import { service } from 'sandly';
1550
+
1551
+ // Simpler than layer() - infers types from the factory
1552
+ const userRepositoryLayer = service(UserRepository, async (ctx) => {
1553
+ const [db, logger] = await ctx.resolveAll(Database, Logger);
1554
+ return new UserRepository(db, logger);
1555
+ });
1556
+
1557
+ // With finalizer
1558
+ const databaseLayer = service(Database, {
1559
+ create: async () => {
1560
+ const db = new Database();
1561
+ await db.connect();
1562
+ return db;
1563
+ },
1564
+ cleanup: (db) => db.disconnect(),
1565
+ });
1566
+ ```
1567
+
1568
+ The dependencies are automatically inferred from the factory's resolution context.
1569
+
1570
+ #### autoService() - Automatic Constructor Injection
1571
+
1572
+ The `autoService()` function automatically injects dependencies based on constructor parameters:
1573
+
1574
+ ```typescript
1575
+ import { autoService } from 'sandly';
1576
+
1577
+ class UserRepository extends Tag.Service('UserRepository') {
1578
+ constructor(
1579
+ private db: Database,
1580
+ private logger: Logger
1581
+ ) {
1582
+ super();
1583
+ }
1584
+
1585
+ async findAll() {
1586
+ this.logger.log('Finding all users');
1587
+ return this.db.query('SELECT * FROM users');
1588
+ }
1589
+ }
1590
+
1591
+ // Automatically resolves Database and Logger from constructor
1592
+ const userRepositoryLayer = autoService(UserRepository, [Database, Logger]);
1593
+ ```
1594
+
1595
+ Mix ServiceTag dependencies, ValueTag dependencies, and static values:
1596
+
1597
+ ```typescript
1598
+ const ApiKeyTag = Tag.of('ApiKey')<string>();
1599
+ const TimeoutTag = Tag.of('Timeout')<number>();
1600
+
1601
+ class ApiClient extends Tag.Service('ApiClient') {
1602
+ constructor(
1603
+ private logger: Logger, // ServiceTag - works automatically
1604
+ private apiKey: Inject<typeof ApiKeyTag>, // ValueTag - needs Inject<>
1605
+ private timeout: Inject<typeof TimeoutTag>, // ValueTag - needs Inject<>
1606
+ private baseUrl: string // Static value
1607
+ ) {
1608
+ super();
1609
+ }
1610
+ }
1611
+
1612
+ // Order matters - must match constructor parameter order
1613
+ const apiClientLayer = autoService(ApiClient, [
1614
+ Logger, // ServiceTag - resolved from container
1615
+ ApiKeyTag, // ValueTag - resolved from container
1616
+ TimeoutTag, // ValueTag - resolved from container
1617
+ 'https://api.example.com', // Static value - passed directly
1618
+ ]);
1619
+ ```
1620
+
1621
+ **Important**: ValueTag dependencies in constructors must be annotated with `Inject<typeof YourTag>`. This preserves type information for `service()` and `autoService()` to infer the dependency. Without `Inject<>`, TypeScript sees it as a regular value and `service()` and `autoService()` won't know to resolve it from the container.
1622
+
1623
+ With cleanup:
1624
+
1625
+ ```typescript
1626
+ const databaseLayer = autoService(Database, {
1627
+ dependencies: ['postgresql://localhost:5432/mydb'],
1628
+ cleanup: (db) => db.disconnect(),
1629
+ });
1630
+ ```
1631
+
1632
+ #### value() - Value Layer Helper
1633
+
1634
+ The `value()` function creates a layer that provides a constant value:
1635
+
1636
+ ```typescript
1637
+ import { value, Tag } from 'sandly';
1638
+
1639
+ const ApiKeyTag = Tag.of('ApiKey')<string>();
1640
+ const PortTag = Tag.of('Port')<number>();
1641
+
1642
+ const apiKeyLayer = value(ApiKeyTag, 'my-secret-key');
1643
+ const portLayer = value(PortTag, 3000);
1644
+
1645
+ // Combine value layers
1646
+ const configLayer = Layer.mergeAll(
1647
+ apiKeyLayer,
1648
+ portLayer,
1649
+ value(Tag.of('Debug')<boolean>(), true)
1650
+ );
1651
+ ```
1652
+
1653
+ ### Using Inject<> for ValueTag Dependencies
1654
+
1655
+ When using ValueTags as constructor parameters with `autoService()`, you must annotate them with `Inject<>`:
1656
+
1657
+ ```typescript
1658
+ import { Tag, Inject, autoService } from 'sandly';
1659
+
1660
+ const ApiKeyTag = Tag.of('ApiKey')<string>();
1661
+ const TimeoutTag = Tag.of('Timeout')<number>();
1662
+
1663
+ class ApiClient extends Tag.Service('ApiClient') {
1664
+ constructor(
1665
+ private logger: Logger, // ServiceTag - works automatically
1666
+ private apiKey: Inject<typeof ApiKeyTag>, // ValueTag - needs Inject<>
1667
+ private timeout: Inject<typeof TimeoutTag> // ValueTag - needs Inject<>
1668
+ ) {
1669
+ super();
1670
+ }
1671
+
1672
+ async get(endpoint: string) {
1673
+ // this.apiKey is typed as string (the actual value type)
1674
+ // this.timeout is typed as number
1675
+ return fetch(endpoint, {
1676
+ headers: { Authorization: `Bearer ${this.apiKey}` },
1677
+ signal: AbortSignal.timeout(this.timeout),
1678
+ });
1679
+ }
1680
+ }
1681
+
1682
+ // autoService infers dependencies from constructor
1683
+ const apiClientLayer = autoService(ApiClient, [
1684
+ Logger, // ServiceTag
1685
+ ApiKeyTag, // ValueTag - resolved from container
1686
+ TimeoutTag, // ValueTag - resolved from container
1687
+ ]);
1688
+ ```
1689
+
1690
+ `Inject<>` is a type-level marker that:
1691
+
1692
+ - Keeps the actual value type (string, number, etc.)
1693
+ - Allows dependency inference for `autoService()`
1694
+ - Has no runtime overhead
1695
+
1696
+ ### Composing Layers
1697
+
1698
+ Layers can be combined in three ways: **provide**, **provideMerge**, and **merge**.
1699
+
1700
+ #### .provide() - Sequential Composition
1701
+
1702
+ Provides dependencies to a layer, hiding the dependency layer's provisions in the result:
1703
+
1704
+ ```typescript
1705
+ const configLayer = layer<never, typeof ConfigTag>((container) =>
1706
+ container.register(ConfigTag, () => loadConfig())
1707
+ );
1708
+
1709
+ const databaseLayer = layer<typeof ConfigTag, typeof Database>((container) =>
1710
+ container.register(Database, async (ctx) => {
1711
+ const config = await ctx.resolve(ConfigTag);
1712
+ return new Database(config);
1713
+ })
1714
+ );
1715
+
1716
+ // Database layer needs ConfigTag, which configLayer provides
1717
+ const infraLayer = databaseLayer.provide(configLayer);
1718
+ // Type: Layer<never, typeof Database>
1719
+ // Note: ConfigTag is hidden - it's an internal detail
1720
+ ```
1721
+
1722
+ The type signature:
1723
+
1724
+ ```typescript
1725
+ Layer<TRequires, TProvides>.provide(
1726
+ dependency: Layer<TDepReq, TDepProv>
1727
+ ) => Layer<TDepReq | Exclude<TRequires, TDepProv>, TProvides>
1728
+ ```
1729
+
1730
+ Reading left-to-right (natural flow):
1731
+
1732
+ ```typescript
1733
+ const appLayer = serviceLayer // needs: Database, Logger
1734
+ .provide(infraLayer) // needs: Config, provides: Database, Logger
1735
+ .provide(configLayer); // needs: nothing, provides: Config
1736
+ // Result: Layer<never, typeof UserService>
1737
+ ```
1738
+
1739
+ #### .provideMerge() - Composition with Merged Provisions
1740
+
1741
+ Like `.provide()` but includes both layers' provisions in the result:
1742
+
1743
+ ```typescript
1744
+ const infraLayer = databaseLayer.provideMerge(configLayer);
1745
+ // Type: Layer<never, typeof ConfigTag | typeof Database>
1746
+ // Both ConfigTag and Database are available
1747
+ ```
1748
+
1749
+ Use when you want to expose multiple layers' services:
1750
+
1751
+ ```typescript
1752
+ const AppConfigTag = Tag.of('AppConfig')<AppConfig>();
1753
+
1754
+ const configLayer = value(AppConfigTag, loadConfig());
1755
+ const databaseLayer = layer<typeof AppConfigTag, typeof Database>((container) =>
1756
+ container.register(
1757
+ Database,
1758
+ async (ctx) => new Database(await ctx.resolve(AppConfigTag))
1759
+ )
1760
+ );
1761
+
1762
+ // Expose both config and database
1763
+ const infraLayer = databaseLayer.provideMerge(configLayer);
1764
+ // Type: Layer<never, typeof AppConfigTag | typeof Database>
1765
+
1766
+ // Services can use both
1767
+ const container = infraLayer.register(Container.empty());
1768
+ const config = await container.resolve(AppConfigTag); // Available!
1769
+ const db = await container.resolve(Database); // Available!
1770
+ ```
1771
+
1772
+ #### .merge() - Parallel Combination
1773
+
1774
+ Merges two independent layers (no dependency relationship):
1775
+
1776
+ ```typescript
1777
+ const databaseLayer = layer<never, typeof Database>((container) =>
1778
+ container.register(Database, () => new Database())
1779
+ );
1780
+
1781
+ const loggerLayer = layer<never, typeof Logger>((container) =>
1782
+ container.register(Logger, () => new Logger())
1783
+ );
1784
+
1785
+ // Combine independent layers
1786
+ const infraLayer = databaseLayer.merge(loggerLayer);
1787
+ // Type: Layer<never, typeof Database | typeof Logger>
1788
+ ```
1789
+
1790
+ For multiple layers, use `Layer.mergeAll()`:
1791
+
1792
+ ```typescript
1793
+ const infraLayer = Layer.mergeAll(
1794
+ databaseLayer,
1795
+ loggerLayer,
1796
+ cacheLayer,
1797
+ metricsLayer
1798
+ );
1799
+ ```
1800
+
1801
+ ### Static Layer Methods
1802
+
1803
+ #### Layer.empty()
1804
+
1805
+ Creates an empty layer (no requirements, no provisions):
1806
+
1807
+ ```typescript
1808
+ import { Layer } from 'sandly';
1809
+
1810
+ const emptyLayer = Layer.empty();
1811
+ // Type: Layer<never, never>
1812
+ ```
1813
+
1814
+ #### Layer.merge()
1815
+
1816
+ Merges exactly two layers:
1817
+
1818
+ ```typescript
1819
+ const combined = Layer.merge(databaseLayer, loggerLayer);
1820
+ // Equivalent to: databaseLayer.merge(loggerLayer)
1821
+ ```
1822
+
1823
+ #### Layer.mergeAll()
1824
+
1825
+ Merges multiple layers at once:
1826
+
1827
+ ```typescript
1828
+ const infraLayer = Layer.mergeAll(
1829
+ value(ApiKeyTag, 'key'),
1830
+ value(PortTag, 3000),
1831
+ databaseLayer,
1832
+ loggerLayer
1833
+ );
1834
+ // Type: Layer<Requirements, Provisions> with all merged
1835
+ ```
1836
+
1837
+ Requires at least 2 layers.
1838
+
1839
+ ### Applying Layers to Containers
1840
+
1841
+ Use the `.register()` method to apply a layer to a container:
1842
+
1843
+ ```typescript
1844
+ const appLayer = userServiceLayer.provide(databaseLayer).provide(configLayer);
1845
+
1846
+ // Apply to container
1847
+ const container = appLayer.register(Container.empty());
1848
+
1849
+ // Now resolve services
1850
+ const userService = await container.resolve(UserService);
1851
+ ```
1852
+
1853
+ Layers can be applied to containers that already have services:
1854
+
1855
+ ```typescript
1856
+ const baseContainer = Container.empty().register(Logger, () => new Logger());
1857
+
1858
+ // Apply layer to container with existing services
1859
+ const container = databaseLayer.register(baseContainer);
1860
+ // Container now has both Logger and Database
1861
+ ```
1862
+
1863
+ #### Type Safety: Requirements Must Be Satisfied
1864
+
1865
+ 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.
1866
+
1867
+ ```typescript
1868
+ // Layer that requires Database
1869
+ const userServiceLayer = autoService(UserService, [Database, Logger]);
1870
+
1871
+ // ✅ Works - Container.empty() can be used because layer has no requirements
1872
+ // (userServiceLayer was composed with all dependencies via .provide())
1873
+ const completeLayer = userServiceLayer
1874
+ .provide(userRepositoryLayer)
1875
+ .provide(infraLayer);
1876
+ // Type: Layer<never, typeof UserService> - no requirements!
1877
+
1878
+ const container = completeLayer.register(Container.empty());
1879
+ // ✅ TypeScript allows this because completeLayer has no requirements
1880
+
1881
+ // ❌ Type error - Layer still has requirements
1882
+ const incompleteLayer = userServiceLayer.provide(userRepositoryLayer);
1883
+ // Type: Layer<typeof Logger, typeof UserService> - still needs Logger!
1884
+
1885
+ const container2 = incompleteLayer.register(Container.empty());
1886
+ // ❌ Error: Argument of type 'Container<never>' is not assignable to parameter of type 'IContainer<ServiceTag<"Logger", Logger>>'.
1887
+ ```
1888
+
1889
+ When applying a layer to an existing container, the container must already have all the layer's requirements:
1890
+
1891
+ ```typescript
1892
+ // Layer requires Database
1893
+ const userRepositoryLayer = autoService(UserRepository, [Database, Logger]);
1894
+
1895
+ // ✅ Works - baseContainer has Logger, and we provide Database via layer
1896
+ const baseContainer = Container.empty().register(Logger, () => new Logger());
1897
+ const container = userRepositoryLayer
1898
+ .provide(databaseLayer)
1899
+ .register(baseContainer);
1900
+
1901
+ // ❌ Type error - baseContainer doesn't have Database
1902
+ const baseContainer2 = Container.empty().register(Logger, () => new Logger());
1903
+ const container2 = userRepositoryLayer.register(baseContainer2);
1904
+ // ❌ Error: Argument of type 'Conainer<ttypeof Logger>' is not assignable to parameter of type 'IContainer<ServiceTag<"Database", Database> | ServiceTag<"Logger", Logger>>'.
1905
+ ```
1906
+
1907
+ This compile-time checking ensures that all dependencies are satisfied before your code runs, preventing `UnknownDependencyError` at runtime.
1908
+
1909
+ ### Best Practices
1910
+
1911
+ **Always annotate layer<> type parameters manually:**
1912
+
1913
+ ```typescript
1914
+ // ✅ Good - explicit types
1915
+ const myLayer = layer<typeof Requirement, typeof Provision>((container) =>
1916
+ container.register(Provision, async (ctx) => {
1917
+ const req = await ctx.resolve(Requirement);
1918
+ return new Provision(req);
1919
+ })
1920
+ );
1921
+
1922
+ // ❌ Bad - inference is difficult/impossible
1923
+ const myLayer = layer((container) =>
1924
+ container.register(Provision, async (ctx) => {
1925
+ const req = await ctx.resolve(Requirement);
1926
+ return new Provision(req);
1927
+ })
1928
+ );
1929
+ ```
1930
+
1931
+ **Follow the types when composing layers:**
1932
+
1933
+ Start with the target layer, inspect its type to see requirements, then chain `.provide()` calls:
1934
+
1935
+ ```typescript
1936
+ // Start with what you need
1937
+ const userServiceLayer = service(UserService, ...);
1938
+ // Type: Layer<typeof Database | typeof Logger, typeof UserService>
1939
+ // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ requirements
1940
+
1941
+ // Provide those requirements
1942
+ const appLayer = userServiceLayer
1943
+ .provide(Layer.mergeAll(databaseLayer, loggerLayer));
1944
+ ```
1945
+
1946
+ **Define layers in the same file as the service class:**
1947
+
1948
+ ```typescript
1949
+ // user-repository.ts
1950
+ export class UserRepository extends Tag.Service('UserRepository') {
1951
+ constructor(private db: Database) {
1952
+ super();
1953
+ }
1954
+
1955
+ async findAll() {
1956
+ return this.db.query('SELECT * FROM users');
1957
+ }
1958
+ }
1959
+
1960
+ // Layer definition stays with the class
1961
+ export const userRepositoryLayer = autoService(UserRepository, [Database]);
1962
+ ```
1963
+
1964
+ This keeps related code together while keeping the service class decoupled from DI details.
1965
+
1966
+ **Resolve dependencies locally:**
1967
+
1968
+ When a module has internal dependencies, provide them within the module's layer to avoid leaking implementation details:
1969
+
1970
+ ```typescript
1971
+ // user-module/user-validator.ts
1972
+ export class UserValidator extends Tag.Service('UserValidator') {
1973
+ validate(user: User) {
1974
+ // Validation logic
1975
+ }
1976
+ }
1977
+
1978
+ export const userValidatorLayer = autoService(UserValidator, []);
1979
+ ```
1980
+
1981
+ ```typescript
1982
+ // user-module/user-notifier.ts
1983
+ export class UserNotifier extends Tag.Service('UserNotifier') {
1984
+ notify(user: User) {
1985
+ // Notification logic
1986
+ }
1987
+ }
1988
+
1989
+ export const userNotifierLayer = autoService(UserNotifier, []);
1990
+ ```
1991
+
1992
+ ```typescript
1993
+ // user-module/user-service.ts
1994
+ import { UserValidator, userValidatorLayer } from './user-validator.js';
1995
+ import { UserNotifier, userNotifierLayer } from './user-notifier.js';
1996
+
1997
+ // Public service - external consumers only see this
1998
+ export class UserService extends Tag.Service('UserService') {
1999
+ constructor(
2000
+ private validator: UserValidator, // Internal dependency
2001
+ private notifier: UserNotifier, // Internal dependency
2002
+ private db: Database // External dependency
2003
+ ) {
2004
+ super();
2005
+ }
2006
+
2007
+ async createUser(user: User) {
2008
+ this.validator.validate(user);
2009
+ await this.db.save(user);
2010
+ this.notifier.notify(user);
2011
+ }
2012
+ }
2013
+
2014
+ // Public layer - provides internal dependencies inline
2015
+ export const userServiceLayer = autoService(UserService, [
2016
+ UserValidator,
2017
+ UserNotifier,
2018
+ Database,
2019
+ ]).provide(Layer.mergeAll(userValidatorLayer, userNotifierLayer));
2020
+ // Type: Layer<typeof Database, typeof UserService>
2021
+
2022
+ // Consumers of this module only need to provide Database
2023
+ // UserValidator and UserNotifier are internal details
2024
+ ```
2025
+
2026
+ ```typescript
2027
+ // app.ts
2028
+ import { userServiceLayer } from './user-module/user-service.js';
2029
+
2030
+ // Only need to provide Database - internal dependencies already resolved
2031
+ const appLayer = userServiceLayer.provide(databaseLayer);
2032
+ ```
2033
+
2034
+ This pattern:
2035
+
2036
+ - **Encapsulates internal dependencies**: Consumers don't need to know about `UserValidator` or `UserNotifier`
2037
+ - **Reduces coupling**: Changes to internal dependencies don't affect consumers
2038
+ - **Simplifies usage**: Consumers only provide what the module actually needs externally
2039
+
2040
+ **Use provideMerge when you need access to intermediate services:**
2041
+
2042
+ ```typescript
2043
+ // Need both config and database in final container
2044
+ const infraLayer = databaseLayer.provideMerge(configLayer);
2045
+ // Type: Layer<never, typeof ConfigTag | typeof Database>
2046
+
2047
+ // vs. provide hides config
2048
+ const infraLayer = databaseLayer.provide(configLayer);
2049
+ // Type: Layer<never, typeof Database> - ConfigTag not accessible
2050
+ ```
2051
+
2052
+ **Prefer autoService for simple cases:**
2053
+
2054
+ ```typescript
2055
+ // ✅ Simple and clear
2056
+ const userServiceLayer = autoService(UserService, [Database, Logger]);
2057
+
2058
+ // ❌ Verbose for simple case
2059
+ const userServiceLayer = service(UserService, async (ctx) => {
2060
+ const [db, logger] = await ctx.resolveAll(Database, Logger);
2061
+ return new UserService(db, logger);
2062
+ });
2063
+ ```
2064
+
2065
+ But use `service()` when you need custom logic:
2066
+
2067
+ ```typescript
2068
+ // ✅ Good - custom initialization logic
2069
+ const databaseLayer = service(Database, {
2070
+ create: async () => {
2071
+ const db = new Database();
2072
+ await db.connect();
2073
+ await db.runMigrations();
2074
+ return db;
2075
+ },
2076
+ cleanup: (db) => db.disconnect(),
2077
+ });
2078
+ ```
2079
+
2080
+ ## Scope Management
2081
+
2082
+ 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.).
2083
+
2084
+ ### When to Use Scopes
2085
+
2086
+ Use scoped containers when you have dependencies with different lifecycles:
2087
+
2088
+ **Web servers**: Application-level services (database pool, config) vs. request-level services (request context, user session)
2089
+
2090
+ **Serverless functions**: Function-level services (logger, metrics) vs. invocation-level services (event context, request ID)
2091
+
2092
+ **Background jobs**: Worker-level services (job queue, database) vs. job-level services (job context, transaction)
2093
+
2094
+ ### Creating Scoped Containers
2095
+
2096
+ Use `ScopedContainer.empty()` to create a root scope:
2097
+
2098
+ ```typescript
2099
+ import { ScopedContainer, Tag } from 'sandly';
2100
+
2101
+ class Database extends Tag.Service('Database') {
2102
+ query(sql: string) {
2103
+ return [];
2104
+ }
2105
+ }
2106
+
2107
+ // Create root scope with application-level services
2108
+ const appContainer = ScopedContainer.empty('app').register(
2109
+ Database,
2110
+ () => new Database()
2111
+ );
2112
+ ```
2113
+
2114
+ The scope identifier (`'app'`) is used for debugging and has no runtime behavior.
2115
+
2116
+ ### Child Scopes
2117
+
2118
+ Create child scopes using `.child()`:
2119
+
2120
+ ```typescript
2121
+ class RequestContext extends Tag.Service('RequestContext') {
2122
+ constructor(public requestId: string, public userId: string) {
2123
+ super();
2124
+ }
2125
+ }
2126
+
2127
+ function handleRequest(requestId: string, userId: string) {
2128
+ // Create child scope for each request
2129
+ const requestScope = appContainer.child('request')
2130
+ // Register request-specific services
2131
+ .register(RequestContext, () =>
2132
+ new RequestContext(requestId, userId)
2133
+ )
2134
+ );
2135
+
2136
+ // Child can access parent services
2137
+ const db = await requestScope.resolve(Database); // From parent
2138
+ const ctx = await requestScope.resolve(RequestContext); // From child
2139
+
2140
+ // Clean up request scope when done
2141
+ await requestScope.destroy();
2142
+ }
2143
+ ```
2144
+
2145
+ ### Scope Resolution Rules
2146
+
2147
+ When resolving a dependency, scoped containers follow these rules:
2148
+
2149
+ 1. **Check current scope cache**: If already instantiated in this scope, return it
2150
+ 2. **Check current scope factory**: If registered in this scope, create and cache it here
2151
+ 3. **Delegate to parent**: If not in current scope, try parent scope
2152
+ 4. **Throw error**: If not found in any scope, throw `UnknownDependencyError`
2153
+
2154
+ ```typescript
2155
+ const appScope = ScopedContainer.empty('app').register(
2156
+ Database,
2157
+ () => new Database()
2158
+ );
2159
+
2160
+ const requestScope = appScope
2161
+ .child('request')
2162
+ .register(RequestContext, () => new RequestContext());
2163
+
2164
+ // Resolving Database from requestScope:
2165
+ // 1. Not in requestScope cache
2166
+ // 2. Not in requestScope factory
2167
+ // 3. Delegate to appScope -> found and cached in appScope
2168
+ await requestScope.resolve(Database); // Returns Database from appScope
2169
+
2170
+ // Resolving RequestContext from requestScope:
2171
+ // 1. Not in requestScope cache
2172
+ // 2. Found in requestScope factory -> create and cache in requestScope
2173
+ await requestScope.resolve(RequestContext); // Returns RequestContext from requestScope
2174
+ ```
2175
+
2176
+ ### Complete Web Server Example
2177
+
2178
+ Here's a realistic Express.js application with scoped containers:
2179
+
2180
+ ```typescript
2181
+ import express from 'express';
2182
+ import { ScopedContainer, Tag, autoService } from 'sandly';
2183
+
2184
+ // ============ Application-Level Services ============
2185
+ class Database extends Tag.Service('Database') {
2186
+ async query(sql: string) {
2187
+ // Real database query
2188
+ return [];
2189
+ }
2190
+ }
2191
+
2192
+ class Logger extends Tag.Service('Logger') {
2193
+ log(message: string) {
2194
+ console.log(`[${new Date().toISOString()}] ${message}`);
2195
+ }
2196
+ }
2197
+
2198
+ // ============ Request-Level Services ============
2199
+ class RequestContext extends Tag.Service('RequestContext') {
2200
+ constructor(
2201
+ public requestId: string,
2202
+ public userId: string | null,
2203
+ public startTime: number
2204
+ ) {
2205
+ super();
2206
+ }
2207
+
2208
+ getDuration() {
2209
+ return Date.now() - this.startTime;
2210
+ }
2211
+ }
2212
+
2213
+ class UserSession extends Tag.Service('UserSession') {
2214
+ constructor(
2215
+ private ctx: RequestContext,
2216
+ private db: Database,
2217
+ private logger: Logger
2218
+ ) {
2219
+ super();
2220
+ }
2221
+
2222
+ async getCurrentUser() {
2223
+ if (!this.ctx.userId) {
2224
+ return null;
2225
+ }
2226
+
2227
+ this.logger.log(`Fetching user ${this.ctx.userId}`);
2228
+ const users = await this.db.query(
2229
+ `SELECT * FROM users WHERE id = '${this.ctx.userId}'`
2230
+ );
2231
+ return users[0] || null;
2232
+ }
2233
+ }
2234
+
2235
+ // ============ Setup Application Container ============
2236
+ const appContainer = ScopedContainer.empty('app')
2237
+ .register(Database, () => new Database())
2238
+ .register(Logger, () => new Logger());
2239
+
2240
+ // ============ Express Middleware ============
2241
+ const app = express();
2242
+
2243
+ // Store request scope in res.locals
2244
+ app.use((req, res, next) => {
2245
+ const requestId = crypto.randomUUID();
2246
+ const userId = req.headers['user-id'] as string | undefined;
2247
+
2248
+ // Create child scope for this request
2249
+ const requestScope = appContainer.child(`request-${requestId}`);
2250
+
2251
+ // Register request-specific services
2252
+ requestScope
2253
+ .register(
2254
+ RequestContext,
2255
+ () => new RequestContext(requestId, userId || null, Date.now())
2256
+ )
2257
+ .register(
2258
+ UserSession,
2259
+ async (ctx) =>
2260
+ new UserSession(
2261
+ await ctx.resolve(RequestContext),
2262
+ await ctx.resolve(Database),
2263
+ await ctx.resolve(Logger)
2264
+ )
2265
+ );
2266
+
2267
+ // Store scope for use in route handlers
2268
+ res.locals.scope = requestScope;
2269
+
2270
+ // Clean up scope when response finishes
2271
+ res.on('finish', async () => {
2272
+ await requestScope.destroy();
2273
+ });
2274
+
2275
+ next();
2276
+ });
2277
+
2278
+ // ============ Route Handlers ============
2279
+ app.get('/api/user', async (req, res) => {
2280
+ const scope: ScopedContainer<typeof UserSession> = res.locals.scope;
2281
+
2282
+ const session = await scope.resolve(UserSession);
2283
+ const user = await session.getCurrentUser();
2284
+
2285
+ if (!user) {
2286
+ res.status(401).json({ error: 'Unauthorized' });
2287
+ return;
2288
+ }
2289
+
2290
+ res.json({ user });
2291
+ });
2292
+
2293
+ app.get('/api/stats', async (req, res) => {
2294
+ const scope: ScopedContainer<typeof RequestContext | typeof Database> =
2295
+ res.locals.scope;
2296
+
2297
+ const ctx = await scope.resolve(RequestContext);
2298
+ const db = await scope.resolve(Database);
2299
+
2300
+ const stats = await db.query('SELECT COUNT(*) FROM users');
2301
+
2302
+ res.json({
2303
+ stats,
2304
+ requestId: ctx.requestId,
2305
+ duration: ctx.getDuration(),
2306
+ });
2307
+ });
2308
+
2309
+ // ============ Start Server ============
2310
+ const PORT = 3000;
2311
+ app.listen(PORT, () => {
2312
+ console.log(`Server running on port ${PORT}`);
2313
+ });
2314
+
2315
+ // ============ Graceful Shutdown ============
2316
+ process.on('SIGTERM', async () => {
2317
+ console.log('Shutting down...');
2318
+ await appContainer.destroy();
2319
+ process.exit(0);
2320
+ });
2321
+ ```
2322
+
2323
+ ### Serverless Function Example
2324
+
2325
+ Scoped containers work perfectly for serverless functions where each invocation should have isolated state:
2326
+
2327
+ ```typescript
2328
+ import { ScopedContainer, Tag } from 'sandly';
2329
+ import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
2330
+
2331
+ // ============ Function-Level Services (shared across invocations) ============
2332
+ class Logger extends Tag.Service('Logger') {
2333
+ log(level: string, message: string) {
2334
+ console.log(JSON.stringify({ level, message, timestamp: Date.now() }));
2335
+ }
2336
+ }
2337
+
2338
+ class DynamoDB extends Tag.Service('DynamoDB') {
2339
+ async get(table: string, key: string) {
2340
+ // AWS SDK call
2341
+ return {};
2342
+ }
2343
+ }
2344
+
2345
+ // ============ Invocation-Level Services (per Lambda invocation) ============
2346
+ const EventContextTag = Tag.of('EventContext')<APIGatewayProxyEvent>();
2347
+ const InvocationIdTag = Tag.of('InvocationId')<string>();
2348
+
2349
+ class RequestProcessor extends Tag.Service('RequestProcessor') {
2350
+ constructor(
2351
+ private event: Inject<typeof EventContextTag>,
2352
+ private invocationId: Inject<typeof InvocationIdTag>,
2353
+ private db: DynamoDB,
2354
+ private logger: Logger
2355
+ ) {
2356
+ super();
2357
+ }
2358
+
2359
+ async process() {
2360
+ this.logger.log('info', `Processing ${this.invocationId}`);
2361
+
2362
+ const userId = this.event.pathParameters?.userId;
2363
+ if (!userId) {
2364
+ return { statusCode: 400, body: 'Missing userId' };
2365
+ }
2366
+
2367
+ const user = await this.db.get('users', userId);
2368
+ return { statusCode: 200, body: JSON.stringify(user) };
2369
+ }
2370
+ }
2371
+
2372
+ // ============ Initialize Function-Level Container (cold start) ============
2373
+ const functionContainer = ScopedContainer.empty('function')
2374
+ .register(Logger, () => new Logger())
2375
+ .register(DynamoDB, () => new DynamoDB());
2376
+
2377
+ // ============ Lambda Handler ============
2378
+ export async function handler(
2379
+ event: APIGatewayProxyEvent
2380
+ ): Promise<APIGatewayProxyResult> {
2381
+ const invocationId = crypto.randomUUID();
2382
+
2383
+ // Create invocation scope
2384
+ const invocationScope = functionContainer.child(
2385
+ `invocation-${invocationId}`
2386
+ );
2387
+
2388
+ try {
2389
+ // Register invocation-specific context
2390
+ invocationScope
2391
+ .register(EventContextTag, () => event)
2392
+ .register(InvocationIdTag, () => invocationId)
2393
+ .register(
2394
+ RequestProcessor,
2395
+ async (ctx) =>
2396
+ new RequestProcessor(
2397
+ await ctx.resolve(EventContextTag),
2398
+ await ctx.resolve(InvocationIdTag),
2399
+ await ctx.resolve(DynamoDB),
2400
+ await ctx.resolve(Logger)
2401
+ )
2402
+ );
2403
+
2404
+ // Process request
2405
+ const processor = await invocationScope.resolve(RequestProcessor);
2406
+ const result = await processor.process();
2407
+
2408
+ return result;
2409
+ } finally {
2410
+ // Clean up invocation scope
2411
+ await invocationScope.destroy();
2412
+ }
2413
+ }
2414
+ ```
2415
+
2416
+ ### Scope Destruction Order
2417
+
2418
+ When a scope is destroyed, finalizers run in this order:
2419
+
2420
+ 1. **Child scopes first**: All child scopes are destroyed before the parent
2421
+ 2. **Concurrent finalizers**: Within a scope, finalizers run concurrently
2422
+ 3. **Parent scope last**: Parent finalizers run after all children are cleaned up
2423
+
2424
+ ```typescript
2425
+ const appScope = ScopedContainer.empty('app').register(Database, {
2426
+ create: () => new Database(),
2427
+ cleanup: (db) => {
2428
+ console.log('Closing database');
2429
+ return db.close();
2430
+ },
2431
+ });
2432
+
2433
+ const request1 = appScope.child('request-1').register(RequestContext, {
2434
+ create: () => new RequestContext('req-1'),
2435
+ cleanup: (ctx) => {
2436
+ console.log('Cleaning up request-1');
2437
+ },
2438
+ });
2439
+
2440
+ const request2 = appScope.child('request-2').register(RequestContext, {
2441
+ create: () => new RequestContext('req-2'),
2442
+ cleanup: (ctx) => {
2443
+ console.log('Cleaning up request-2');
2444
+ },
2445
+ });
2446
+
2447
+ // Destroy parent scope
2448
+ await appScope.destroy();
2449
+ // Output:
2450
+ // Cleaning up request-1
2451
+ // Cleaning up request-2
2452
+ // Closing database
2453
+ ```
2454
+
2455
+ ### Scope Lifecycle Best Practices
2456
+
2457
+ **Always destroy child scopes**: Failing to destroy child scopes causes memory leaks:
2458
+
2459
+ ```typescript
2460
+ // ❌ Bad - memory leak
2461
+ app.use((req, res, next) => {
2462
+ const requestScope = appContainer.child('request');
2463
+ res.locals.scope = requestScope;
2464
+ next();
2465
+ // Scope never destroyed!
2466
+ });
2467
+
2468
+ // ✅ Good - proper cleanup
2469
+ app.use((req, res, next) => {
2470
+ const requestScope = appContainer.child('request');
2471
+ res.locals.scope = requestScope;
2472
+
2473
+ res.on('finish', async () => {
2474
+ await requestScope.destroy();
2475
+ });
2476
+
2477
+ next();
2478
+ });
2479
+ ```
2480
+
2481
+ **Use try-finally for cleanup**: Ensure scopes are destroyed even if errors occur:
2482
+
2483
+ ```typescript
2484
+ // ✅ Good - cleanup guaranteed
2485
+ async function processRequest() {
2486
+ const requestScope = appContainer.child('request');
2487
+
2488
+ try {
2489
+ // Process request
2490
+ const result = await requestScope.resolve(RequestProcessor);
2491
+ return await result.process();
2492
+ } finally {
2493
+ // Always cleanup, even on error
2494
+ await requestScope.destroy();
2495
+ }
2496
+ }
2497
+ ```
2498
+
2499
+ **Don't share scopes across async boundaries**: Each context should have its own scope:
2500
+
2501
+ ```typescript
2502
+ // ❌ Bad - scope shared across requests
2503
+ const sharedScope = appContainer.child('shared');
2504
+
2505
+ app.get('/api/user', async (req, res) => {
2506
+ const service = await sharedScope.resolve(UserService);
2507
+ // Multiple requests share the same scope - potential data leaks!
2508
+ });
2509
+
2510
+ // ✅ Good - scope per request
2511
+ app.get('/api/user', async (req, res) => {
2512
+ const requestScope = appContainer.child('request');
2513
+ const service = await requestScope.resolve(UserService);
2514
+ // Each request gets isolated scope
2515
+ await requestScope.destroy();
2516
+ });
2517
+ ```
2518
+
2519
+ **Register request-scoped services in parent scope when possible**: If services don't need request-specific data, register them once:
2520
+
2521
+ ```typescript
2522
+ // ❌ Suboptimal - registering service definition per request
2523
+ app.use((req, res, next) => {
2524
+ const requestScope = appContainer.child('request');
2525
+
2526
+ // UserService factory defined repeatedly
2527
+ requestScope.register(
2528
+ UserService,
2529
+ async (ctx) => new UserService(await ctx.resolve(Database))
2530
+ );
2531
+
2532
+ next();
2533
+ });
2534
+
2535
+ // ✅ Better - register service definition once, instantiate per request
2536
+ const appContainer = ScopedContainer.empty('app')
2537
+ .register(Database, () => new Database())
2538
+ .register(
2539
+ UserService,
2540
+ async (ctx) => new UserService(await ctx.resolve(Database))
2541
+ );
2542
+
2543
+ app.use((req, res, next) => {
2544
+ const requestScope = appContainer.child('request');
2545
+ // UserService factory already registered in parent
2546
+ // First resolve in requestScope will create instance
2547
+ next();
2548
+ });
2549
+ ```
2550
+
2551
+ **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.
2552
+
2553
+ ### Combining Scopes with Layers
2554
+
2555
+ You can apply layers to scoped containers just like regular containers:
2556
+
2557
+ ```typescript
2558
+ import { ScopedContainer, Layer, autoService } from 'sandly';
2559
+
2560
+ // Define layers
2561
+ const databaseLayer = autoService(Database, []);
2562
+ const loggerLayer = autoService(Logger, []);
2563
+ const infraLayer = Layer.mergeAll(databaseLayer, loggerLayer);
2564
+
2565
+ // Apply layers to scoped container
2566
+ const appContainer = infraLayer.register(ScopedContainer.empty('app'));
2567
+
2568
+ // Create child scopes as needed
2569
+ const requestScope = appContainer.child('request');
2570
+ ```
2571
+
2572
+ This combines the benefits of:
2573
+
2574
+ - **Layers**: Composable, reusable dependency definitions
2575
+ - **Scopes**: Hierarchical lifetime management
2576
+
2577
+ ## Comparison with Alternatives
2578
+
2579
+ ### vs NestJS
2580
+
2581
+ **NestJS**:
2582
+
2583
+ - **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.
2584
+ - **Decorator-Based**: Uses experimental decorators which are being deprecated in favor of the new TypeScript standard.
2585
+ - **Framework Lock-In**: Tightly coupled to the NestJS framework. You can't use the DI system independently.
2586
+ - **Heavy**: Pulls in many dependencies and runtime overhead.
2587
+
2588
+ **Sandly**:
2589
+
2590
+ - **Full Type Safety**: Compile-time validation of your entire dependency graph.
2591
+ - **No Decorators**: Uses standard TypeScript without experimental features.
2592
+ - **Framework-Agnostic**: Works with any TypeScript project (Express, Fastify, plain Node.js, serverless, etc.).
2593
+ - **Lightweight**: Zero runtime dependencies, minimal overhead.
2594
+
2595
+ ### vs InversifyJS
2596
+
2597
+ **InversifyJS**:
2598
+
2599
+ - **Complex API**: Requires learning container binding DSL, identifiers, and numerous decorators.
2600
+ - **Decorator-heavy**: Relies heavily on experimental decorators.
2601
+ - **No async factory support**: Doesn't support async dependency creation out of the box.
2602
+ - **Weak type inference**: Type safety requires manual type annotations everywhere.
2603
+
2604
+ **Sandly**:
2605
+
2606
+ - **Simple API**: Clean, minimal API surface. Tags, containers, and layers.
2607
+ - **No decorators**: Standard TypeScript classes and functions.
2608
+ - **Async first**: Native support for async factories and finalizers.
2609
+ - **Strong type inference**: Types are automatically inferred from your code.
2610
+
2611
+ ```typescript
2612
+ // InversifyJS - Complex and decorator-heavy
2613
+ const TYPES = {
2614
+ Database: Symbol.for('Database'),
2615
+ UserService: Symbol.for('UserService'),
2616
+ };
2617
+
2618
+ @injectable()
2619
+ class UserService {
2620
+ constructor(@inject(TYPES.Database) private db: Database) {}
2621
+ }
2622
+
2623
+ container.bind<Database>(TYPES.Database).to(Database).inSingletonScope();
2624
+ container.bind<UserService>(TYPES.UserService).to(UserService);
2625
+
2626
+ // Sandly - Simple and type-safe
2627
+ class UserService extends Tag.Service('UserService') {
2628
+ constructor(private db: Database) {
2629
+ super();
2630
+ }
2631
+ }
2632
+
2633
+ const container = Container.empty()
2634
+ .register(Database, () => new Database())
2635
+ .register(
2636
+ UserService,
2637
+ async (ctx) => new UserService(await ctx.resolve(Database))
2638
+ );
2639
+ ```
2640
+
2641
+ ### vs TSyringe
2642
+
2643
+ **TSyringe**:
2644
+
2645
+ - **Decorator-based**: Uses experimental `reflect-metadata` and decorators.
2646
+ - **No type-safe container**: The container doesn't track what's registered. Easy to request unregistered dependencies and only find out at runtime.
2647
+ - **No async support**: Factories must be synchronous.
2648
+ - **Global container**: Relies on a global container which makes testing harder.
2649
+
2650
+ **Sandly**:
2651
+
2652
+ - **No decorators**: Standard TypeScript, no experimental features.
2653
+ - **Type-Safe container**: Container tracks all registered services. TypeScript prevents requesting unregistered dependencies.
2654
+ - **Full async support**: Factories and finalizers can be async.
2655
+ - **Explicit containers**: Create and manage containers explicitly for better testability and scope management.
2656
+
2657
+ ```typescript
2658
+ // TSyringe - Global container, no compile-time safety
2659
+ @injectable()
2660
+ class UserService {
2661
+ constructor(@inject('Database') private db: Database) {}
2662
+ }
2663
+
2664
+ container.register('Database', { useClass: Database });
2665
+ container.register('UserService', { useClass: UserService });
2666
+
2667
+ // Will compile but fail at runtime if 'Database' wasn't registered
2668
+ const service = container.resolve('UserService');
2669
+
2670
+ // Sandly - Type-safe, explicit
2671
+ const container = Container.empty()
2672
+ .register(Database, () => new Database())
2673
+ .register(
2674
+ UserService,
2675
+ async (ctx) => new UserService(await ctx.resolve(Database))
2676
+ );
2677
+
2678
+ // Won't compile if Database isn't registered
2679
+ const service = await container.resolve(UserService); // Type-safe
2680
+ ```
2681
+
2682
+ ### vs Effect-TS
2683
+
2684
+ **Effect-TS**:
2685
+
2686
+ - **Steep learning curve**: Requires learning functional programming concepts, Effect type, generators, and extensive API.
2687
+ - **All-or-nothing**: Designed as a complete effect system. Hard to adopt incrementally.
2688
+ - **Functional programming**: Uses FP paradigms which may not fit all teams or codebases.
2689
+ - **Large bundle**: Comprehensive framework with significant bundle size.
2690
+
2691
+ **Sandly**:
2692
+
2693
+ - **Easy to learn**: Simple, familiar API. If you know TypeScript classes, you're ready to use Sandly.
2694
+ - **Incremental adoption**: Add DI to existing codebases without major refactoring.
2695
+ - **Pragmatic**: Works with standard OOP and functional styles.
2696
+ - **Minimal size**: Tiny library focused on DI only.
2697
+
2698
+ **Similarities with Effect**:
2699
+
2700
+ - Both provide full type safety for dependency management
2701
+ - Both use the concept of layers for composable dependency graphs
2702
+ - Both support complete async lifecycle management and scope management
2703
+
2704
+ **When to choose Effect**: If you want a complete effect system with error handling, concurrency, streams, and are comfortable with FP paradigms.
2705
+
2706
+ **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.
2707
+
2708
+ ### Feature Comparison Table
2709
+
2710
+ | Feature | Sandly | NestJS | InversifyJS | TSyringe | Effect-TS |
2711
+ | -------------------------- | ------- | ---------- | --------------------- | -------- | --------- |
2712
+ | Compile-time type safety | ✅ Full | ❌ None | ⚠️ Partial | ❌ None | ✅ Full |
2713
+ | No experimental decorators | ✅ | ❌ | ❌ | ❌ | ✅ |
2714
+ | Async lifecycle methods | ✅ | ✅ | ❌ | ❌ | ✅ |
2715
+ | Framework-agnostic | ✅ | ❌ | ✅ | ✅ | ✅ |
2716
+ | Learning curve | Low | Medium | Medium | Low | Very High |
2717
+ | Bundle size | Small | Large | Medium | Small | Large |
2718
+ | Custom scopes | ✅ | ⚠️ Limited | ⚠️ Request scope only | ❌ | ✅ |
2719
+ | Layer composition | ✅ | ❌ | ❌ | ❌ | ✅ |
2720
+ | Zero dependencies | ✅ | ❌ | ❌ | ❌ | ❌ |
2721
+
2722
+ ### Why Choose Sandly?
201
2723
 
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
2724
+ Choose Sandly if you want:
208
2725
 
209
- ## License
2726
+ - **Type safety** without sacrificing developer experience
2727
+ - **Dependency injection** without the need for experimental features that won't be supported in the future
2728
+ - **Clean architecture** with layers and composable modules
2729
+ - **Async support** for real-world scenarios (database connections, API clients, etc.)
2730
+ - **Testing-friendly** design with easy mocking and isolation
2731
+ - **Incremental adoption** in existing codebases
2732
+ - **Zero runtime dependencies** and minimal overhead
210
2733
 
211
- MIT
2734
+ 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.