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.
- package/README.md +2646 -123
- package/package.json +1 -2
package/README.md
CHANGED
|
@@ -1,211 +1,2734 @@
|
|
|
1
1
|
# Sandly
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7
|
+
## Why Sandly?
|
|
8
8
|
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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 {
|
|
135
|
+
import { Container, Tag } from 'sandly';
|
|
19
136
|
|
|
20
|
-
class
|
|
21
|
-
|
|
22
|
-
return
|
|
137
|
+
class CacheService extends Tag.Service('CacheService') {
|
|
138
|
+
get(key: string) {
|
|
139
|
+
return null;
|
|
23
140
|
}
|
|
24
141
|
}
|
|
25
142
|
|
|
26
|
-
|
|
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
|
-
//
|
|
29
|
-
const
|
|
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
|
-
//
|
|
32
|
-
|
|
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
|
-
|
|
204
|
+
For large applications, organizing dependencies into layers helps manage complexity and makes dependencies composable.
|
|
38
205
|
|
|
39
206
|
```typescript
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
46
|
-
|
|
47
|
-
(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
//
|
|
55
|
-
const
|
|
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
|
-
|
|
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
|
-
###
|
|
300
|
+
### Async Lifecycle Management
|
|
61
301
|
|
|
62
|
-
|
|
302
|
+
Both service creation and cleanup can be asynchronous:
|
|
63
303
|
|
|
64
304
|
```typescript
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
440
|
+
### Easy Testing
|
|
89
441
|
|
|
90
|
-
|
|
442
|
+
Create test containers with real or mocked services:
|
|
91
443
|
|
|
92
444
|
```typescript
|
|
93
|
-
import {
|
|
445
|
+
import { Container, Tag } from 'sandly';
|
|
94
446
|
|
|
95
447
|
class EmailService extends Tag.Service('EmailService') {
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
454
|
+
constructor(private email: EmailService) {
|
|
103
455
|
super();
|
|
104
456
|
}
|
|
105
457
|
|
|
106
|
-
async
|
|
107
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
529
|
+
const repo = await container.resolve(UserRepository);
|
|
530
|
+
await container.destroy(); // Clean up
|
|
120
531
|
```
|
|
121
532
|
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
//
|
|
134
|
-
const
|
|
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
|
-
|
|
626
|
+
The factory receives a resolution context for injecting dependencies:
|
|
138
627
|
|
|
139
628
|
```typescript
|
|
140
|
-
|
|
141
|
-
|
|
629
|
+
class Database extends Tag.Service('Database') {
|
|
630
|
+
query(sql: string) {
|
|
631
|
+
return [];
|
|
632
|
+
}
|
|
633
|
+
}
|
|
142
634
|
|
|
143
|
-
class
|
|
635
|
+
class UserRepository extends Tag.Service('UserRepository') {
|
|
144
636
|
constructor(
|
|
145
|
-
private
|
|
146
|
-
private
|
|
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
|
|
153
|
-
.register(
|
|
154
|
-
.register(
|
|
155
|
-
.register(
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
659
|
+
#### Value Tags (Non-Classes)
|
|
660
|
+
|
|
661
|
+
Register values using `Tag.of()`:
|
|
166
662
|
|
|
167
663
|
```typescript
|
|
168
|
-
|
|
169
|
-
const
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
667
|
+
interface AppConfig {
|
|
668
|
+
apiKey: string;
|
|
669
|
+
timeout: number;
|
|
670
|
+
}
|
|
671
|
+
const ConfigTag = Tag.of('app.config')<AppConfig>();
|
|
176
672
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
);
|
|
682
|
+
### Resolving Dependencies
|
|
683
|
+
|
|
684
|
+
Use `resolve()` to get a service instance:
|
|
196
685
|
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|