outbox-event-bus 1.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.
Files changed (48) hide show
  1. package/README.md +510 -0
  2. package/dist/index.cjs +574 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +238 -0
  5. package/dist/index.d.cts.map +1 -0
  6. package/dist/index.d.mts +238 -0
  7. package/dist/index.d.mts.map +1 -0
  8. package/dist/index.mjs +554 -0
  9. package/dist/index.mjs.map +1 -0
  10. package/docs/API_REFERENCE.md +717 -0
  11. package/docs/CONTRIBUTING.md +43 -0
  12. package/docs/PUBLISHING.md +144 -0
  13. package/docs/docs/API_REFERENCE.md +717 -0
  14. package/docs/docs/CONTRIBUTING.md +43 -0
  15. package/docs/docs/PUBLISHING.md +144 -0
  16. package/docs/docs/images/choose_publisher.png +0 -0
  17. package/docs/docs/images/event_life_cycle.png +0 -0
  18. package/docs/docs/images/problem.png +0 -0
  19. package/docs/docs/images/solution.png +0 -0
  20. package/docs/images/choose_publisher.png +0 -0
  21. package/docs/images/event_life_cycle.png +0 -0
  22. package/docs/images/problem.png +0 -0
  23. package/docs/images/solution.png +0 -0
  24. package/package.json +49 -0
  25. package/src/bus/outbox-event-bus.test.ts +305 -0
  26. package/src/bus/outbox-event-bus.ts +167 -0
  27. package/src/errors/error-reporting.ts +20 -0
  28. package/src/errors/error-utils.ts +3 -0
  29. package/src/errors/errors.test.ts +142 -0
  30. package/src/errors/errors.ts +153 -0
  31. package/src/errors/index.ts +2 -0
  32. package/src/in-memory-outbox/in-memory-outbox.ts +129 -0
  33. package/src/index.ts +10 -0
  34. package/src/outboxes/in-memory/failed-events.test.ts +64 -0
  35. package/src/outboxes/in-memory/in-memory-outbox-unit.test.ts +150 -0
  36. package/src/outboxes/in-memory/in-memory-outbox.test.ts +380 -0
  37. package/src/outboxes/in-memory/in-memory-outbox.ts +128 -0
  38. package/src/services/event-publisher.test.ts +93 -0
  39. package/src/services/event-publisher.ts +75 -0
  40. package/src/services/polling-service.test.ts +80 -0
  41. package/src/services/polling-service.ts +92 -0
  42. package/src/types/interfaces.ts +80 -0
  43. package/src/types/types.ts +57 -0
  44. package/src/utils/batcher.ts +64 -0
  45. package/src/utils/promise-utils.ts +13 -0
  46. package/src/utils/retry.ts +23 -0
  47. package/src/utils/time-utils.test.ts +97 -0
  48. package/src/utils/time-utils.ts +47 -0
package/README.md ADDED
@@ -0,0 +1,510 @@
1
+ # outbox-event-bus
2
+
3
+ <div align="center">
4
+
5
+ ![npm version](https://img.shields.io/npm/v/outbox-event-bus?style=flat-square&color=2563eb)
6
+ ![npm downloads](https://img.shields.io/npm/dm/outbox-event-bus?style=flat-square&color=2563eb)
7
+ ![npm bundle size](https://img.shields.io/bundlephobia/minzip/outbox-event-bus?style=flat-square&color=2563eb)
8
+ ![license](https://img.shields.io/npm/l/outbox-event-bus?style=flat-square&color=2563eb)
9
+
10
+ </div>
11
+
12
+ > **Never Lose an Event Again**
13
+ >
14
+ > Transactional outbox pattern made simple. Persist events atomically with your data. Guaranteed delivery with your database.
15
+
16
+ **The Problem**: You save data to your database and attempt to emit a relevant event. If your process crashes or the network fails before the event is sent, your system becomes inconsistent.
17
+
18
+ ![The Dual Write Problem](./docs/images/problem.png)
19
+
20
+ **The Solution**: `outbox-event-bus` stores events in your database *within the same transaction* as your data. A background worker then reliably delivers them.
21
+
22
+ ![The Outbox Solution](./docs/images/solution.png)
23
+
24
+ ## Quick Start (Postgres + Drizzle ORM + SQS Example)
25
+
26
+ ```bash
27
+ npm install outbox-event-bus @outbox-event-bus/postgres-drizzle-outbox drizzle-orm @outbox-event-bus/sqs-publisher
28
+ ```
29
+
30
+ ```typescript
31
+ import { OutboxEventBus } from 'outbox-event-bus';
32
+ import { PostgresDrizzleOutbox } from '@outbox-event-bus/postgres-drizzle-outbox';
33
+ import { SQSPublisher } from '@outbox-event-bus/sqs-publisher';
34
+
35
+ // 1. Setup
36
+ const outbox = new PostgresDrizzleOutbox({ db });
37
+ const bus = new OutboxEventBus(outbox, (error: OutboxError) => console.error(error));
38
+
39
+ // Forward messages to SQS
40
+ const sqsClient = new SQSClient({ region: 'us-east-1' });
41
+ const publisher = new SQSPublisher(bus, { queueUrl: '...', sqsClient });
42
+ publisher.subscribe(['sync-to-sqs']);
43
+
44
+ // 2. Register Handlers
45
+ bus.on('user.created', async (event) => {
46
+ // Fan Out as needed
47
+ await bus.emitMany([
48
+ { type: 'send.welcome', payload: event.payload },
49
+ { type: 'sync-to-sqs', payload: event.payload }
50
+ ]);
51
+ });
52
+
53
+ bus.on('send.welcome', async (event) => {
54
+ await emailService.sendWelcome(event.payload.email);
55
+ });
56
+
57
+
58
+ // 3. Start the Bus
59
+ bus.start();
60
+
61
+ // 4. Emit Transactionally
62
+ await db.transaction(async (transaction) => {
63
+ const [user] = await transaction.insert(users).values(newUser).returning();
64
+
65
+ // Both operations commit together or rollback together
66
+ await bus.emit({ type: 'user.created', payload: user }, transaction);
67
+ });
68
+ ```
69
+
70
+ ## Why outbox-event-bus?
71
+
72
+ - **Zero Event Loss**: Events persist atomically with your data using database transactions.
73
+ - **Storage Agnostic**: Works with any database. **Use our built-in adapters** for Postgres, MongoDB, DynamoDB, Redis, and SQLite, or create your own.
74
+ - **Guaranteed Delivery**: At-least-once semantics with exponential backoff and dead letter handling.
75
+ - **Safe Retries**: Strict 1:1 command bus pattern prevents duplicate side-effects.
76
+ - **Built-in Publishers**: Comes with optional publishers for SQS, SNS, Kafka, RabbitMQ, Redis Streams, and EventBridge
77
+ - **Typed Error Handling**: Comprehensive typed errors for precise control over failure scenarios and recovery strategies.
78
+ - **TypeScript First**: Full type safety with generics for events, payloads, and transactions.
79
+
80
+ ## Contents
81
+
82
+ - [Concepts](#concepts)
83
+ - [How-To Guides](#how-to-guides)
84
+ - [API Reference](./docs/API_REFERENCE.md)
85
+ - [Adapters & Publishers](#adapters--publishers)
86
+ - [Production Guide](#production-guide)
87
+ - [Contributing](./docs/CONTRIBUTING.md)
88
+ - [License](#license)
89
+
90
+
91
+
92
+ ## Concepts
93
+
94
+ ### Strict 1:1 Command Bus Pattern
95
+
96
+ > [!IMPORTANT]
97
+ > This library enforces a **Command Bus pattern**: Each event type can have exactly **one** handler.
98
+
99
+ **Why?**
100
+ - If you have 2 handlers and one fails, retrying the event would re-run the successful handler too (double side-effects)
101
+ - Example: `user.created` triggers "Send Welcome Email", "Send Analytics" and "Sync to SQS". If "Send Analytics" fails and retries, "Send Welcome Email" runs again → duplicate emails
102
+
103
+ **Solution:** Strict 1:1 binding ensures that if a handler fails, only that specific logic is retried.
104
+
105
+ **Fan-Out Pattern:** If you need multiple actions, publish new "intent events" from your handler:
106
+
107
+ ```typescript
108
+ // Main handler
109
+ bus.on('user.created', async (event) => {
110
+ // Fan-out: Publish intent events back to the outbox
111
+ await bus.emitMany([
112
+ { type: 'send.welcome', payload: event.payload },
113
+ { type: 'send.analytics', payload: event.payload },
114
+ { type: 'sync-to-sqs', payload: event.payload }
115
+ ]);
116
+ });
117
+
118
+ // Specialized handlers (1:1)
119
+ bus.on('send.welcome', async (event) => {
120
+ await emailService.sendWelcome(event.payload.email);
121
+ });
122
+
123
+ bus.on('send.analytics', async (event) => {
124
+ await analyticsService.track(event.payload);
125
+ });
126
+ ```
127
+
128
+ ### Event Lifecycle
129
+
130
+ Events flow through several states from creation to completion:
131
+
132
+ ![Event Lifecycle](./docs/images/event_life_cycle.png)
133
+
134
+ **State Descriptions:**
135
+
136
+ | State | Description | Next States |
137
+ |:---|:---|:---|
138
+ | `created` | Event saved to outbox, waiting to be processed | `active` (claimed by worker) |
139
+ | `active` | Event claimed by worker, handler executing | `completed` (success), `failed` (error), `active` (timeout) |
140
+ | `completed` | Handler succeeded, event ready for archival | `archived` (maintenance) |
141
+ | `failed` | Error occurred (retry pending or max retries exceeded) | `active` (retry), Manual intervention (max retries) |
142
+
143
+ **Stuck Event Recovery:**
144
+
145
+ If a worker crashes while processing an event (status: `active`), the event becomes "stuck". The outbox adapter automatically reclaims stuck events after `processingTimeoutMs` (default: 30s).
146
+
147
+ **SQL Example (Postgres/SQLite):**
148
+ ```sql
149
+ -- Events stuck for more than 30 seconds are reclaimed
150
+ SELECT * FROM outbox_events
151
+ WHERE status = 'active'
152
+ AND keep_alive < NOW() - INTERVAL '30 seconds';
153
+ ```
154
+
155
+ > **Note:** Non-SQL adapters (DynamoDB, Redis, Mongo) implement equivalent recovery mechanisms using their native features (TTL, Sorted Sets, etc).
156
+
157
+ ## How-To Guides
158
+
159
+ ### Working with Transactions (Prisma + Postgres Example)
160
+
161
+ ```typescript
162
+ import { PrismaClient } from '@prisma/client';
163
+ import { PostgresPrismaOutbox } from '@outbox-event-bus/postgres-prisma-outbox';
164
+ import { OutboxEventBus } from 'outbox-event-bus';
165
+
166
+ const prisma = new PrismaClient();
167
+ const outbox = new PostgresPrismaOutbox({ prisma });
168
+ const bus = new OutboxEventBus(outbox, (error) => console.error(error));
169
+
170
+ bus.start();
171
+
172
+ // Register handler
173
+ bus.on('user.created', async (event) => {
174
+ await emailService.sendWelcome(event.payload.email);
175
+ });
176
+
177
+ // Emit within a transaction
178
+ async function createUser(userData: any) {
179
+ await prisma.$transaction(async (transaction) => {
180
+ // 1. Create user
181
+ const user = await transaction.user.create({ data: userData });
182
+
183
+ // 2. Emit event (will commit with the user creation)
184
+ await bus.emit({ type: 'user.created', payload: user }, transaction);
185
+ });
186
+ }
187
+ ```
188
+
189
+ ### Async Transactions (SQLite + better-sqlite3 Example)
190
+
191
+ SQLite transactions are synchronous by default. To use `await` with the event bus, use the `withBetterSqlite3Transaction` helper which manages the transaction scope for you.
192
+
193
+ ```typescript
194
+ import Database from 'better-sqlite3';
195
+ import { SqliteBetterSqlite3Outbox, withBetterSqlite3Transaction, getBetterSqlite3Transaction } from '@outbox-event-bus/sqlite-better-sqlite3-outbox';
196
+ import { OutboxEventBus } from 'outbox-event-bus';
197
+
198
+ const db = new Database('app.db');
199
+ const outbox = new SqliteBetterSqlite3Outbox({
200
+ dbPath: 'app.db',
201
+ getTransaction: getBetterSqlite3Transaction()
202
+ });
203
+ const bus = new OutboxEventBus(outbox, (error) => console.error(error));
204
+
205
+ bus.start();
206
+
207
+ async function createUser(userData: any) {
208
+ return withBetterSqlite3Transaction(db, async (transaction) => {
209
+ const stmt = transaction.prepare('INSERT INTO users (name) VALUES (?)');
210
+ const info = stmt.run(userData.name);
211
+
212
+ await bus.emit({
213
+ type: 'user.created',
214
+ payload: { id: info.lastInsertRowid, ...userData }
215
+ });
216
+
217
+ return info;
218
+ });
219
+ }
220
+ ```
221
+
222
+ > **Note:** Similar helpers (`withPrismaTransaction`, `withDrizzleTransaction`, `withMongodbTransaction`, etc.) are available in other adapters to simplify transaction management and avoid passing transaction objects manually.
223
+
224
+ ### Environment-Specific Adapters
225
+
226
+ ```typescript
227
+ import { InMemoryOutbox } from 'outbox-event-bus';
228
+ import { PostgresPrismaOutbox } from '@outbox-event-bus/postgres-prisma-outbox';
229
+
230
+ const outbox = process.env.NODE_ENV === 'production'
231
+ ? new PostgresPrismaOutbox({ prisma })
232
+ : new InMemoryOutbox();
233
+
234
+ const bus = new OutboxEventBus(outbox, (error) => console.error(error));
235
+ ```
236
+
237
+ ### Testing Event Handlers
238
+
239
+ **Problem:** How do I test event-driven code without a real database?
240
+
241
+ **Solution:** Use `InMemoryOutbox` and `waitFor`:
242
+
243
+ ```typescript
244
+ import { describe, it, expect } from 'vitest';
245
+ import { OutboxEventBus, InMemoryOutbox } from 'outbox-event-bus';
246
+
247
+ describe('User Creation', () => {
248
+ it('sends welcome email when user is created', async () => {
249
+ const outbox = new InMemoryOutbox();
250
+ const bus = new OutboxEventBus(outbox, (error) => console.error(error));
251
+
252
+ let emailSent = false;
253
+ bus.on('user.created', async (event) => {
254
+ emailSent = true;
255
+ });
256
+
257
+ bus.start();
258
+
259
+ await bus.emit({ type: 'user.created', payload: { email: 'test@example.com' } });
260
+ await bus.waitFor('user.created');
261
+
262
+ expect(emailSent).toBe(true);
263
+
264
+ await bus.stop();
265
+ });
266
+ });
267
+ ```
268
+
269
+ ### Error Handling
270
+
271
+ The library provides typed errors to help you handle specific failure scenarios programmatically. All errors extend the base `OutboxError` class.
272
+
273
+ - **Configuration Errors**: `DuplicateListenerError`, `UnsupportedOperationError`
274
+ - **Validation Errors**: `BatchSizeLimitError`
275
+ - **Operational Errors**: `TimeoutError`, `BackpressureError`, `MaxRetriesExceededError`, `HandlerError`
276
+
277
+ #### Example
278
+
279
+ ```typescript
280
+ import { OutboxEventBus, MaxRetriesExceededError } from 'outbox-event-bus';
281
+
282
+ const bus = new OutboxEventBus(outbox, (error: OutboxError) => {
283
+ // error is always an OutboxError instance
284
+ const event = error.context?.event;
285
+
286
+ if (error instanceof MaxRetriesExceededError) {
287
+ console.error(`Event ${event?.id} permanently failed after ${error.retryCount} attempts`);
288
+ console.error('Original error:', error.cause);
289
+ } else {
290
+ console.error(`Event ${event?.id} failed:`, error.message);
291
+ }
292
+ });
293
+ ```
294
+
295
+ For a complete list and usage examples, see the [API Reference](./docs/API_REFERENCE.md).
296
+
297
+ ### Monitoring & Debugging
298
+
299
+ **Problem:** How do I monitor event processing errors in production?
300
+
301
+ **Solution:** Use the `onError` callback for infrastructure errors and query the outbox table for failed events (DLQ).
302
+
303
+ > **Note:** The `onError` callback captures unexpected errors (e.g., database connection loss, handler crashes). Events that simply fail processing and are retried are not considered "errors" until they exceed max retries, at which point they are marked as `failed` in the database.
304
+
305
+ ```typescript
306
+ const bus = new OutboxEventBus(outbox, (error: OutboxError) => {
307
+ const event = error.context?.event;
308
+
309
+ // Send to monitoring service
310
+ logger.error('Event processing failed', {
311
+ eventId: event?.id,
312
+ eventType: event?.type,
313
+ errorMessage: error.message,
314
+ errorName: error.name
315
+ });
316
+
317
+ // Send to error tracking
318
+ if (error instanceof MaxRetriesExceededError) {
319
+ Sentry.captureException(error, {
320
+ tags: {
321
+ eventType: event?.type,
322
+ retryCount: error.retryCount
323
+ },
324
+ extra: error.context
325
+ });
326
+ }
327
+ });
328
+ ```
329
+
330
+ **Query failed events:**
331
+ ```typescript
332
+ // Get failed events (if supported by adapter)
333
+ const failedEvents = await bus.getFailedEvents();
334
+ ```
335
+
336
+ Or via SQL:
337
+ ```sql
338
+ SELECT * FROM outbox_events
339
+ WHERE status = 'failed'
340
+ ORDER BY created_on DESC
341
+ LIMIT 10;
342
+ ```
343
+
344
+ ### Handling Failures
345
+
346
+ **Problem:** An event failed after max retries. How do I retry it?
347
+
348
+ **Solution:** Use `retryEvents` or reset via SQL.
349
+
350
+ **Using API:**
351
+ ```typescript
352
+ const failedEvents = await bus.getFailedEvents();
353
+ const idsToRetry = failedEvents.map(e => e.id);
354
+ await bus.retryEvents(idsToRetry);
355
+ ```
356
+
357
+ **Using SQL:**
358
+ ```sql
359
+ -- Reset a specific event
360
+ UPDATE outbox_events
361
+ SET status = 'created', retry_count = 0, last_error = NULL
362
+ WHERE id = 'event-id-here';
363
+
364
+ -- Reset all failed events of a type
365
+ UPDATE outbox_events
366
+ SET status = 'created', retry_count = 0, last_error = NULL
367
+ WHERE status = 'failed' AND type = 'user.created';
368
+ ```
369
+
370
+ ### Schema Evolution
371
+
372
+ **Problem:** I need to change my event payload structure. How do I handle old events?
373
+
374
+ **Solution:** Use versioned event types and handlers:
375
+
376
+ ```typescript
377
+ // Old handler (still processes legacy events)
378
+ bus.on('user.created.v1', async (event) => {
379
+ const { firstName, lastName } = event.payload;
380
+ await emailService.send({ name: `${firstName} ${lastName}` });
381
+ });
382
+
383
+ // New handler (processes new events)
384
+ bus.on('user.created.v2', async (event) => {
385
+ const { fullName } = event.payload;
386
+ await emailService.send({ name: fullName });
387
+ });
388
+
389
+ // Emit new version
390
+ await bus.emit({ type: 'user.created.v2', payload: { fullName: 'John Doe' } });
391
+ ```
392
+
393
+ ## Adapters & Publishers
394
+
395
+ Mix and match any storage adapter with any publisher.
396
+
397
+ ### Storage Adapters (The "Outbox")
398
+
399
+ These store your events. Choose one that matches your primary database.
400
+
401
+ | Database | Adapters | Transaction Support | Concurrency |
402
+ |:---|:---|:---:|:---|
403
+ | **Postgres** | [Prisma](./adapters/postgres-prisma/README.md), [Drizzle](./adapters/postgres-drizzle/README.md) | Full (ACID) | `SKIP LOCKED` |
404
+ | **MongoDB** | [Native Driver](./adapters/mongo-mongodb/README.md) | Full (Replica Set) | Optimistic Locking |
405
+ | **DynamoDB** | [AWS SDK](./adapters/dynamodb-aws-sdk/README.md) | Full (TransactWrite) | Optimistic Locking |
406
+ | **Redis** | [ioredis](./adapters/redis-ioredis/README.md) | Atomic (Multi/Exec) | Distributed Lock |
407
+ | **SQLite** | [better-sqlite3](./adapters/sqlite-better-sqlite3/README.md) | Full (ACID) | Serialized |
408
+
409
+ **Legend:**
410
+
411
+ - **Full**: ACID transactions with atomicity guarantees
412
+ - **Limited**: Single-document transactions or optimistic locking
413
+ - **None**: No transaction support (events saved separately)
414
+ - **SKIP LOCKED**: High-performance non-blocking reads for multiple workers
415
+
416
+ ### Publishers
417
+
418
+ These send your events to the world.
419
+
420
+ | Publisher | Target | Batching | Package |
421
+ |:---|:---|:---:|:---|
422
+ | **[AWS SQS](./publishers/sqs/README.md)** | Amazon SQS Queues | Yes (10) | `@outbox-event-bus/sqs-publisher` |
423
+ | **[AWS SNS](./publishers/sns/README.md)** | Amazon SNS Topics | Yes (10) | `@outbox-event-bus/sns-publisher` |
424
+ | **[EventBridge](./publishers/eventbridge/README.md)** | AWS Event Bus | Yes (10) | `@outbox-event-bus/eventbridge-publisher` |
425
+ | **[RabbitMQ](./publishers/rabbitmq/README.md)** | AMQP Brokers | Yes (Configurable) | `@outbox-event-bus/rabbitmq-publisher` |
426
+ | **[Kafka](./publishers/kafka/README.md)** | Streaming | Yes (Configurable) | `@outbox-event-bus/kafka-publisher` |
427
+ | **[Redis Streams](./publishers/redis-streams/README.md)** | Lightweight Stream | Yes (Configurable) | `@outbox-event-bus/redis-streams-publisher` |
428
+
429
+
430
+ ### Choosing the Right Publisher
431
+
432
+ ![Choose Publisher](./docs/images/choose_publisher.png)
433
+
434
+ ## Production Guide
435
+
436
+ > [!TIP]
437
+ > Start with conservative settings and tune based on your metrics. It's easier to increase throughput than to debug overload issues.
438
+
439
+ ### Deployment Checklist
440
+
441
+ - [ ] **Database Schema**: Ensure outbox tables are created and migrated
442
+ - [ ] **Connection Pooling**: Size your connection pool for concurrent workers
443
+ - [ ] **Error Monitoring**: Set up error tracking (Sentry, Datadog, etc.)
444
+ - [ ] **Metrics**: Track event processing rates, retry counts, failure rates
445
+ - [ ] **Archiving**: Configure automatic archiving of completed events
446
+ - [ ] **Scaling**: Plan for horizontal scaling (multiple workers)
447
+
448
+ ### Monitoring
449
+
450
+ **Key Metrics to Track:**
451
+
452
+ 1. **Event Processing Rate**: Events/second processed
453
+ 2. **Retry Rate**: Percentage of events requiring retries
454
+ 3. **Failure Rate**: Percentage of events failing after max retries
455
+ 4. **Processing Latency**: Time from event creation to successful delivery
456
+ 5. **Queue Depth**: Number of pending events in the outbox
457
+
458
+
459
+ ### Scaling
460
+
461
+ **Horizontal Scaling:**
462
+
463
+ Run multiple instances of your application. Each instance runs its own poller. The outbox adapter handles coordination using:
464
+
465
+ - **Row-level locking** (Postgres, SQLite): `FOR UPDATE SKIP LOCKED`
466
+ - **Optimistic locking** (MongoDB, DynamoDB): Version fields
467
+ - **Distributed locks** (Redis): Redlock algorithm
468
+
469
+ **Vertical Scaling:**
470
+
471
+ Increase `batchSize` and reduce `pollIntervalMs` for higher throughput:
472
+
473
+ ```typescript
474
+ const outbox = new PostgresPrismaOutbox({
475
+ prisma,
476
+ batchSize: 100, // Process 100 events per poll
477
+ pollIntervalMs: 500 // Poll every 500ms
478
+ });
479
+ ```
480
+
481
+ ### Security
482
+
483
+ **Best Practices:**
484
+
485
+ 1. **Encrypt Sensitive Payloads**: Use application-level encryption for PII
486
+ 2. **IAM Permissions**: Grant minimal permissions to publishers (e.g., `sqs:SendMessage` only)
487
+ 3. **Network Security**: Use VPC endpoints for AWS services
488
+ 4. **Audit Logging**: Log all event emissions and processing
489
+
490
+ **Example: Encrypting Payloads**
491
+
492
+ Essential when forwarding events to external systems (SQS, Kafka) or to protect PII stored in the `outbox_events` table.
493
+
494
+ ```typescript
495
+ import { encrypt, decrypt } from './crypto';
496
+
497
+ await bus.emit({
498
+ type: 'user.created',
499
+ payload: encrypt(user) // Encrypt before saving
500
+ });
501
+
502
+ bus.on('user.created', async (event) => {
503
+ const user = decrypt(event.payload); // Decrypt in handler
504
+ await emailService.send(user.email);
505
+ });
506
+ ```
507
+
508
+ ## License
509
+
510
+ MIT © [Dunika](https://github.com/dunika)