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