sandly 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +314 -2624
- package/dist/index.d.ts +645 -1557
- package/dist/index.js +522 -921
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,35 +1,46 @@
|
|
|
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
|
+
Type-safe dependency injection 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';
|
|
13
11
|
|
|
14
|
-
class
|
|
15
|
-
|
|
16
|
-
return ['alice', 'bob'];
|
|
17
|
-
}
|
|
12
|
+
class Database {
|
|
13
|
+
query(sql: string) { return []; }
|
|
18
14
|
}
|
|
19
15
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
16
|
+
class UserService {
|
|
17
|
+
constructor(private db: Database) {}
|
|
18
|
+
getUsers() { return this.db.query('SELECT * FROM users'); }
|
|
19
|
+
}
|
|
24
20
|
|
|
25
|
-
//
|
|
21
|
+
// Define layers
|
|
22
|
+
const dbLayer = Layer.service(Database, []);
|
|
23
|
+
const userLayer = Layer.service(UserService, [Database]);
|
|
24
|
+
|
|
25
|
+
// Compose and create container
|
|
26
|
+
const container = Container.from(userLayer.provide(dbLayer));
|
|
27
|
+
|
|
28
|
+
// TypeScript knows UserService is available
|
|
26
29
|
const users = await container.resolve(UserService);
|
|
27
30
|
|
|
28
|
-
//
|
|
31
|
+
// TypeScript error - OrderService not registered
|
|
29
32
|
const orders = await container.resolve(OrderService);
|
|
30
|
-
// Error: Argument of type 'typeof OrderService' is not assignable to parameter of type 'typeof UserService'
|
|
31
33
|
```
|
|
32
34
|
|
|
35
|
+
**Key features:**
|
|
36
|
+
|
|
37
|
+
- **Compile-time safety**: TypeScript catches missing dependencies before runtime
|
|
38
|
+
- **No decorators**: Works with standard TypeScript, no experimental features
|
|
39
|
+
- **Async support**: Factories and cleanup functions can be async
|
|
40
|
+
- **Composable layers**: Organize dependencies into reusable modules
|
|
41
|
+
- **Scoped containers**: Hierarchical dependency management for web servers
|
|
42
|
+
- **Zero dependencies**: Tiny library with no runtime overhead
|
|
43
|
+
|
|
33
44
|
## Installation
|
|
34
45
|
|
|
35
46
|
```bash
|
|
@@ -40,2746 +51,425 @@ pnpm add sandly
|
|
|
40
51
|
yarn add sandly
|
|
41
52
|
```
|
|
42
53
|
|
|
43
|
-
|
|
54
|
+
Requires TypeScript 5.0+.
|
|
44
55
|
|
|
45
56
|
## Quick Start
|
|
46
57
|
|
|
47
|
-
Here's a complete example showing dependency injection with automatic cleanup:
|
|
48
|
-
|
|
49
|
-
```typescript
|
|
50
|
-
import { Container, Tag } from 'sandly';
|
|
51
|
-
|
|
52
|
-
// Define services using Tag.Service
|
|
53
|
-
class Database extends Tag.Service('Database') {
|
|
54
|
-
async query(sql: string) {
|
|
55
|
-
console.log(`Executing: ${sql}`);
|
|
56
|
-
return [{ id: 1, name: 'Alice' }];
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
async close() {
|
|
60
|
-
console.log('Database connection closed');
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
class UserRepository extends Tag.Service('UserRepository') {
|
|
65
|
-
constructor(private db: Database) {
|
|
66
|
-
super();
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
async findAll() {
|
|
70
|
-
return this.db.query('SELECT * FROM users');
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Register services with their factories
|
|
75
|
-
const container = Container.empty()
|
|
76
|
-
.register(Database, {
|
|
77
|
-
create: () => new Database(),
|
|
78
|
-
cleanup: (db) => db.close(), // Cleanup when container is destroyed
|
|
79
|
-
})
|
|
80
|
-
.register(
|
|
81
|
-
UserRepository,
|
|
82
|
-
async (ctx) => new UserRepository(await ctx.resolve(Database))
|
|
83
|
-
);
|
|
84
|
-
|
|
85
|
-
// Use the services
|
|
86
|
-
const userRepo = await container.resolve(UserRepository);
|
|
87
|
-
const users = await userRepo.findAll();
|
|
88
|
-
console.log(users); // [{ id: 1, name: 'Alice' }]
|
|
89
|
-
|
|
90
|
-
// Clean up all resources
|
|
91
|
-
await container.destroy(); // Calls db.close()
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
**Key concepts:**
|
|
95
|
-
|
|
96
|
-
- **Tags** identify dependencies. Use `Tag.Service()` for classes or `Tag.of()` for values.
|
|
97
|
-
- **Container** manages service instantiation and caching. Each service is created once (singleton).
|
|
98
|
-
- **Factories** create service instances and can resolve other dependencies via the resolution context.
|
|
99
|
-
- **Finalizers** (optional) clean up resources when the container is destroyed.
|
|
100
|
-
|
|
101
|
-
For larger applications, use **Layers** to organize dependencies into composable modules:
|
|
102
|
-
|
|
103
|
-
```typescript
|
|
104
|
-
import { layer, autoService, Container } from 'sandly';
|
|
105
|
-
|
|
106
|
-
// Layer that provides Database
|
|
107
|
-
const databaseLayer = layer<never, typeof Database>((container) =>
|
|
108
|
-
container.register(Database, {
|
|
109
|
-
create: () => new Database(),
|
|
110
|
-
cleanup: (db) => db.close(),
|
|
111
|
-
})
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
// Layer that provides UserRepository (depends on Database)
|
|
115
|
-
const userRepositoryLayer = autoService(UserRepository, [Database]);
|
|
116
|
-
|
|
117
|
-
// Compose layers - userRepositoryLayer.provide(databaseLayer) creates
|
|
118
|
-
// a complete layer with all dependencies satisfied
|
|
119
|
-
const appLayer = userRepositoryLayer.provide(databaseLayer);
|
|
120
|
-
|
|
121
|
-
// Apply to container
|
|
122
|
-
const container = appLayer.register(Container.empty());
|
|
123
|
-
const userRepo = await container.resolve(UserRepository);
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
Continue reading to learn about all features including value tags, layer composition, and scope management.
|
|
127
|
-
|
|
128
|
-
## Main Features
|
|
129
|
-
|
|
130
|
-
### Type Safety
|
|
131
|
-
|
|
132
|
-
The container tracks registered dependencies in its generic type parameters, making it impossible to resolve unregistered dependencies.
|
|
133
|
-
|
|
134
58
|
```typescript
|
|
135
|
-
import { Container, Tag } from 'sandly';
|
|
59
|
+
import { Container, Layer, Tag } from 'sandly';
|
|
136
60
|
|
|
137
|
-
class
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
61
|
+
// Any class can be a dependency - no special base class needed
|
|
62
|
+
class Database {
|
|
63
|
+
async query(sql: string) {
|
|
64
|
+
return [{ id: 1, name: 'Alice' }];
|
|
65
|
+
}
|
|
66
|
+
async close() {
|
|
67
|
+
console.log('Database closed');
|
|
68
|
+
}
|
|
141
69
|
}
|
|
142
70
|
|
|
143
|
-
class
|
|
144
|
-
|
|
71
|
+
class UserRepository {
|
|
72
|
+
constructor(private db: Database) {}
|
|
73
|
+
findAll() {
|
|
74
|
+
return this.db.query('SELECT * FROM users');
|
|
75
|
+
}
|
|
145
76
|
}
|
|
146
77
|
|
|
147
|
-
//
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
);
|
|
152
|
-
// Type: Container<typeof CacheService>
|
|
78
|
+
// Create layers
|
|
79
|
+
const dbLayer = Layer.service(Database, [], {
|
|
80
|
+
cleanup: (db) => db.close()
|
|
81
|
+
});
|
|
153
82
|
|
|
154
|
-
|
|
155
|
-
const cache = await container.resolve(CacheService);
|
|
83
|
+
const userRepoLayer = Layer.service(UserRepository, [Database]);
|
|
156
84
|
|
|
157
|
-
//
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
// to parameter of type 'typeof CacheService'
|
|
161
|
-
```
|
|
85
|
+
// Compose layers and create container
|
|
86
|
+
const appLayer = userRepoLayer.provide(dbLayer);
|
|
87
|
+
const container = Container.from(appLayer);
|
|
162
88
|
|
|
163
|
-
|
|
89
|
+
// Use services
|
|
90
|
+
const repo = await container.resolve(UserRepository);
|
|
91
|
+
const users = await repo.findAll();
|
|
164
92
|
|
|
165
|
-
|
|
166
|
-
|
|
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);
|
|
93
|
+
// Clean up
|
|
94
|
+
await container.destroy();
|
|
174
95
|
```
|
|
175
96
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
```typescript
|
|
179
|
-
class UserService extends Tag.Service('UserService') {
|
|
180
|
-
constructor(
|
|
181
|
-
private cache: CacheService,
|
|
182
|
-
private email: EmailService
|
|
183
|
-
) {
|
|
184
|
-
super();
|
|
185
|
-
}
|
|
186
|
-
}
|
|
97
|
+
## Core Concepts
|
|
187
98
|
|
|
188
|
-
|
|
189
|
-
// and must return a value of the same type as the dependency
|
|
190
|
-
const container = Container.empty()
|
|
191
|
-
.register(CacheService, () => new CacheService())
|
|
192
|
-
.register(EmailService, () => new EmailService())
|
|
193
|
-
.register(UserService, async (ctx) => {
|
|
194
|
-
// ctx.resolve() only accepts CacheService or EmailService
|
|
195
|
-
return new UserService(
|
|
196
|
-
await ctx.resolve(CacheService),
|
|
197
|
-
await ctx.resolve(EmailService)
|
|
198
|
-
);
|
|
199
|
-
});
|
|
200
|
-
```
|
|
99
|
+
### Tags
|
|
201
100
|
|
|
202
|
-
|
|
101
|
+
Tags identify dependencies. There are two types:
|
|
203
102
|
|
|
204
|
-
|
|
103
|
+
**Classes as tags**: Any class constructor can be used directly as a tag:
|
|
205
104
|
|
|
206
105
|
```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
|
-
);
|
|
232
|
-
|
|
233
|
-
// Service layer - depends on database
|
|
234
|
-
class UserService extends Tag.Service('UserService') {
|
|
235
|
-
constructor(private db: Database) {
|
|
236
|
-
super();
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
async getUsers() {
|
|
240
|
-
return this.db.query('SELECT * FROM users');
|
|
241
|
-
}
|
|
106
|
+
class UserService {
|
|
107
|
+
getUsers() { return []; }
|
|
242
108
|
}
|
|
243
109
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
async (ctx) => new UserService(await ctx.resolve(Database))
|
|
247
|
-
);
|
|
248
|
-
|
|
249
|
-
// Or alternatively, using shorter syntax:
|
|
250
|
-
// const userServiceLayer = autoService(UserService, [Database]);
|
|
251
|
-
|
|
252
|
-
// Compose into complete application layer
|
|
253
|
-
// Dependencies flow: Config -> Database -> UserService
|
|
254
|
-
const appLayer = userServiceLayer.provide(databaseLayer).provide(configLayer);
|
|
255
|
-
|
|
256
|
-
// Apply to container - all dependencies satisfied
|
|
257
|
-
const container = appLayer.register(Container.empty());
|
|
258
|
-
const userService = await container.resolve(UserService);
|
|
110
|
+
// UserService is both the class and its tag
|
|
111
|
+
const layer = Layer.service(UserService, []);
|
|
259
112
|
```
|
|
260
113
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
### Flexible Dependency Values
|
|
264
|
-
|
|
265
|
-
Any value can be a dependency, not just class instances:
|
|
114
|
+
**ValueTags for non-class values**: Use `Tag.of()` for primitives, objects, or functions:
|
|
266
115
|
|
|
267
116
|
```typescript
|
|
268
|
-
import { Tag, constant, Container } from 'sandly';
|
|
269
|
-
|
|
270
|
-
// Primitive values
|
|
271
117
|
const PortTag = Tag.of('Port')<number>();
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
// Configuration objects
|
|
275
|
-
interface Config {
|
|
276
|
-
apiUrl: string;
|
|
277
|
-
timeout: number;
|
|
278
|
-
retries: number;
|
|
279
|
-
}
|
|
280
|
-
const ConfigTag = Tag.of('Config')<Config>();
|
|
281
|
-
|
|
282
|
-
// Even functions
|
|
283
|
-
type Logger = (msg: string) => void;
|
|
284
|
-
const LoggerTag = Tag.of('Logger')<Logger>();
|
|
285
|
-
|
|
286
|
-
const container = Container.empty()
|
|
287
|
-
.register(PortTag, () => 3000)
|
|
288
|
-
.register(DebugModeTag, () => process.env.NODE_ENV === 'development')
|
|
289
|
-
.register(ConfigTag, () => ({
|
|
290
|
-
apiUrl: 'https://api.example.com',
|
|
291
|
-
timeout: 5000,
|
|
292
|
-
retries: 3,
|
|
293
|
-
}))
|
|
294
|
-
.register(LoggerTag, () => (msg: string) => console.log(msg));
|
|
295
|
-
|
|
296
|
-
const port = await container.resolve(PortTag); // number
|
|
297
|
-
const config = await container.resolve(ConfigTag); // Config
|
|
298
|
-
```
|
|
299
|
-
|
|
300
|
-
### Async Lifecycle Management
|
|
301
|
-
|
|
302
|
-
Both service creation and cleanup can be asynchronous:
|
|
303
|
-
|
|
304
|
-
```typescript
|
|
305
|
-
import { Container, Tag } from 'sandly';
|
|
306
|
-
|
|
307
|
-
class DatabaseConnection extends Tag.Service('DatabaseConnection') {
|
|
308
|
-
private connection: any = null;
|
|
309
|
-
|
|
310
|
-
async connect() {
|
|
311
|
-
console.log('Connecting to database...');
|
|
312
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
313
|
-
this.connection = {
|
|
314
|
-
/* connection object */
|
|
315
|
-
};
|
|
316
|
-
console.log('Connected!');
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
async disconnect() {
|
|
320
|
-
console.log('Disconnecting from database...');
|
|
321
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
322
|
-
this.connection = null;
|
|
323
|
-
console.log('Disconnected!');
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
query(sql: string) {
|
|
327
|
-
if (!this.connection) throw new Error('Not connected');
|
|
328
|
-
return [];
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
const container = Container.empty().register(DatabaseConnection, {
|
|
333
|
-
create: async () => {
|
|
334
|
-
const db = new DatabaseConnection();
|
|
335
|
-
await db.connect(); // Async initialization
|
|
336
|
-
return db;
|
|
337
|
-
},
|
|
338
|
-
cleanup: async (db) => {
|
|
339
|
-
await db.disconnect(); // Async cleanup
|
|
340
|
-
},
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
// Use the service
|
|
344
|
-
const db = await container.resolve(DatabaseConnection);
|
|
345
|
-
await db.query('SELECT * FROM users');
|
|
118
|
+
const ConfigTag = Tag.of('Config')<{ apiUrl: string }>();
|
|
346
119
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
// Output:
|
|
350
|
-
// Disconnecting from database...
|
|
351
|
-
// Disconnected!
|
|
120
|
+
const portLayer = Layer.value(PortTag, 3000);
|
|
121
|
+
const configLayer = Layer.value(ConfigTag, { apiUrl: 'https://api.example.com' });
|
|
352
122
|
```
|
|
353
123
|
|
|
354
|
-
###
|
|
124
|
+
### Container
|
|
355
125
|
|
|
356
|
-
|
|
126
|
+
Containers manage dependency instantiation and lifecycle:
|
|
357
127
|
|
|
358
128
|
```typescript
|
|
359
|
-
|
|
129
|
+
// Create from layers (recommended)
|
|
130
|
+
const container = Container.from(appLayer);
|
|
360
131
|
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
132
|
+
// Or build manually
|
|
133
|
+
const container = Container.builder()
|
|
134
|
+
.add(Database, () => new Database())
|
|
135
|
+
.add(UserService, async (ctx) =>
|
|
136
|
+
new UserService(await ctx.resolve(Database))
|
|
137
|
+
)
|
|
138
|
+
.build();
|
|
367
139
|
|
|
368
|
-
//
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
super();
|
|
372
|
-
}
|
|
373
|
-
}
|
|
140
|
+
// Resolve dependencies
|
|
141
|
+
const db = await container.resolve(Database);
|
|
142
|
+
const [db, users] = await container.resolveAll(Database, UserService);
|
|
374
143
|
|
|
375
|
-
//
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
() => new Database()
|
|
144
|
+
// Use and discard pattern - resolves, runs callback, then destroys
|
|
145
|
+
const result = await container.use(UserService, (service) =>
|
|
146
|
+
service.getUsers()
|
|
379
147
|
);
|
|
380
148
|
|
|
381
|
-
//
|
|
382
|
-
|
|
383
|
-
const requestContainer = rootContainer.child('request');
|
|
384
|
-
|
|
385
|
-
requestContainer.register(
|
|
386
|
-
RequestContext,
|
|
387
|
-
() => new RequestContext(requestId)
|
|
388
|
-
);
|
|
389
|
-
|
|
390
|
-
const ctx = await requestContainer.resolve(RequestContext);
|
|
391
|
-
const db = await requestContainer.resolve(Database); // From parent scope
|
|
392
|
-
|
|
393
|
-
// Clean up request scope only
|
|
394
|
-
await requestContainer.destroy();
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// Each request gets isolated scope, but shares Database
|
|
398
|
-
await handleRequest('req-1');
|
|
399
|
-
await handleRequest('req-2');
|
|
400
|
-
```
|
|
401
|
-
|
|
402
|
-
### Performance & Developer Experience
|
|
403
|
-
|
|
404
|
-
**Zero runtime overhead for resolution**: Dependency resolution uses a simple `Map` lookup. Services are instantiated once and cached.
|
|
405
|
-
|
|
406
|
-
**No third-party dependencies**: The library has zero runtime dependencies, keeping your bundle size small.
|
|
407
|
-
|
|
408
|
-
**No experimental decorators**: Works with standard TypeScript - no special compiler flags or deprecated decorator metadata.
|
|
409
|
-
|
|
410
|
-
**IntelliSense works perfectly**: Because dependencies are tracked at the type level, your IDE knows exactly what's available:
|
|
411
|
-
|
|
412
|
-
```typescript
|
|
413
|
-
const container = Container.empty()
|
|
414
|
-
.register(Database, () => new Database())
|
|
415
|
-
.register(Cache, () => new Cache());
|
|
416
|
-
|
|
417
|
-
// IDE autocomplete shows: Database | Cache
|
|
418
|
-
await container.resolve(/* IDE suggests Database and Cache */);
|
|
419
|
-
```
|
|
420
|
-
|
|
421
|
-
**Lazy instantiation**: Services are only created when first resolved:
|
|
422
|
-
|
|
423
|
-
```typescript
|
|
424
|
-
const container = Container.empty()
|
|
425
|
-
.register(ExpensiveService, () => {
|
|
426
|
-
console.log('Creating expensive service...');
|
|
427
|
-
return new ExpensiveService();
|
|
428
|
-
})
|
|
429
|
-
.register(CheapService, () => {
|
|
430
|
-
console.log('Creating cheap service...');
|
|
431
|
-
return new CheapService();
|
|
432
|
-
});
|
|
433
|
-
|
|
434
|
-
// Nothing instantiated yet
|
|
435
|
-
await container.resolve(CheapService);
|
|
436
|
-
// Output: "Creating cheap service..."
|
|
437
|
-
// ExpensiveService never created unless resolved
|
|
438
|
-
```
|
|
439
|
-
|
|
440
|
-
### Easy Testing
|
|
441
|
-
|
|
442
|
-
Create test containers with real or mocked services:
|
|
443
|
-
|
|
444
|
-
```typescript
|
|
445
|
-
import { Container, Tag } from 'sandly';
|
|
446
|
-
|
|
447
|
-
class EmailService extends Tag.Service('EmailService') {
|
|
448
|
-
async send(to: string, body: string) {
|
|
449
|
-
/* real implementation */
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
class UserService extends Tag.Service('UserService') {
|
|
454
|
-
constructor(private email: EmailService) {
|
|
455
|
-
super();
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
async registerUser(email: string) {
|
|
459
|
-
await this.email.send(email, 'Welcome!');
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
// In the main application, create a live container with real EmailService
|
|
464
|
-
const liveContainer = Container.empty()
|
|
465
|
-
.register(EmailService, () => new EmailService())
|
|
466
|
-
.register(
|
|
467
|
-
UserService,
|
|
468
|
-
async (ctx) => new UserService(await ctx.resolve(EmailService))
|
|
469
|
-
);
|
|
470
|
-
|
|
471
|
-
// In the test, override EmailService with mock
|
|
472
|
-
const mockEmail = { send: vi.fn() };
|
|
473
|
-
|
|
474
|
-
const testContainer = liveContainer.register(EmailService, () => mockEmail);
|
|
475
|
-
|
|
476
|
-
const userService = await testContainer.resolve(UserService);
|
|
477
|
-
await userService.registerUser('test@example.com');
|
|
478
|
-
|
|
479
|
-
expect(mockEmail.send).toHaveBeenCalledWith('test@example.com', 'Welcome!');
|
|
149
|
+
// Manual clean up
|
|
150
|
+
await container.destroy();
|
|
480
151
|
```
|
|
481
152
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
Before diving into detailed usage, let's understand the four main building blocks of Sandly.
|
|
485
|
-
|
|
486
|
-
### Tags
|
|
487
|
-
|
|
488
|
-
Tags are unique tokens that represent dependencies and serve as a way to reference them in the container. They come in two flavors:
|
|
153
|
+
Each dependency is created once (singleton) and cached.
|
|
489
154
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
```typescript
|
|
493
|
-
class UserRepository extends Tag.Service('UserRepository') {
|
|
494
|
-
findUser(id: string) {
|
|
495
|
-
return { id, name: 'Alice' };
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
```
|
|
499
|
-
|
|
500
|
-
The class itself serves as both the tag and the implementation. The string identifier can be anything you want,
|
|
501
|
-
but the best practice is to use a descriptive name that is unique across your application.
|
|
155
|
+
### Layers
|
|
502
156
|
|
|
503
|
-
|
|
157
|
+
Layers are composable units of dependency registrations:
|
|
504
158
|
|
|
505
159
|
```typescript
|
|
506
|
-
|
|
507
|
-
const
|
|
508
|
-
```
|
|
509
|
-
|
|
510
|
-
ValueTags separate the identifier from the value type. The string identifier should be unique in order to avoid collisions in TypeScript type error reporting. The main use-case for ValueTags is for injecting configuration values. Be careful with generic names like `'ApiKey'` or `'Config'` - prefer specific identifiers like `'ThirdPartyApiKey'` or `'HttpClientConfig'`.
|
|
160
|
+
// Layer.service for classes with dependencies
|
|
161
|
+
const userLayer = Layer.service(UserService, [Database, Logger]);
|
|
511
162
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
The container manages the lifecycle of your dependencies. It handles:
|
|
515
|
-
|
|
516
|
-
- **Registration**: Associating tags with factory functions
|
|
517
|
-
- **Resolution**: Creating and caching service instances
|
|
518
|
-
- **Dependency injection**: Making dependencies available to factories
|
|
519
|
-
- **Lifecycle management**: Calling finalizers when destroyed
|
|
520
|
-
|
|
521
|
-
```typescript
|
|
522
|
-
const container = Container.empty()
|
|
523
|
-
.register(Database, () => new Database())
|
|
524
|
-
.register(
|
|
525
|
-
UserRepository,
|
|
526
|
-
async (ctx) => new UserRepository(await ctx.resolve(Database))
|
|
527
|
-
);
|
|
163
|
+
// Layer.value for constants
|
|
164
|
+
const configLayer = Layer.value(ConfigTag, { port: 3000 });
|
|
528
165
|
|
|
529
|
-
|
|
530
|
-
|
|
166
|
+
// Layer.create for custom factory logic
|
|
167
|
+
const cacheLayer = Layer.create({
|
|
168
|
+
requires: [ConfigTag],
|
|
169
|
+
apply: (builder) => builder.add(Cache, async (ctx) => {
|
|
170
|
+
const config = await ctx.resolve(ConfigTag);
|
|
171
|
+
return new Cache({ ttl: config.cacheTtl });
|
|
172
|
+
})
|
|
173
|
+
});
|
|
531
174
|
```
|
|
532
175
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
### Layers
|
|
536
|
-
|
|
537
|
-
Layers are composable units of dependency registrations. Think of them as blueprints that can be combined and reused:
|
|
176
|
+
Compose layers with `provide()`, `provideMerge()`, and `merge()`:
|
|
538
177
|
|
|
539
178
|
```typescript
|
|
540
|
-
//
|
|
541
|
-
const
|
|
542
|
-
container.register(Database, () => new Database())
|
|
543
|
-
);
|
|
179
|
+
// provide: satisfy dependencies, expose only this layer's provisions
|
|
180
|
+
const appLayer = userLayer.provide(dbLayer);
|
|
544
181
|
|
|
545
|
-
//
|
|
546
|
-
const
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
UserRepository,
|
|
550
|
-
async (ctx) => new UserRepository(await ctx.resolve(Database))
|
|
551
|
-
)
|
|
552
|
-
);
|
|
182
|
+
// merge: combine independent layers
|
|
183
|
+
const infraLayer = Layer.merge(dbLayer, loggerLayer);
|
|
184
|
+
// or
|
|
185
|
+
const infraLayer = Layer.mergeAll(dbLayer, loggerLayer, cacheLayer);
|
|
553
186
|
|
|
554
|
-
//
|
|
555
|
-
const
|
|
187
|
+
// provideMerge: satisfy dependencies and expose both layers
|
|
188
|
+
const fullLayer = userLayer.provideMerge(dbLayer);
|
|
556
189
|
```
|
|
557
190
|
|
|
558
|
-
|
|
191
|
+
### Scoped Containers
|
|
559
192
|
|
|
560
|
-
|
|
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
|
|
193
|
+
Scoped containers enable hierarchical dependency management:
|
|
570
194
|
|
|
571
195
|
```typescript
|
|
572
|
-
//
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
);
|
|
196
|
+
// Application scope - use builder to add dependencies
|
|
197
|
+
const appContainer = ScopedContainer.builder('app')
|
|
198
|
+
.add(Database, () => new Database())
|
|
199
|
+
.build();
|
|
577
200
|
|
|
578
|
-
//
|
|
579
|
-
const requestContainer =
|
|
580
|
-
|
|
581
|
-
|
|
201
|
+
// Request scope - use child() to create a child builder
|
|
202
|
+
const requestContainer = appContainer.child('request')
|
|
203
|
+
.add(RequestContext, () => new RequestContext())
|
|
204
|
+
.build();
|
|
582
205
|
|
|
583
|
-
// Child can
|
|
584
|
-
const db = await requestContainer.resolve(Database);
|
|
206
|
+
// Child can resolve both its own and parent dependencies
|
|
207
|
+
const db = await requestContainer.resolve(Database); // From parent
|
|
208
|
+
const ctx = await requestContainer.resolve(RequestContext); // From child
|
|
585
209
|
|
|
586
|
-
//
|
|
210
|
+
// Destroy child without affecting parent
|
|
587
211
|
await requestContainer.destroy();
|
|
588
212
|
```
|
|
589
213
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
## Working with Containers
|
|
593
|
-
|
|
594
|
-
This section covers direct container usage. For larger applications, you'll typically use layers instead (covered in the next section), but understanding containers is essential.
|
|
595
|
-
|
|
596
|
-
### Creating a Container
|
|
597
|
-
|
|
598
|
-
Start with an empty container:
|
|
214
|
+
Or use layers with `childFrom`:
|
|
599
215
|
|
|
600
216
|
```typescript
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
217
|
+
const appContainer = ScopedContainer.from('app', dbLayer);
|
|
218
|
+
const requestContainer = appContainer.childFrom('request',
|
|
219
|
+
Layer.value(RequestContext, new RequestContext())
|
|
220
|
+
);
|
|
605
221
|
```
|
|
606
222
|
|
|
607
|
-
###
|
|
223
|
+
### Use and Discard Pattern
|
|
608
224
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
Register a class by providing a factory function:
|
|
225
|
+
The `use()` method resolves a service, runs a callback, and automatically destroys the container:
|
|
612
226
|
|
|
613
227
|
```typescript
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
const container = Container.empty().register(Logger, () => new Logger());
|
|
623
|
-
// Type: Container<typeof Logger>
|
|
228
|
+
// Perfect for short-lived operations like Lambda handlers or worker jobs
|
|
229
|
+
const result = await appContainer
|
|
230
|
+
.childFrom('request', requestLayer)
|
|
231
|
+
.use(UserService, (service) => service.processEvent(event));
|
|
232
|
+
// Container is automatically destroyed after callback completes
|
|
624
233
|
```
|
|
625
234
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
```typescript
|
|
629
|
-
class Database extends Tag.Service('Database') {
|
|
630
|
-
query(sql: string) {
|
|
631
|
-
return [];
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
class UserRepository extends Tag.Service('UserRepository') {
|
|
636
|
-
constructor(
|
|
637
|
-
private db: Database,
|
|
638
|
-
private logger: Logger
|
|
639
|
-
) {
|
|
640
|
-
super();
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
async findAll() {
|
|
644
|
-
this.logger.log('Finding all users');
|
|
645
|
-
return this.db.query('SELECT * FROM users');
|
|
646
|
-
}
|
|
647
|
-
}
|
|
235
|
+
This is especially useful for serverless functions or message handlers where the container lifecycle matches a single operation.
|
|
648
236
|
|
|
649
|
-
|
|
650
|
-
.register(Database, () => new Database())
|
|
651
|
-
.register(Logger, () => new Logger())
|
|
652
|
-
.register(UserRepository, async (ctx) => {
|
|
653
|
-
// ctx provides resolve() and resolveAll()
|
|
654
|
-
const [db, logger] = await ctx.resolveAll(Database, Logger);
|
|
655
|
-
return new UserRepository(db, logger);
|
|
656
|
-
});
|
|
657
|
-
```
|
|
237
|
+
## Working with Layers
|
|
658
238
|
|
|
659
|
-
|
|
239
|
+
### Creating Layers
|
|
660
240
|
|
|
661
|
-
|
|
241
|
+
**Layer.service**: Class dependencies with automatic injection
|
|
662
242
|
|
|
663
243
|
```typescript
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
interface AppConfig {
|
|
668
|
-
apiKey: string;
|
|
669
|
-
timeout: number;
|
|
244
|
+
class ApiClient {
|
|
245
|
+
constructor(private config: Config, private logger: Logger) {}
|
|
670
246
|
}
|
|
671
|
-
const ConfigTag = Tag.of('app.config')<AppConfig>();
|
|
672
|
-
|
|
673
|
-
const container = Container.empty()
|
|
674
|
-
.register(PortTag, () => 3000)
|
|
675
|
-
.register(DatabaseUrlTag, () => process.env.DATABASE_URL!)
|
|
676
|
-
.register(ConfigTag, () => ({
|
|
677
|
-
apiKey: process.env.API_KEY!,
|
|
678
|
-
timeout: 5000,
|
|
679
|
-
}));
|
|
680
|
-
```
|
|
681
|
-
|
|
682
|
-
### Resolving Dependencies
|
|
683
|
-
|
|
684
|
-
Use `resolve()` to get a service instance:
|
|
685
|
-
|
|
686
|
-
```typescript
|
|
687
|
-
const logger = await container.resolve(Logger);
|
|
688
|
-
logger.log('Hello!');
|
|
689
247
|
|
|
690
|
-
//
|
|
691
|
-
const
|
|
692
|
-
// Error: Argument of type 'typeof UserRepository' is not assignable...
|
|
693
|
-
```
|
|
248
|
+
// Dependencies must match constructor parameters in order
|
|
249
|
+
const apiLayer = Layer.service(ApiClient, [Config, Logger]);
|
|
694
250
|
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
// Returns tuple with correct types: [Database, Logger]
|
|
251
|
+
// With cleanup function
|
|
252
|
+
const dbLayer = Layer.service(Database, [], {
|
|
253
|
+
cleanup: (db) => db.close()
|
|
254
|
+
});
|
|
700
255
|
```
|
|
701
256
|
|
|
702
|
-
|
|
257
|
+
**Layer.value**: Constant values
|
|
703
258
|
|
|
704
259
|
```typescript
|
|
705
|
-
const
|
|
706
|
-
const
|
|
707
|
-
|
|
708
|
-
console.log(logger1 === logger2); // true
|
|
260
|
+
const ApiKeyTag = Tag.of('apiKey')<string>();
|
|
261
|
+
const configLayer = Layer.value(ApiKeyTag, process.env.API_KEY!);
|
|
709
262
|
```
|
|
710
263
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
#### Finalizers for Cleanup
|
|
714
|
-
|
|
715
|
-
Register finalizers to clean up resources when the container is destroyed. They receive the created instance and should perform any necessary cleanup (closing connections, releasing resources, etc.):
|
|
264
|
+
**Layer.create**: Custom factory logic
|
|
716
265
|
|
|
717
266
|
```typescript
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
async disconnect() {
|
|
727
|
-
this.connected = false;
|
|
728
|
-
console.log('Disconnected');
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
query(sql: string) {
|
|
732
|
-
if (!this.connected) throw new Error('Not connected');
|
|
733
|
-
return [];
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
const container = Container.empty().register(DatabaseConnection, {
|
|
738
|
-
// Factory
|
|
739
|
-
create: async () => {
|
|
740
|
-
const db = new DatabaseConnection();
|
|
741
|
-
await db.connect();
|
|
742
|
-
return db;
|
|
743
|
-
},
|
|
744
|
-
// Finalizer
|
|
745
|
-
cleanup: async (db) => {
|
|
746
|
-
await db.disconnect();
|
|
747
|
-
},
|
|
267
|
+
const dbLayer = Layer.create({
|
|
268
|
+
requires: [ConfigTag],
|
|
269
|
+
apply: (builder) => builder.add(Database, async (ctx) => {
|
|
270
|
+
const config = await ctx.resolve(ConfigTag);
|
|
271
|
+
const db = new Database(config.dbUrl);
|
|
272
|
+
await db.connect();
|
|
273
|
+
return db;
|
|
274
|
+
})
|
|
748
275
|
});
|
|
749
|
-
|
|
750
|
-
// Use the service
|
|
751
|
-
const db = await container.resolve(DatabaseConnection);
|
|
752
|
-
await db.query('SELECT 1');
|
|
753
|
-
|
|
754
|
-
// Clean up
|
|
755
|
-
await container.destroy();
|
|
756
|
-
// Output: "Disconnected"
|
|
757
|
-
```
|
|
758
|
-
|
|
759
|
-
You can also implement `DependencyLifecycle` as a class for better organization and reuse:
|
|
760
|
-
|
|
761
|
-
```typescript
|
|
762
|
-
import {
|
|
763
|
-
Container,
|
|
764
|
-
Tag,
|
|
765
|
-
type DependencyLifecycle,
|
|
766
|
-
type ResolutionContext,
|
|
767
|
-
} from 'sandly';
|
|
768
|
-
|
|
769
|
-
class Logger extends Tag.Service('Logger') {
|
|
770
|
-
log(message: string) {
|
|
771
|
-
console.log(message);
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
class DatabaseConnection extends Tag.Service('DatabaseConnection') {
|
|
776
|
-
constructor(
|
|
777
|
-
private logger: Logger,
|
|
778
|
-
private url: string
|
|
779
|
-
) {
|
|
780
|
-
super();
|
|
781
|
-
}
|
|
782
|
-
async connect() {
|
|
783
|
-
this.logger.log('Connected');
|
|
784
|
-
}
|
|
785
|
-
async disconnect() {
|
|
786
|
-
this.logger.log('Disconnected');
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
class DatabaseLifecycle
|
|
791
|
-
implements DependencyLifecycle<DatabaseConnection, typeof Logger>
|
|
792
|
-
{
|
|
793
|
-
constructor(private url: string) {}
|
|
794
|
-
|
|
795
|
-
async create(
|
|
796
|
-
ctx: ResolutionContext<typeof Logger>
|
|
797
|
-
): Promise<DatabaseConnection> {
|
|
798
|
-
const logger = await ctx.resolve(Logger);
|
|
799
|
-
const db = new DatabaseConnection(logger, this.url);
|
|
800
|
-
await db.connect();
|
|
801
|
-
return db;
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
async cleanup(db: DatabaseConnection): Promise<void> {
|
|
805
|
-
await db.disconnect();
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
const container = Container.empty()
|
|
810
|
-
.register(Logger, () => new Logger())
|
|
811
|
-
.register(
|
|
812
|
-
DatabaseConnection,
|
|
813
|
-
new DatabaseLifecycle('postgresql://localhost:5432')
|
|
814
|
-
);
|
|
815
276
|
```
|
|
816
277
|
|
|
817
|
-
|
|
278
|
+
### Composing Layers
|
|
818
279
|
|
|
819
280
|
```typescript
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
implements DependencyLifecycle<SimpleService, never>
|
|
826
|
-
{
|
|
827
|
-
create(): SimpleService {
|
|
828
|
-
return new SimpleService();
|
|
829
|
-
}
|
|
830
|
-
// cleanup is optional
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
const container = Container.empty().register(
|
|
834
|
-
SimpleService,
|
|
835
|
-
new SimpleServiceFactory()
|
|
836
|
-
);
|
|
837
|
-
```
|
|
281
|
+
// Build your application layer by layer
|
|
282
|
+
const configLayer = Layer.value(ConfigTag, loadConfig());
|
|
283
|
+
const dbLayer = Layer.service(Database, [ConfigTag]);
|
|
284
|
+
const repoLayer = Layer.service(UserRepository, [Database]);
|
|
285
|
+
const serviceLayer = Layer.service(UserService, [UserRepository, Logger]);
|
|
838
286
|
|
|
839
|
-
|
|
287
|
+
// Compose into complete application
|
|
288
|
+
const appLayer = serviceLayer
|
|
289
|
+
.provide(repoLayer)
|
|
290
|
+
.provide(dbLayer)
|
|
291
|
+
.provide(configLayer)
|
|
292
|
+
.provide(Layer.service(Logger, []));
|
|
840
293
|
|
|
841
|
-
|
|
842
|
-
const container = Container.
|
|
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();
|
|
294
|
+
// Create container - all dependencies satisfied
|
|
295
|
+
const container = Container.from(appLayer);
|
|
854
296
|
```
|
|
855
297
|
|
|
856
|
-
|
|
298
|
+
### Layer Type Safety
|
|
857
299
|
|
|
858
|
-
|
|
300
|
+
Layers track their requirements and provisions at the type level:
|
|
859
301
|
|
|
860
302
|
```typescript
|
|
861
|
-
const
|
|
862
|
-
|
|
863
|
-
.register(Logger, () => new FileLogger()); // Overrides previous
|
|
864
|
-
|
|
865
|
-
const logger = await container.resolve(Logger);
|
|
866
|
-
// Gets FileLogger instance
|
|
867
|
-
```
|
|
303
|
+
const dbLayer = Layer.service(Database, []);
|
|
304
|
+
// Type: Layer<never, typeof Database>
|
|
868
305
|
|
|
869
|
-
|
|
306
|
+
const userLayer = Layer.service(UserService, [Database]);
|
|
307
|
+
// Type: Layer<typeof Database, typeof UserService>
|
|
870
308
|
|
|
871
|
-
|
|
872
|
-
|
|
309
|
+
const appLayer = userLayer.provide(dbLayer);
|
|
310
|
+
// Type: Layer<never, typeof UserService>
|
|
873
311
|
|
|
874
|
-
|
|
312
|
+
// Container.from only accepts layers with no requirements
|
|
313
|
+
const container = Container.from(appLayer); // OK
|
|
875
314
|
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
// it has already been instantiated
|
|
315
|
+
const incomplete = Layer.service(UserService, [Database]);
|
|
316
|
+
const container = Container.from(incomplete); // Type error!
|
|
879
317
|
```
|
|
880
318
|
|
|
881
|
-
|
|
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
|
-
```
|
|
319
|
+
## Scoped Containers
|
|
891
320
|
|
|
892
|
-
|
|
321
|
+
### Request Scoping for Web Servers
|
|
893
322
|
|
|
894
323
|
```typescript
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
console.log(container.exists(Logger)); // false - not instantiated yet
|
|
898
|
-
|
|
899
|
-
await container.resolve(Logger);
|
|
324
|
+
import { ScopedContainer, Layer } from 'sandly';
|
|
900
325
|
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
#### Unknown Dependency
|
|
326
|
+
// App-level dependencies (shared across requests)
|
|
327
|
+
const appContainer = ScopedContainer.from('app',
|
|
328
|
+
Layer.mergeAll(dbLayer, loggerLayer)
|
|
329
|
+
);
|
|
907
330
|
|
|
908
|
-
|
|
909
|
-
|
|
331
|
+
// Express middleware
|
|
332
|
+
app.use(async (req, res, next) => {
|
|
333
|
+
// Create request scope with request-specific dependencies
|
|
334
|
+
const requestScope = appContainer.childFrom('request',
|
|
335
|
+
Layer.value(RequestContext, {
|
|
336
|
+
requestId: crypto.randomUUID(),
|
|
337
|
+
userId: req.user?.id
|
|
338
|
+
})
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
res.locals.container = requestScope;
|
|
342
|
+
|
|
343
|
+
res.on('finish', () => requestScope.destroy());
|
|
344
|
+
next();
|
|
345
|
+
});
|
|
910
346
|
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
}
|
|
347
|
+
// Route handler
|
|
348
|
+
app.get('/users', async (req, res) => {
|
|
349
|
+
const userService = await res.locals.container.resolve(UserService);
|
|
350
|
+
res.json(await userService.getUsers());
|
|
351
|
+
});
|
|
917
352
|
```
|
|
918
353
|
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
#### Circular Dependencies
|
|
354
|
+
### Destruction Order
|
|
922
355
|
|
|
923
|
-
|
|
356
|
+
When destroying a scoped container:
|
|
357
|
+
1. Child scopes are destroyed first
|
|
358
|
+
2. Then the current scope's finalizers run
|
|
359
|
+
3. Parent scope is unaffected
|
|
924
360
|
|
|
925
361
|
```typescript
|
|
926
|
-
|
|
927
|
-
|
|
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`:
|
|
362
|
+
const parent = ScopedContainer.builder('parent')
|
|
363
|
+
.add(Database, { create: () => new Database(), cleanup: (db) => db.close() })
|
|
364
|
+
.build();
|
|
953
365
|
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
});
|
|
366
|
+
const child = parent.child('child')
|
|
367
|
+
.add(Cache, { create: () => new Cache(), cleanup: (c) => c.clear() })
|
|
368
|
+
.build();
|
|
958
369
|
|
|
959
|
-
|
|
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
|
-
}
|
|
370
|
+
await parent.destroy(); // Destroys child first (Cache.clear), then parent (Database.close)
|
|
965
371
|
```
|
|
966
372
|
|
|
967
|
-
|
|
373
|
+
## Error Handling
|
|
968
374
|
|
|
969
|
-
|
|
375
|
+
Sandly provides specific error types for common issues:
|
|
970
376
|
|
|
971
377
|
```typescript
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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
|
-
);
|
|
378
|
+
import {
|
|
379
|
+
UnknownDependencyError,
|
|
380
|
+
CircularDependencyError,
|
|
381
|
+
DependencyCreationError,
|
|
382
|
+
DependencyFinalizationError
|
|
383
|
+
} from 'sandly';
|
|
1001
384
|
|
|
1002
385
|
try {
|
|
1003
|
-
|
|
386
|
+
const service = await container.resolve(UserService);
|
|
1004
387
|
} catch (error) {
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
388
|
+
if (error instanceof CircularDependencyError) {
|
|
389
|
+
console.log(error.message);
|
|
390
|
+
// "Circular dependency detected for UserService: UserService -> Database -> UserService"
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (error instanceof DependencyCreationError) {
|
|
394
|
+
// Get the original error that caused the failure
|
|
395
|
+
const rootCause = error.getRootCause();
|
|
396
|
+
console.log(rootCause.message);
|
|
397
|
+
}
|
|
1014
398
|
}
|
|
1015
399
|
```
|
|
1016
400
|
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
If any finalizer fails, cleanup continues for others and a `DependencyFinalizationError` is thrown with details of all failures:
|
|
401
|
+
## API Reference
|
|
1020
402
|
|
|
1021
|
-
|
|
1022
|
-
class Database extends Tag.Service('Database') {
|
|
1023
|
-
async close() {
|
|
1024
|
-
throw new Error('Database close failed');
|
|
1025
|
-
}
|
|
1026
|
-
}
|
|
403
|
+
### Container
|
|
1027
404
|
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
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
|
|
405
|
+
| Method | Description |
|
|
406
|
+
|--------|-------------|
|
|
407
|
+
| `Container.from(layer)` | Create container from a fully resolved layer |
|
|
408
|
+
| `Container.builder()` | Create a container builder |
|
|
409
|
+
| `Container.empty()` | Create an empty container |
|
|
410
|
+
| `Container.scoped(scope)` | Create an empty scoped container |
|
|
411
|
+
| `container.resolve(tag)` | Get a dependency instance |
|
|
412
|
+
| `container.resolveAll(...tags)` | Get multiple dependencies |
|
|
413
|
+
| `container.use(tag, fn)` | Resolve, run callback, then destroy container |
|
|
414
|
+
| `container.destroy()` | Run finalizers and clean up |
|
|
415
|
+
|
|
416
|
+
### ContainerBuilder
|
|
417
|
+
|
|
418
|
+
| Method | Description |
|
|
419
|
+
|--------|-------------|
|
|
420
|
+
| `builder.add(tag, spec)` | Register a dependency |
|
|
421
|
+
| `builder.build()` | Create the container |
|
|
422
|
+
|
|
423
|
+
### Layer
|
|
424
|
+
|
|
425
|
+
| Method | Description |
|
|
426
|
+
|--------|-------------|
|
|
427
|
+
| `Layer.service(class, deps, options?)` | Create layer for a class |
|
|
428
|
+
| `Layer.value(tag, value)` | Create layer for a constant value |
|
|
429
|
+
| `Layer.create({ requires, apply })` | Create custom layer |
|
|
430
|
+
| `Layer.empty()` | Create empty layer |
|
|
431
|
+
| `Layer.merge(a, b)` | Merge two layers |
|
|
432
|
+
| `Layer.mergeAll(...layers)` | Merge multiple layers |
|
|
433
|
+
| `layer.provide(dep)` | Satisfy dependencies |
|
|
434
|
+
| `layer.provideMerge(dep)` | Satisfy and merge provisions |
|
|
435
|
+
| `layer.merge(other)` | Merge with another layer |
|
|
436
|
+
|
|
437
|
+
### ScopedContainer
|
|
438
|
+
|
|
439
|
+
| Method | Description |
|
|
440
|
+
|--------|-------------|
|
|
441
|
+
| `ScopedContainer.builder(scope)` | Create a new scoped container builder |
|
|
442
|
+
| `ScopedContainer.empty(scope)` | Create empty scoped container |
|
|
443
|
+
| `ScopedContainer.from(scope, layer)` | Create from layer |
|
|
444
|
+
| `container.child(scope)` | Create child scope builder |
|
|
445
|
+
| `container.childFrom(scope, layer)` | Create child scope from layer (convenience) |
|
|
446
|
+
|
|
447
|
+
### Tag
|
|
448
|
+
|
|
449
|
+
| Method | Description |
|
|
450
|
+
|--------|-------------|
|
|
451
|
+
| `Tag.of(id)<T>()` | Create a ValueTag |
|
|
452
|
+
| `Tag.id(tag)` | Get tag's string identifier |
|
|
453
|
+
| `Tag.isTag(value)` | Check if value is a tag |
|
|
2627
454
|
|
|
2628
455
|
## Comparison with Alternatives
|
|
2629
456
|
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
**Sandly
|
|
2640
|
-
|
|
2641
|
-
-
|
|
2642
|
-
-
|
|
2643
|
-
-
|
|
2644
|
-
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
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 | ✅ | ❌ | ❌ | ❌ | ❌ |
|
|
2772
|
-
|
|
2773
|
-
### Why Choose Sandly?
|
|
2774
|
-
|
|
2775
|
-
Choose Sandly if you want:
|
|
2776
|
-
|
|
2777
|
-
- **Type safety** without sacrificing developer experience
|
|
2778
|
-
- **Dependency injection** without the need for experimental features that won't be supported in the future
|
|
2779
|
-
- **Clean architecture** with layers and composable modules
|
|
2780
|
-
- **Async support** for real-world scenarios (database connections, API clients, etc.)
|
|
2781
|
-
- **Testing-friendly** design with easy mocking and isolation
|
|
2782
|
-
- **Incremental adoption** in existing codebases
|
|
2783
|
-
- **Zero runtime dependencies** and minimal overhead
|
|
2784
|
-
|
|
2785
|
-
Sandly takes inspiration from Effect-TS's excellent type safety and layer composition, while keeping the API simple and accessible for teams that don't need a full effect system.
|
|
457
|
+
| Feature | Sandly | NestJS | InversifyJS | TSyringe |
|
|
458
|
+
|---------|--------|--------|-------------|----------|
|
|
459
|
+
| Compile-time type safety | ✅ | ❌ | ⚠️ Partial | ❌ |
|
|
460
|
+
| No experimental decorators | ✅ | ❌ | ❌ | ❌ |
|
|
461
|
+
| Async factories | ✅ | ✅ | ❌ | ❌ |
|
|
462
|
+
| Framework-agnostic | ✅ | ❌ | ✅ | ✅ |
|
|
463
|
+
| Layer composition | ✅ | ❌ | ❌ | ❌ |
|
|
464
|
+
| Zero dependencies | ✅ | ❌ | ❌ | ❌ |
|
|
465
|
+
|
|
466
|
+
**Choose Sandly when you want:**
|
|
467
|
+
- Type safety without sacrificing simplicity
|
|
468
|
+
- DI without experimental decorators
|
|
469
|
+
- Composable, reusable dependency modules
|
|
470
|
+
- Easy testing with mock injection
|
|
471
|
+
- Minimal bundle size and zero dependencies
|
|
472
|
+
|
|
473
|
+
## License
|
|
474
|
+
|
|
475
|
+
MIT
|