@bluelibs/runner 6.3.0 → 6.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,350 @@
1
+ # BlueLibs Runner: Functional Programming Without Classes
2
+
3
+ ← [Back to main README](../README.md)
4
+
5
+ ---
6
+
7
+ _Or: How I Learned to Stop Worrying and Love Closures_
8
+
9
+ This guide shows how to build applications using BlueLibs Runner's functional approach. Instead of thinking in classes, think in terms of functions that return capabilities. You get the power of OOP patterns with the simplicity and testability of functions. With 100% type-safety.
10
+
11
+ ## Why Functions > Classes
12
+
13
+ The core idea is that **resources are factories** that return API objects.
14
+
15
+ ```ts
16
+ // Bad: Instead of a class...
17
+ class UserService {
18
+ constructor(private db: Database) {}
19
+ async getUser(id: string) {
20
+ /* ... */
21
+ }
22
+ }
23
+
24
+ // Good: Use a resource that returns an API object.
25
+ import { r } from "@bluelibs/runner";
26
+
27
+ const userService = r
28
+ .resource("app.services.user")
29
+ .dependencies({ db: database })
30
+ .init(async (_config, { db }) => {
31
+ // Private state is managed by closures
32
+ const cache = new Map<string, User>();
33
+
34
+ // The returned object is your public interface
35
+ return {
36
+ async getUser(id: string) {
37
+ if (cache.has(id)) return cache.get(id);
38
+ const user = await db.findUser(id);
39
+ cache.set(id, user);
40
+ return user;
41
+ },
42
+ };
43
+ })
44
+ .build();
45
+ ```
46
+
47
+ ## Private State with Closures
48
+
49
+ Variables declared inside `init` but outside the returned object are completely private.
50
+
51
+ ```ts
52
+ const secureWallet = r
53
+ .resource("app.wallet")
54
+ .init(async (config: { initialBalance: number }) => {
55
+ // Good: Private state - invisible from the outside
56
+ let balance = config.initialBalance;
57
+
58
+ // Good: Private helper function
59
+ const validate = (amount: number) => {
60
+ if (balance < amount) throw new Error("Insufficient funds");
61
+ };
62
+
63
+ // Good: Public API - only these methods are accessible
64
+ return {
65
+ getBalance: () => balance,
66
+ debit(amount: number) {
67
+ validate(amount);
68
+ balance -= amount;
69
+ },
70
+ };
71
+ })
72
+ .build();
73
+ ```
74
+
75
+ ### Cleanup with `dispose`
76
+
77
+ Resources can define a `dispose` function to clean up private state when the container shuts down. This is the functional equivalent of a destructor.
78
+
79
+ ```ts
80
+ // Assume Connection and createConnection are defined elsewhere
81
+ const connectionPool = r
82
+ .resource("app.db.pool")
83
+ .init(async (config: { connectionString: string }) => {
84
+ const connections: Connection[] = [];
85
+
86
+ return {
87
+ acquire() {
88
+ const conn = createConnection(config.connectionString);
89
+ connections.push(conn);
90
+ return conn;
91
+ },
92
+ getConnections() {
93
+ return connections;
94
+ },
95
+ };
96
+ })
97
+ .dispose(async (api) => {
98
+ // Cleanup: close all connections when the container shuts down
99
+ // The api parameter is the object returned from init
100
+ for (const conn of api.getConnections()) {
101
+ await conn.close();
102
+ }
103
+ })
104
+ .build();
105
+ ```
106
+
107
+ ### Isolation Guarantee
108
+
109
+ Each `run()` creates a completely isolated container. Closure state is **never shared** between containers, which makes Runner ideal for multi-tenant or test scenarios.
110
+
111
+ ```ts
112
+ import { r, run } from "@bluelibs/runner";
113
+
114
+ // Each app is a root resource that registers secureWallet with its own config
115
+ const app1 = r
116
+ .resource("app.1")
117
+ .dependencies({ wallet: secureWallet })
118
+ .register([secureWallet.with({ initialBalance: 100 })])
119
+ .init(async (_, { wallet }) => ({ wallet }))
120
+ .build();
121
+
122
+ const app2 = r
123
+ .resource("app.2")
124
+ .dependencies({ wallet: secureWallet })
125
+ .register([secureWallet.with({ initialBalance: 200 })])
126
+ .init(async (_, { wallet }) => ({ wallet }))
127
+ .build();
128
+
129
+ // These two containers have completely separate wallet state
130
+ const result1 = await run(app1);
131
+ const result2 = await run(app2);
132
+
133
+ result1.value.wallet.debit(50); // result2's wallet is unaffected
134
+
135
+ await result1.dispose();
136
+ await result2.dispose();
137
+ ```
138
+
139
+ ## Extension and Composition
140
+
141
+ ### Extension via `r.override(base, fn)`
142
+
143
+ `r.override(base, fn)` is ideal for replacing behavior while keeping the same id. For decorator-style extension, prefer a wrapper resource composed through DI so lifecycle and dependencies stay explicit and predictable.
144
+
145
+ ```ts
146
+ // Assume loggerService is a resource defined elsewhere
147
+ const baseEmailer = r
148
+ .resource("app.emailer")
149
+ .init(async (config: { apiKey: string }) => ({
150
+ async send(to: string, subject: string, body: string) {
151
+ // Real email logic...
152
+ },
153
+ }))
154
+ .build();
155
+
156
+ // Decorate via composition (recommended for extension)
157
+ const loggingEmailer = r
158
+ .resource("app.emailer.logging")
159
+ .dependencies({ emailer: baseEmailer, logger: loggerService })
160
+ .init(async (_config, { emailer, logger }) => ({
161
+ async send(to: string, subject: string, body: string) {
162
+ logger.info(`Sending email to ${to}`);
163
+ await emailer.send(to, subject, body);
164
+ logger.info(`Email sent to ${to}`);
165
+ },
166
+ }))
167
+ .build();
168
+
169
+ // For testing, you can completely replace the implementation:
170
+ const mockEmailer = r.override(baseEmailer, async () => {
171
+ const sentEmails: any[] = [];
172
+ return {
173
+ async send(to: string, subject: string, body: string) {
174
+ sentEmails.push({ to, subject, body });
175
+ },
176
+ getSentEmails: () => sentEmails,
177
+ };
178
+ });
179
+ ```
180
+
181
+ ### Composition via Dependency Injection
182
+
183
+ The best way to compose resources is through dependency injection. For conditional logic, `.optional()` is very useful.
184
+
185
+ ```ts
186
+ const smartUserService = r
187
+ .resource("app.services.user.smart")
188
+ .dependencies({
189
+ db: database,
190
+ cache: cacheService.optional(), // Optional dependency
191
+ })
192
+ .init(async (_, { db, cache }) => {
193
+ // Fallback to a simple cache if no cache resource is registered
194
+ const effectiveCache = cache || new Map();
195
+
196
+ return {
197
+ async getUser(id: string) {
198
+ const cached = effectiveCache.get(id);
199
+ return cached || db.findUser(id);
200
+ },
201
+ };
202
+ })
203
+ .build();
204
+ ```
205
+
206
+ _Note: While you can manually call another resource's `.init()` for composition, prefer DI to keep your code decoupled._
207
+
208
+ ## Contract Tags as Interfaces
209
+
210
+ Contract tags enforce the return shape of a resource or task at compile time, acting as powerful, configurable interfaces.
211
+
212
+ ```ts
213
+ import { r } from "@bluelibs/runner";
214
+
215
+ // Define contracts for expected data shapes
216
+ const userContract = r
217
+ .tag<void, void, { name: string }>("contract.user")
218
+ .build();
219
+ const ageContract = r.tag<void, void, { age: number }>("contract.age").build();
220
+
221
+ // A task must return the intersection of all its contract shapes
222
+ const getUserProfile = r
223
+ .task("app.tasks.getUserProfile")
224
+ .tags([userContract, ageContract])
225
+ .run(async () => {
226
+ // Good: TypeScript enforces this return shape: { name: string } & { age: number }
227
+ return { name: "Ada", age: 37 };
228
+ })
229
+ .build();
230
+
231
+ // This works for resources too
232
+ const profileService = r
233
+ .resource("app.resources.profile")
234
+ .tags([userContract])
235
+ .init(async () => {
236
+ // Good: Must return { name: string }
237
+ return { name: "Ada" };
238
+ })
239
+ .build();
240
+ ```
241
+
242
+ This approach is more flexible than traditional interfaces because contracts can be composed and discovered at runtime.
243
+
244
+ ## OOP vs. Runner Parallels
245
+
246
+ | OOP Concept | Runner Pattern | Example |
247
+ | ------------------- | ----------------------------- | ---------------------------------------------------------------------------- |
248
+ | **Class** | Resource returning API object | `r.resource("app.service").init(async () => ({ method: () => {} })).build()` |
249
+ | **Constructor** | `init()` function | `.init(async (config, deps) => { /* setup */ })` |
250
+ | **Destructor** | `dispose()` function | `.dispose(async (api) => { /* cleanup */ })` |
251
+ | **Private members** | Closured `const`/`let` | `const secret = ...; return { /* public */ }` |
252
+ | **Public methods** | Returned object methods | `return { publicMethod: () => {} }` |
253
+ | **Inheritance** | `r.override(base, fn)` | `r.override(base, async () => replacement)` |
254
+ | **Composition** | Resource dependencies | `.dependencies({ db, logger })` |
255
+ | **Interfaces** | Contract tags | `.tag<..., { shape }>()` |
256
+ | **Encapsulation** | Closure-based privacy | Private state is inaccessible from outside `init`. |
257
+
258
+ ## Advanced Patterns
259
+
260
+ The functional approach supports many classic design patterns.
261
+
262
+ ### Strategy Pattern
263
+
264
+ Use contract tags to discover and select implementations at runtime.
265
+
266
+ ```ts
267
+ import { r } from "@bluelibs/runner";
268
+
269
+ // 1. Define the strategy contract
270
+ const paymentStrategyContract = r
271
+ .tag<
272
+ void,
273
+ void,
274
+ { process(amount: number): Promise<boolean> }
275
+ >("contract.paymentStrategy")
276
+ .build();
277
+
278
+ // 2. Implement concrete strategies
279
+ const creditCardStrategy = r
280
+ .resource("payment.strategies.creditCard")
281
+ .tags([paymentStrategyContract])
282
+ .init(async () => ({
283
+ async process(amount: number) {
284
+ /* charge credit card... */ return true;
285
+ },
286
+ }))
287
+ .build();
288
+
289
+ // 3. Discover strategy resources via tag dependency
290
+ const paymentProcessor = r
291
+ .resource("app.payment.processor")
292
+ .dependencies({
293
+ paymentStrategyContract,
294
+ runtime: r.system.runtime,
295
+ })
296
+ .init(async (_config, { paymentStrategyContract, runtime }) => ({
297
+ async process(amount: number, method: string) {
298
+ const match = paymentStrategyContract.resources.find((entry) =>
299
+ entry.definition.id.includes(method),
300
+ );
301
+ if (!match) throw new Error("Strategy not found");
302
+
303
+ const strategy = await runtime.getResourceValue(match.definition);
304
+ return strategy.process(amount);
305
+ },
306
+ }))
307
+ .build();
308
+ ```
309
+
310
+ ### Observer Pattern
311
+
312
+ Use events and hooks for decoupled communication.
313
+
314
+ ```ts
315
+ // 1. The subject emits events
316
+ const userRegistered = r
317
+ .event("app.events.userRegistered")
318
+ .payloadSchema({ userId: String })
319
+ .build();
320
+
321
+ const userService = r
322
+ .resource("app.user.service")
323
+ .dependencies({ userRegistered })
324
+ .init(async (_config, { userRegistered }) => ({
325
+ async createUser() {
326
+ const userId = "u1";
327
+ await userRegistered({ userId });
328
+ },
329
+ }))
330
+ .build();
331
+
332
+ // 2. Observers listen with hooks
333
+ const welcomeEmailer = r
334
+ .hook("app.hooks.welcome")
335
+ .on(userRegistered)
336
+ .run(async (e) => console.log(`Sending welcome email to ${e.data.userId}`))
337
+ .build();
338
+ ```
339
+
340
+ ## Key Takeaways
341
+
342
+ 1. **Resources are factories** that return API objects.
343
+ 2. **Closures create privacy** for state and helpers.
344
+ 3. **`dispose` handles cleanup** — the functional destructor.
345
+ 4. **`r.override(base, fn)` replaces behavior while preserving id**.
346
+ 5. **Prefer DI for composition** to keep components decoupled.
347
+ 6. **Contract tags are configurable interfaces** that provide compile-time safety.
348
+ 7. **Each `run()` call is fully isolated** — closure state is never shared between containers.
349
+
350
+ This functional approach gives you the power of OOP without the boilerplate, leading to simpler, more testable, and more composable code.