sandly 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +188 -43
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -74,8 +74,8 @@ class UserRepository extends Tag.Service('UserRepository') {
|
|
|
74
74
|
// Register services with their factories
|
|
75
75
|
const container = Container.empty()
|
|
76
76
|
.register(Database, {
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
create: () => new Database(),
|
|
78
|
+
cleanup: (db) => db.close(), // Cleanup when container is destroyed
|
|
79
79
|
})
|
|
80
80
|
.register(
|
|
81
81
|
UserRepository,
|
|
@@ -106,8 +106,8 @@ import { layer, autoService, Container } from 'sandly';
|
|
|
106
106
|
// Layer that provides Database
|
|
107
107
|
const databaseLayer = layer<never, typeof Database>((container) =>
|
|
108
108
|
container.register(Database, {
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
create: () => new Database(),
|
|
110
|
+
cleanup: (db) => db.close(),
|
|
111
111
|
})
|
|
112
112
|
);
|
|
113
113
|
|
|
@@ -330,12 +330,12 @@ class DatabaseConnection extends Tag.Service('DatabaseConnection') {
|
|
|
330
330
|
}
|
|
331
331
|
|
|
332
332
|
const container = Container.empty().register(DatabaseConnection, {
|
|
333
|
-
|
|
333
|
+
create: async () => {
|
|
334
334
|
const db = new DatabaseConnection();
|
|
335
335
|
await db.connect(); // Async initialization
|
|
336
336
|
return db;
|
|
337
337
|
},
|
|
338
|
-
|
|
338
|
+
cleanup: async (db) => {
|
|
339
339
|
await db.disconnect(); // Async cleanup
|
|
340
340
|
},
|
|
341
341
|
});
|
|
@@ -485,7 +485,7 @@ Before diving into detailed usage, let's understand the four main building block
|
|
|
485
485
|
|
|
486
486
|
### Tags
|
|
487
487
|
|
|
488
|
-
Tags are unique
|
|
488
|
+
Tags are unique tokens that represent dependencies and serve as a way to reference them in the container. They come in two flavors:
|
|
489
489
|
|
|
490
490
|
**ServiceTag** - For class-based dependencies. Created by extending `Tag.Service()`:
|
|
491
491
|
|
|
@@ -507,7 +507,7 @@ const ApiKeyTag = Tag.of('ApiKey')<string>();
|
|
|
507
507
|
const ConfigTag = Tag.of('Config')<{ port: number }>();
|
|
508
508
|
```
|
|
509
509
|
|
|
510
|
-
ValueTags separate the identifier from the value type. The string identifier
|
|
510
|
+
ValueTags separate the identifier from the value type. The string identifier should be unique in order to avoid collisions in TypeScript type error reporting. The main use-case for ValueTags is for injecting configuration values. Be careful with generic names like `'ApiKey'` or `'Config'` - prefer specific identifiers like `'ThirdPartyApiKey'` or `'HttpClientConfig'`.
|
|
511
511
|
|
|
512
512
|
### Container
|
|
513
513
|
|
|
@@ -712,7 +712,7 @@ console.log(logger1 === logger2); // true
|
|
|
712
712
|
|
|
713
713
|
#### Finalizers for Cleanup
|
|
714
714
|
|
|
715
|
-
Register finalizers to clean up resources when the container is destroyed:
|
|
715
|
+
Register finalizers to clean up resources when the container is destroyed. They receive the created instance and should perform any necessary cleanup (closing connections, releasing resources, etc.):
|
|
716
716
|
|
|
717
717
|
```typescript
|
|
718
718
|
class DatabaseConnection extends Tag.Service('DatabaseConnection') {
|
|
@@ -735,12 +735,14 @@ class DatabaseConnection extends Tag.Service('DatabaseConnection') {
|
|
|
735
735
|
}
|
|
736
736
|
|
|
737
737
|
const container = Container.empty().register(DatabaseConnection, {
|
|
738
|
-
|
|
738
|
+
// Factory
|
|
739
|
+
create: async () => {
|
|
739
740
|
const db = new DatabaseConnection();
|
|
740
741
|
await db.connect();
|
|
741
742
|
return db;
|
|
742
743
|
},
|
|
743
|
-
|
|
744
|
+
// Finalizer
|
|
745
|
+
cleanup: async (db) => {
|
|
744
746
|
await db.disconnect();
|
|
745
747
|
},
|
|
746
748
|
});
|
|
@@ -754,25 +756,103 @@ await container.destroy();
|
|
|
754
756
|
// Output: "Disconnected"
|
|
755
757
|
```
|
|
756
758
|
|
|
759
|
+
You can also implement `DependencyLifecycle` as a class for better organization and reuse:
|
|
760
|
+
|
|
761
|
+
```typescript
|
|
762
|
+
import {
|
|
763
|
+
Container,
|
|
764
|
+
Tag,
|
|
765
|
+
type DependencyLifecycle,
|
|
766
|
+
type ResolutionContext,
|
|
767
|
+
} from 'sandly';
|
|
768
|
+
|
|
769
|
+
class Logger extends Tag.Service('Logger') {
|
|
770
|
+
log(message: string) {
|
|
771
|
+
console.log(message);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
class DatabaseConnection extends Tag.Service('DatabaseConnection') {
|
|
776
|
+
constructor(
|
|
777
|
+
private logger: Logger,
|
|
778
|
+
private url: string
|
|
779
|
+
) {
|
|
780
|
+
super();
|
|
781
|
+
}
|
|
782
|
+
async connect() {
|
|
783
|
+
this.logger.log('Connected');
|
|
784
|
+
}
|
|
785
|
+
async disconnect() {
|
|
786
|
+
this.logger.log('Disconnected');
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
class DatabaseLifecycle
|
|
791
|
+
implements DependencyLifecycle<DatabaseConnection, typeof Logger>
|
|
792
|
+
{
|
|
793
|
+
constructor(private url: string) {}
|
|
794
|
+
|
|
795
|
+
async create(
|
|
796
|
+
ctx: ResolutionContext<typeof Logger>
|
|
797
|
+
): Promise<DatabaseConnection> {
|
|
798
|
+
const logger = await ctx.resolve(Logger);
|
|
799
|
+
const db = new DatabaseConnection(logger, this.url);
|
|
800
|
+
await db.connect();
|
|
801
|
+
return db;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
async cleanup(db: DatabaseConnection): Promise<void> {
|
|
805
|
+
await db.disconnect();
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const container = Container.empty()
|
|
810
|
+
.register(Logger, () => new Logger())
|
|
811
|
+
.register(
|
|
812
|
+
DatabaseConnection,
|
|
813
|
+
new DatabaseLifecycle('postgresql://localhost:5432')
|
|
814
|
+
);
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
The `cleanup` method is optional, so you can implement classes with only a `create` method:
|
|
818
|
+
|
|
819
|
+
```typescript
|
|
820
|
+
import { Container, Tag, type DependencyLifecycle } from 'sandly';
|
|
821
|
+
|
|
822
|
+
class SimpleService extends Tag.Service('SimpleService') {}
|
|
823
|
+
|
|
824
|
+
class SimpleServiceFactory
|
|
825
|
+
implements DependencyLifecycle<SimpleService, never>
|
|
826
|
+
{
|
|
827
|
+
create(): SimpleService {
|
|
828
|
+
return new SimpleService();
|
|
829
|
+
}
|
|
830
|
+
// cleanup is optional
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const container = Container.empty().register(
|
|
834
|
+
SimpleService,
|
|
835
|
+
new SimpleServiceFactory()
|
|
836
|
+
);
|
|
837
|
+
```
|
|
838
|
+
|
|
757
839
|
All finalizers run concurrently when you call `destroy()`:
|
|
758
840
|
|
|
759
841
|
```typescript
|
|
760
842
|
const container = Container.empty()
|
|
761
843
|
.register(Database, {
|
|
762
|
-
|
|
763
|
-
|
|
844
|
+
create: () => new Database(),
|
|
845
|
+
cleanup: (db) => db.close(),
|
|
764
846
|
})
|
|
765
847
|
.register(Cache, {
|
|
766
|
-
|
|
767
|
-
|
|
848
|
+
create: () => new Cache(),
|
|
849
|
+
cleanup: (cache) => cache.clear(),
|
|
768
850
|
});
|
|
769
851
|
|
|
770
852
|
// Both finalizers run in parallel
|
|
771
853
|
await container.destroy();
|
|
772
854
|
```
|
|
773
855
|
|
|
774
|
-
If any finalizer fails, cleanup continues for others and a `DependencyFinalizationError` is thrown with details of all failures.
|
|
775
|
-
|
|
776
856
|
#### Overriding Registrations
|
|
777
857
|
|
|
778
858
|
You can override a registration before it's instantiated:
|
|
@@ -934,6 +1014,51 @@ try {
|
|
|
934
1014
|
}
|
|
935
1015
|
```
|
|
936
1016
|
|
|
1017
|
+
#### Finalization Errors
|
|
1018
|
+
|
|
1019
|
+
If any finalizer fails, cleanup continues for others and a `DependencyFinalizationError` is thrown with details of all failures:
|
|
1020
|
+
|
|
1021
|
+
```typescript
|
|
1022
|
+
class Database extends Tag.Service('Database') {
|
|
1023
|
+
async close() {
|
|
1024
|
+
throw new Error('Database close failed');
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
class Cache extends Tag.Service('Cache') {
|
|
1029
|
+
async clear() {
|
|
1030
|
+
throw new Error('Cache clear failed');
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const container = Container.empty()
|
|
1035
|
+
.register(Database, {
|
|
1036
|
+
create: () => new Database(),
|
|
1037
|
+
cleanup: async (db) => db.close(),
|
|
1038
|
+
})
|
|
1039
|
+
.register(Cache, {
|
|
1040
|
+
create: () => new Cache(),
|
|
1041
|
+
cleanup: async (cache) => cache.clear(),
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
await container.resolve(Database);
|
|
1045
|
+
await container.resolve(Cache);
|
|
1046
|
+
|
|
1047
|
+
try {
|
|
1048
|
+
await container.destroy();
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
if (error instanceof DependencyFinalizationError) {
|
|
1051
|
+
// Get all original errors that caused the finalization failure
|
|
1052
|
+
const rootCauses = error.getRootCauses();
|
|
1053
|
+
console.error('Finalization failures:', rootCauses);
|
|
1054
|
+
// [
|
|
1055
|
+
// Error: Database close failed,
|
|
1056
|
+
// Error: Cache clear failed
|
|
1057
|
+
// ]
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
```
|
|
1061
|
+
|
|
937
1062
|
### Type Safety in Action
|
|
938
1063
|
|
|
939
1064
|
The container's type parameter tracks all registered dependencies:
|
|
@@ -995,18 +1120,6 @@ container.register(Logger, () => new Logger());
|
|
|
995
1120
|
// TypeScript doesn't track these registrations
|
|
996
1121
|
```
|
|
997
1122
|
|
|
998
|
-
**Use namespaced identifiers for ValueTags** - Prevents collisions:
|
|
999
|
-
|
|
1000
|
-
```typescript
|
|
1001
|
-
// ❌ Generic names can collide
|
|
1002
|
-
const Port = Tag.of('port')<number>();
|
|
1003
|
-
const Timeout = Tag.of('timeout')<number>();
|
|
1004
|
-
|
|
1005
|
-
// ✅ Namespaced identifiers
|
|
1006
|
-
const ServerPort = Tag.of('server.port')<number>();
|
|
1007
|
-
const HttpTimeout = Tag.of('http.timeout')<number>();
|
|
1008
|
-
```
|
|
1009
|
-
|
|
1010
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.
|
|
1011
1124
|
|
|
1012
1125
|
**Handle cleanup errors** - Finalizers can fail:
|
|
@@ -1022,6 +1135,39 @@ try {
|
|
|
1022
1135
|
}
|
|
1023
1136
|
```
|
|
1024
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
|
+
|
|
1025
1171
|
## Working with Layers
|
|
1026
1172
|
|
|
1027
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.
|
|
@@ -1410,12 +1556,12 @@ const userRepositoryLayer = service(UserRepository, async (ctx) => {
|
|
|
1410
1556
|
|
|
1411
1557
|
// With finalizer
|
|
1412
1558
|
const databaseLayer = service(Database, {
|
|
1413
|
-
|
|
1559
|
+
create: async () => {
|
|
1414
1560
|
const db = new Database();
|
|
1415
1561
|
await db.connect();
|
|
1416
1562
|
return db;
|
|
1417
1563
|
},
|
|
1418
|
-
|
|
1564
|
+
cleanup: (db) => db.disconnect(),
|
|
1419
1565
|
});
|
|
1420
1566
|
```
|
|
1421
1567
|
|
|
@@ -1474,12 +1620,12 @@ const apiClientLayer = autoService(ApiClient, [
|
|
|
1474
1620
|
|
|
1475
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.
|
|
1476
1622
|
|
|
1477
|
-
With
|
|
1623
|
+
With cleanup:
|
|
1478
1624
|
|
|
1479
1625
|
```typescript
|
|
1480
1626
|
const databaseLayer = autoService(Database, {
|
|
1481
1627
|
dependencies: ['postgresql://localhost:5432/mydb'],
|
|
1482
|
-
|
|
1628
|
+
cleanup: (db) => db.disconnect(),
|
|
1483
1629
|
});
|
|
1484
1630
|
```
|
|
1485
1631
|
|
|
@@ -1921,13 +2067,13 @@ But use `service()` when you need custom logic:
|
|
|
1921
2067
|
```typescript
|
|
1922
2068
|
// ✅ Good - custom initialization logic
|
|
1923
2069
|
const databaseLayer = service(Database, {
|
|
1924
|
-
|
|
2070
|
+
create: async () => {
|
|
1925
2071
|
const db = new Database();
|
|
1926
2072
|
await db.connect();
|
|
1927
2073
|
await db.runMigrations();
|
|
1928
2074
|
return db;
|
|
1929
2075
|
},
|
|
1930
|
-
|
|
2076
|
+
cleanup: (db) => db.disconnect(),
|
|
1931
2077
|
});
|
|
1932
2078
|
```
|
|
1933
2079
|
|
|
@@ -2277,23 +2423,23 @@ When a scope is destroyed, finalizers run in this order:
|
|
|
2277
2423
|
|
|
2278
2424
|
```typescript
|
|
2279
2425
|
const appScope = ScopedContainer.empty('app').register(Database, {
|
|
2280
|
-
|
|
2281
|
-
|
|
2426
|
+
create: () => new Database(),
|
|
2427
|
+
cleanup: (db) => {
|
|
2282
2428
|
console.log('Closing database');
|
|
2283
2429
|
return db.close();
|
|
2284
2430
|
},
|
|
2285
2431
|
});
|
|
2286
2432
|
|
|
2287
2433
|
const request1 = appScope.child('request-1').register(RequestContext, {
|
|
2288
|
-
|
|
2289
|
-
|
|
2434
|
+
create: () => new RequestContext('req-1'),
|
|
2435
|
+
cleanup: (ctx) => {
|
|
2290
2436
|
console.log('Cleaning up request-1');
|
|
2291
2437
|
},
|
|
2292
2438
|
});
|
|
2293
2439
|
|
|
2294
2440
|
const request2 = appScope.child('request-2').register(RequestContext, {
|
|
2295
|
-
|
|
2296
|
-
|
|
2441
|
+
create: () => new RequestContext('req-2'),
|
|
2442
|
+
cleanup: (ctx) => {
|
|
2297
2443
|
console.log('Cleaning up request-2');
|
|
2298
2444
|
},
|
|
2299
2445
|
});
|
|
@@ -2565,8 +2711,7 @@ const service = await container.resolve(UserService); // Type-safe
|
|
|
2565
2711
|
| -------------------------- | ------- | ---------- | --------------------- | -------- | --------- |
|
|
2566
2712
|
| Compile-time type safety | ✅ Full | ❌ None | ⚠️ Partial | ❌ None | ✅ Full |
|
|
2567
2713
|
| No experimental decorators | ✅ | ❌ | ❌ | ❌ | ✅ |
|
|
2568
|
-
| Async
|
|
2569
|
-
| Async finalizers | ✅ | ✅ | ❌ | ❌ | ✅ |
|
|
2714
|
+
| Async lifecycle methods | ✅ | ✅ | ❌ | ❌ | ✅ |
|
|
2570
2715
|
| Framework-agnostic | ✅ | ❌ | ✅ | ✅ | ✅ |
|
|
2571
2716
|
| Learning curve | Low | Medium | Medium | Low | Very High |
|
|
2572
2717
|
| Bundle size | Small | Large | Medium | Small | Large |
|