@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/.agents/skills/core/SKILL.md +37 -0
- package/.agents/skills/durable-workflows/SKILL.md +117 -0
- package/.agents/skills/remote-lanes/SKILL.md +114 -0
- package/package.json +1 -11
- package/readmes/BENCHMARKS.md +131 -0
- package/readmes/COMPARISON.md +233 -0
- package/readmes/CRITICAL_THINKING.md +49 -0
- package/readmes/DURABLE_WORKFLOWS.md +2270 -0
- package/readmes/DURABLE_WORKFLOWS_AI.md +258 -0
- package/readmes/ENTERPRISE.md +219 -0
- package/readmes/FLUENT_BUILDERS.md +524 -0
- package/readmes/FULL_GUIDE.md +6694 -0
- package/readmes/FUNCTIONAL.md +350 -0
- package/readmes/MULTI_PLATFORM.md +556 -0
- package/readmes/OOP.md +627 -0
- package/readmes/REMOTE_LANES.md +947 -0
- package/readmes/REMOTE_LANES_AI.md +154 -0
- package/readmes/REMOTE_LANES_HTTP_POLICY.md +330 -0
- package/readmes/SERIALIZER_PROTOCOL.md +337 -0
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.
|