@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.
package/readmes/OOP.md ADDED
@@ -0,0 +1,627 @@
1
+ # Object-Oriented Programming with BlueLibs Runner
2
+
3
+ ← [Back to main README](../README.md)
4
+
5
+ ---
6
+
7
+ _Or: How to Keep Your Classes and Have Runner Too_
8
+
9
+ Runner doesn't tell you not to use classes. It tells your classes not to depend on a framework. Keep your domain modeled with plain, testable TypeScript classes that depend on **interfaces** — Runner acts as the IoC glue that wires implementations, manages lifecycle, and handles cross-cutting concerns.
10
+
11
+ ## Table of Contents
12
+
13
+ - [Core Philosophy](#core-philosophy)
14
+ - [The Pattern](#the-pattern-interface--class--resource--task)
15
+ - [When Tasks Grow](#when-tasks-grow-from-inline-to-command)
16
+ - [Lifecycle Management](#lifecycle-management)
17
+ - [Cross-Cutting Concerns](#cross-cutting-concerns)
18
+ - [Polymorphism with Contract Tags](#polymorphism-with-contract-tags)
19
+ - [Factory Pattern](#factory-pattern)
20
+ - [Testing](#testing)
21
+ - [Key Takeaways](#key-takeaways)
22
+
23
+ ## Core Philosophy
24
+
25
+ Think of Runner as your **object lifecycle manager** — not your class framework.
26
+
27
+ - **Classes own business logic** and depend on interfaces — no decorators, no reflection, no framework imports.
28
+ - **Resources own lifecycle**: `init()` constructs, `dispose()` destructs, `cooldown()` stops ingress.
29
+ - **Tasks are thin boundaries**: receive input → call class methods → return result.
30
+ - **Middleware handles policies** (retry, timeout, caching) — not your classes.
31
+
32
+ ```ts
33
+ // Bad: framework-coupled class
34
+ @Injectable()
35
+ class UserService {
36
+ constructor(
37
+ @Inject("DATABASE") private db: Database,
38
+ @Inject("LOGGER") private logger: Logger,
39
+ ) {}
40
+ }
41
+
42
+ // Good: plain class, interface-driven
43
+ class UserService {
44
+ constructor(
45
+ private readonly repo: IUserRepository,
46
+ private readonly logger: ILogger,
47
+ ) {}
48
+
49
+ async register(data: UserData): Promise<User> {
50
+ this.logger.info("Registering user", { email: data.email });
51
+ return this.repo.create(data);
52
+ }
53
+ }
54
+ ```
55
+
56
+ Runner wires this class exactly as written — no modifications needed:
57
+
58
+ ```ts
59
+ import { Match, r, resources } from "@bluelibs/runner";
60
+
61
+ const userServiceResource = r
62
+ .resource("app.services.user")
63
+ .dependencies({ repo: userRepository, logger: resources.logger })
64
+ .init(async (_config, { repo, logger }) => new UserService(repo, logger))
65
+ .build();
66
+ ```
67
+
68
+ ## The Pattern: Interface → Class → Resource → Task
69
+
70
+ A complete example showing how the four layers compose. The domain: a user registration system with email notifications.
71
+
72
+ ### 1. Define the Interfaces
73
+
74
+ Your classes depend on these — not on Runner, not on concrete implementations.
75
+
76
+ ```ts
77
+ // interfaces.ts — no Runner imports
78
+ interface IUserRepository {
79
+ create(data: UserData): Promise<User>;
80
+ findById(id: string): Promise<User | null>;
81
+ }
82
+
83
+ interface IMailer {
84
+ send(to: string, subject: string, body: string): Promise<void>;
85
+ }
86
+ ```
87
+
88
+ ### 2. Implement the Classes
89
+
90
+ Plain TypeScript. Portable, testable, framework-free.
91
+
92
+ ```ts
93
+ // user-repository.ts
94
+ class PostgresUserRepository implements IUserRepository {
95
+ constructor(private readonly db: DatabaseClient) {}
96
+
97
+ async create(data: UserData): Promise<User> {
98
+ const row = await this.db.query("INSERT INTO users ...", data);
99
+ return this.toUser(row);
100
+ }
101
+
102
+ async findById(id: string): Promise<User | null> {
103
+ const row = await this.db.query("SELECT * FROM users WHERE id = $1", [id]);
104
+ return row ? this.toUser(row) : null;
105
+ }
106
+
107
+ private toUser(row: DbRow): User {
108
+ return { id: row.id, name: row.name, email: row.email };
109
+ }
110
+ }
111
+
112
+ // mailer.ts
113
+ class SmtpMailer implements IMailer {
114
+ private readonly transport: SmtpTransport;
115
+
116
+ constructor(transport: SmtpTransport) {
117
+ this.transport = transport;
118
+ }
119
+
120
+ async send(to: string, subject: string, body: string): Promise<void> {
121
+ await this.transport.sendMail({ to, subject, html: body });
122
+ }
123
+ }
124
+ ```
125
+
126
+ ### 3. Wire with Resources
127
+
128
+ Resources are the IoC layer. `init()` is your async constructor — connect, authenticate, hydrate, then return the ready instance. `dispose()` is the paired destructor.
129
+
130
+ ```ts
131
+ import { r } from "@bluelibs/runner";
132
+
133
+ // Infrastructure: database connection
134
+ const databaseResource = r
135
+ .resource("app.resources.database")
136
+ .schema({ connectionString: String })
137
+ .init(async ({ connectionString }) => {
138
+ const client = new DatabaseClient(connectionString);
139
+ await client.connect();
140
+ return client;
141
+ })
142
+ .dispose(async (client) => {
143
+ await client.close();
144
+ })
145
+ .build();
146
+
147
+ // Repository: class instance wired with its dependency
148
+ const userRepository = r
149
+ .resource("app.repositories.user")
150
+ .dependencies({ db: databaseResource })
151
+ .init(async (_config, { db }) => new PostgresUserRepository(db))
152
+ .build();
153
+
154
+ // Mailer: class instance with lifecycle
155
+ const mailerResource = r
156
+ .resource("app.resources.mailer")
157
+ .schema({ host: String, port: Number })
158
+ .dependencies({ logger: resources.logger })
159
+ .init(async (config, { logger }) => {
160
+ const transport = await SmtpTransport.create(config);
161
+ logger.info("SMTP connected", { host: config.host });
162
+ return new SmtpMailer(transport);
163
+ })
164
+ .dispose(async (mailer) => {
165
+ await mailer.transport.close();
166
+ })
167
+ .build();
168
+
169
+ // Service: composed from other resources
170
+ const userServiceResource = r
171
+ .resource("app.services.user")
172
+ .dependencies({ repo: userRepository, logger: resources.logger })
173
+ .init(async (_config, { repo, logger }) => new UserService(repo, logger))
174
+ .build();
175
+ ```
176
+
177
+ ### 4. Expose via Tasks
178
+
179
+ Tasks are the boundary layer — thin async functions that receive input, delegate to class-backed resources, and return results. Business logic lives in the class; the task is just the entry point.
180
+
181
+ ```ts
182
+ const registerUser = r
183
+ .task("app.tasks.registerUser")
184
+ .schema({ name: String, email: Match.Email })
185
+ .dependencies({ userService: userServiceResource, mailer: mailerResource })
186
+ .run(async (input, { userService, mailer }) => {
187
+ const user = await userService.register(input);
188
+ await mailer.send(user.email, "Welcome!", `Hello ${user.name}`);
189
+ return user;
190
+ })
191
+ .build();
192
+ ```
193
+
194
+ Notice: the task doesn't contain business logic. It orchestrates — call the service, send the email, return the result. The service owns the domain rules.
195
+
196
+ ### 5. Compose the Application
197
+
198
+ ```ts
199
+ import { run } from "@bluelibs/runner";
200
+
201
+ const app = r
202
+ .resource("app")
203
+ .register([
204
+ databaseResource.with({ connectionString: process.env.DATABASE_URL! }),
205
+ mailerResource.with({ host: "smtp.example.com", port: 587 }),
206
+ userRepository,
207
+ userServiceResource,
208
+ registerUser,
209
+ ])
210
+ .build();
211
+
212
+ const runtime = await run(app);
213
+ await runtime.runTask(registerUser, { name: "Ada", email: "ada@example.com" });
214
+ await runtime.dispose();
215
+ ```
216
+
217
+ ## When Tasks Grow: From Inline to Command
218
+
219
+ Tasks start small — a few lines, a couple of dependencies, done. That's fine. You don't need a class for everything.
220
+
221
+ ```ts
222
+ // Small task: inline is perfectly fine
223
+ const getUser = r
224
+ .task("app.tasks.getUser")
225
+ .schema({ id: String })
226
+ .dependencies({ repo: userRepository })
227
+ .run(async (input, { repo }) => {
228
+ return repo.findById(input.id);
229
+ })
230
+ .build();
231
+ ```
232
+
233
+ But tasks grow. What started as "create a user" becomes "create a user, validate uniqueness, hash the password, send a welcome email, emit an event, and log the audit trail." When a task accumulates business logic and multiple dependencies, extract it into a **command class**:
234
+
235
+ ```ts
236
+ // register-user.command.ts — plain class, no Runner imports
237
+ class RegisterUserCommand {
238
+ constructor(
239
+ private readonly repo: IUserRepository,
240
+ private readonly mailer: IMailer,
241
+ private readonly hasher: IPasswordHasher,
242
+ private readonly logger: ILogger,
243
+ ) {}
244
+
245
+ async execute(input: {
246
+ name: string;
247
+ email: string;
248
+ password: string;
249
+ }): Promise<User> {
250
+ const existing = await this.repo.findByEmail(input.email);
251
+ if (existing) throw new Error("Email already registered");
252
+
253
+ const hashed = await this.hasher.hash(input.password);
254
+ const user = await this.repo.create({ ...input, password: hashed });
255
+
256
+ this.logger.info("User registered", { userId: user.id });
257
+ await this.mailer.send(user.email, "Welcome!", `Hello ${user.name}`);
258
+
259
+ return user;
260
+ }
261
+ }
262
+ ```
263
+
264
+ Wire the command as a resource, expose it through a thin task:
265
+
266
+ ```ts
267
+ const registerUserCommand = r
268
+ .resource("app.commands.registerUser")
269
+ .dependencies({
270
+ repo: userRepository,
271
+ mailer: mailerResource,
272
+ hasher: passwordHasher,
273
+ logger: resources.logger,
274
+ })
275
+ .init(
276
+ async (_config, deps) =>
277
+ new RegisterUserCommand(deps.repo, deps.mailer, deps.hasher, deps.logger),
278
+ )
279
+ .build();
280
+
281
+ const registerUser = r
282
+ .task("app.tasks.registerUser")
283
+ .schema({
284
+ name: String,
285
+ email: Match.Email,
286
+ password: Match.NonEmptyString,
287
+ })
288
+ .dependencies({ command: registerUserCommand })
289
+ .run(async (input, { command }) => command.execute(input))
290
+ .build();
291
+ ```
292
+
293
+ The task stays thin — one line of delegation. The command class owns the business logic, depends on interfaces, and is unit-testable without Runner.
294
+
295
+ **The rule of thumb:** any business-important operation (sending emails, charging payments, writing to a database) should be an injected dependency, not something the task reaches for directly. When the task body starts feeling heavy, that's your signal to extract a command class and let Runner wire its dependencies.
296
+
297
+ ## Lifecycle Management
298
+
299
+ ### init() as Async Constructor
300
+
301
+ Classes often need async setup that constructors can't provide. `init()` bridges this gap:
302
+
303
+ ```ts
304
+ class RedisCache {
305
+ constructor(private readonly client: RedisClient) {}
306
+
307
+ async get(key: string): Promise<string | null> {
308
+ return this.client.get(key);
309
+ }
310
+
311
+ async set(key: string, value: string, ttl: number): Promise<void> {
312
+ await this.client.set(key, value, { EX: ttl });
313
+ }
314
+ }
315
+
316
+ const cacheResource = r
317
+ .resource("app.resources.cache")
318
+ .schema({ url: Match.URL })
319
+ .init(async ({ url }) => {
320
+ const client = createClient({ url });
321
+ await client.connect(); // async — can't do this in a constructor
322
+ await client.ping(); // health check before returning
323
+ return new RedisCache(client);
324
+ })
325
+ .dispose(async (cache) => {
326
+ await cache.client.disconnect();
327
+ })
328
+ .build();
329
+ ```
330
+
331
+ ### cooldown() for Ingress Services
332
+
333
+ For services that accept external traffic (HTTP servers, queue consumers), use `cooldown()` to stop intake before the drain wait. Treat it as an ingress switch — flip it, then let in-flight work finish naturally.
334
+
335
+ ```ts
336
+ class HttpServer {
337
+ constructor(
338
+ readonly app: Express,
339
+ private readonly listener: Server,
340
+ ) {}
341
+
342
+ close(): void {
343
+ this.listener.close();
344
+ }
345
+ }
346
+
347
+ const httpServer = r
348
+ .resource("app.resources.httpServer")
349
+ .schema({ port: Number })
350
+ .init(async ({ port }) => {
351
+ const app = express();
352
+ const listener = app.listen(port);
353
+ return new HttpServer(app, listener);
354
+ })
355
+ .cooldown(async (server) => {
356
+ // Stop accepting new connections so in-flight requests can drain naturally
357
+ server.close();
358
+ })
359
+ .build();
360
+ ```
361
+
362
+ ## Cross-Cutting Concerns
363
+
364
+ ### Before: Policies Baked into Classes
365
+
366
+ This is what you want to avoid — retry, timeout, and logging tangled into business logic:
367
+
368
+ ```ts
369
+ // Bad: the class owns policies it shouldn't care about
370
+ class PaymentGateway {
371
+ async charge(amount: number): Promise<Receipt> {
372
+ let attempt = 0;
373
+ while (attempt < 3) {
374
+ try {
375
+ const timeout = setTimeout(() => {
376
+ throw new Error("Timeout");
377
+ }, 5000);
378
+ const result = await this.client.charge(amount);
379
+ clearTimeout(timeout);
380
+ this.logger.info("Charged", { amount });
381
+ return result;
382
+ } catch (err) {
383
+ attempt++;
384
+ if (attempt >= 3) throw err;
385
+ await delay(1000 * attempt);
386
+ }
387
+ }
388
+ throw new Error("unreachable");
389
+ }
390
+ }
391
+ ```
392
+
393
+ ### After: Policies in Middleware
394
+
395
+ Keep the class focused on its one job. Let Runner middleware handle the rest:
396
+
397
+ ```ts
398
+ // Good: class does one thing
399
+ class PaymentGateway {
400
+ constructor(private readonly client: PaymentClient) {}
401
+
402
+ async charge(amount: number): Promise<Receipt> {
403
+ return this.client.charge(amount);
404
+ }
405
+ }
406
+
407
+ // Resource with middleware for cross-cutting concerns
408
+ const paymentGateway = r
409
+ .resource("app.resources.payment")
410
+ .dependencies({ client: paymentClient })
411
+ .middleware([
412
+ r.runner.middleware.resource.retry.with({ retries: 3, delay: 1000 }),
413
+ r.runner.middleware.resource.timeout.with({ ttl: 5000 }),
414
+ ])
415
+ .init(async (_config, { client }) => new PaymentGateway(client))
416
+ .build();
417
+
418
+ // Task with its own middleware
419
+ const chargeCustomer = r
420
+ .task("app.tasks.chargeCustomer")
421
+ .schema({ customerId: String, amount: Number })
422
+ .dependencies({ gateway: paymentGateway })
423
+ .middleware([
424
+ r.runner.middleware.task.rateLimit.with({ max: 100, windowMs: 60_000 }),
425
+ ])
426
+ .run(async (input, { gateway }) => {
427
+ return gateway.charge(input.amount);
428
+ })
429
+ .build();
430
+ ```
431
+
432
+ Resource middleware protects resource initialization. Task middleware protects task execution. Your classes stay clean.
433
+
434
+ ## Polymorphism with Contract Tags
435
+
436
+ Contract tags let multiple class implementations share an interface — Runner handles discovery via tag dependencies.
437
+
438
+ ```ts
439
+ import { r } from "@bluelibs/runner";
440
+
441
+ // Contract: any resource tagged with this must expose a health() method
442
+ const healthCheckTag = r
443
+ .tag<
444
+ void,
445
+ void,
446
+ { health(): Promise<{ status: string }> }
447
+ >("app.tags.healthCheck")
448
+ .for(["resources"])
449
+ .build();
450
+
451
+ // Two classes, same interface, both tagged
452
+ const databaseResource = r
453
+ .resource("app.resources.database")
454
+ .tags([healthCheckTag])
455
+ .init(async () => new DatabaseService(/* ... */))
456
+ .build();
457
+
458
+ const cacheResource = r
459
+ .resource("app.resources.cache")
460
+ .tags([healthCheckTag])
461
+ .init(async () => new RedisCacheService(/* ... */))
462
+ .build();
463
+
464
+ // Discover all tagged resources via tag dependency injection
465
+ const healthAggregator = r
466
+ .task("app.tasks.healthCheck")
467
+ .dependencies({ healthCheckTag })
468
+ .run(async (_input, { healthCheckTag }) => {
469
+ const results = await Promise.all(
470
+ healthCheckTag.resources.map(async (entry) => ({
471
+ id: entry.definition.id,
472
+ ...(await entry.value.health()),
473
+ })),
474
+ );
475
+ return {
476
+ overall: results.every((r) => r.status === "healthy"),
477
+ services: results,
478
+ };
479
+ })
480
+ .build();
481
+ ```
482
+
483
+ No runtime lookups, no string-based service locators — the tag dependency is fully typed and visibility-filtered.
484
+
485
+ ## Factory Pattern
486
+
487
+ When you need per-call class instances instead of singletons, return a factory function from the resource:
488
+
489
+ ```ts
490
+ class ReportBuilder {
491
+ constructor(
492
+ private readonly locale: string,
493
+ private readonly templates: TemplateEngine,
494
+ ) {}
495
+
496
+ build(data: ReportData): Report {
497
+ const template = this.templates.get(data.type, this.locale);
498
+ return new Report(template.render(data));
499
+ }
500
+ }
501
+
502
+ const reportFactory = r
503
+ .resource("app.factories.report")
504
+ .schema({ defaultLocale: String })
505
+ .dependencies({ templates: templateEngine })
506
+ .init(async (config, { templates }) => {
507
+ // Resource value is a factory function, not a class instance
508
+ return (locale?: string) =>
509
+ new ReportBuilder(locale ?? config.defaultLocale, templates);
510
+ })
511
+ .build();
512
+
513
+ const generateReport = r
514
+ .task("app.tasks.generateReport")
515
+ .schema({
516
+ type: String,
517
+ data: Match.Any,
518
+ locale: Match.Optional(String),
519
+ })
520
+ .dependencies({ createReport: reportFactory })
521
+ .run(async (input, { createReport }) => {
522
+ const builder = createReport(input.locale);
523
+ return builder.build({ type: input.type, data: input.data });
524
+ })
525
+ .build();
526
+ ```
527
+
528
+ ## Testing
529
+
530
+ ### Unit Testing Classes (No Runner)
531
+
532
+ Classes depending on interfaces are trivially testable without Runner:
533
+
534
+ ```ts
535
+ describe("UserService", () => {
536
+ it("should register a user", async () => {
537
+ const mockRepo: IUserRepository = {
538
+ create: jest
539
+ .fn()
540
+ .mockResolvedValue({ id: "1", name: "Ada", email: "ada@test.com" }),
541
+ findById: jest.fn(),
542
+ };
543
+ const mockLogger: ILogger = { info: jest.fn(), error: jest.fn() };
544
+
545
+ const service = new UserService(mockRepo, mockLogger);
546
+ const user = await service.register({
547
+ name: "Ada",
548
+ email: "ada@test.com",
549
+ });
550
+
551
+ expect(user.id).toBe("1");
552
+ expect(mockRepo.create).toHaveBeenCalledWith({
553
+ name: "Ada",
554
+ email: "ada@test.com",
555
+ });
556
+ expect(mockLogger.info).toHaveBeenCalled();
557
+ });
558
+ });
559
+ ```
560
+
561
+ No container, no bootstrap, no lifecycle — just plain construction and assertion.
562
+
563
+ ### Integration Testing with Overrides
564
+
565
+ Test the full wiring by swapping implementations via `r.override(base, fn)`:
566
+
567
+ ```ts
568
+ import { r, run } from "@bluelibs/runner";
569
+
570
+ describe("registerUser task", () => {
571
+ it("should register and email user", async () => {
572
+ const sent: string[] = [];
573
+
574
+ const mockMailer = r.override(mailerResource, async () => ({
575
+ send: async (to: string) => {
576
+ sent.push(to);
577
+ },
578
+ }));
579
+
580
+ const mockDb = r.override(databaseResource, async () => {
581
+ const mockClient = {
582
+ query: jest
583
+ .fn()
584
+ .mockResolvedValue({ id: "1", name: "Ada", email: "ada@test.com" }),
585
+ connect: jest.fn(),
586
+ close: jest.fn(),
587
+ };
588
+ return mockClient;
589
+ });
590
+
591
+ const testApp = r
592
+ .resource("spec.app")
593
+ .register([
594
+ registerUser,
595
+ userServiceResource,
596
+ userRepository,
597
+ mailerResource,
598
+ databaseResource,
599
+ ])
600
+ .overrides([mockMailer, mockDb])
601
+ .build();
602
+
603
+ const runtime = await run(testApp);
604
+ const user = await runtime.runTask(registerUser, {
605
+ name: "Ada",
606
+ email: "ada@test.com",
607
+ });
608
+
609
+ expect(user.name).toBe("Ada");
610
+ expect(sent).toContain("ada@test.com");
611
+
612
+ await runtime.dispose();
613
+ });
614
+ });
615
+ ```
616
+
617
+ ## Key Takeaways
618
+
619
+ 1. **Classes depend on interfaces** — Runner wires the implementations; your classes never know.
620
+ 2. **Resources are the IoC layer** — `init()` constructs, `dispose()` destructs, `cooldown()` stops ingress.
621
+ 3. **Tasks are thin boundaries** — receive input, delegate to class methods, return result. No business logic in tasks.
622
+ 4. **Middleware owns policies** — retry, timeout, caching belong in middleware, not baked into your classes.
623
+ 5. **Contract tags enable polymorphism** — discover tagged implementations without runtime lookups.
624
+ 6. **Test classes directly** — interface-driven classes don't need Runner to be unit-tested.
625
+ 7. **Test wiring with overrides** — `r.override(base, fn)` swaps implementations cleanly for integration tests.
626
+
627
+ In short: **write great classes; let Runner do the wiring.** Your domain stays portable, testable, and framework-free.