@rolandsall24/nest-mediator 0.7.0 → 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.
- package/README.md +650 -1349
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/aggregate/aggregate-repository.base.d.ts +90 -0
- package/dist/lib/aggregate/aggregate-repository.base.d.ts.map +1 -0
- package/dist/lib/aggregate/aggregate-repository.base.js +161 -0
- package/dist/lib/aggregate/aggregate-repository.base.js.map +1 -0
- package/dist/lib/aggregate/aggregate-root.base.d.ts +105 -0
- package/dist/lib/aggregate/aggregate-root.base.d.ts.map +1 -0
- package/dist/lib/aggregate/aggregate-root.base.js +127 -0
- package/dist/lib/aggregate/aggregate-root.base.js.map +1 -0
- package/dist/lib/aggregate/index.d.ts +3 -0
- package/dist/lib/aggregate/index.d.ts.map +1 -0
- package/dist/lib/aggregate/index.js +19 -0
- package/dist/lib/aggregate/index.js.map +1 -0
- package/dist/lib/context/index.d.ts +2 -0
- package/dist/lib/context/index.d.ts.map +1 -0
- package/dist/lib/context/index.js +18 -0
- package/dist/lib/context/index.js.map +1 -0
- package/dist/lib/context/mediator-context.d.ts +78 -0
- package/dist/lib/context/mediator-context.d.ts.map +1 -0
- package/dist/lib/context/mediator-context.js +93 -0
- package/dist/lib/context/mediator-context.js.map +1 -0
- package/dist/lib/decorators/domain-event.decorator.d.ts +48 -0
- package/dist/lib/decorators/domain-event.decorator.d.ts.map +1 -0
- package/dist/lib/decorators/domain-event.decorator.js +60 -0
- package/dist/lib/decorators/domain-event.decorator.js.map +1 -0
- package/dist/lib/decorators/for-aggregate.decorator.d.ts +21 -0
- package/dist/lib/decorators/for-aggregate.decorator.d.ts.map +1 -0
- package/dist/lib/decorators/for-aggregate.decorator.js +29 -0
- package/dist/lib/decorators/for-aggregate.decorator.js.map +1 -0
- package/dist/lib/decorators/index.d.ts +2 -0
- package/dist/lib/decorators/index.d.ts.map +1 -1
- package/dist/lib/decorators/index.js +2 -0
- package/dist/lib/decorators/index.js.map +1 -1
- package/dist/lib/event-store/aggregate-info.extractor.d.ts +35 -0
- package/dist/lib/event-store/aggregate-info.extractor.d.ts.map +1 -0
- package/dist/lib/event-store/aggregate-info.extractor.js +41 -0
- package/dist/lib/event-store/aggregate-info.extractor.js.map +1 -0
- package/dist/lib/event-store/event-store-persistence.consumer.d.ts +33 -0
- package/dist/lib/event-store/event-store-persistence.consumer.d.ts.map +1 -0
- package/dist/lib/event-store/event-store-persistence.consumer.js +79 -0
- package/dist/lib/event-store/event-store-persistence.consumer.js.map +1 -0
- package/dist/lib/event-store/index.d.ts +6 -0
- package/dist/lib/event-store/index.d.ts.map +1 -0
- package/dist/lib/event-store/index.js +22 -0
- package/dist/lib/event-store/index.js.map +1 -0
- package/dist/lib/event-store/repositories/postgres-event-store.repository.d.ts +49 -0
- package/dist/lib/event-store/repositories/postgres-event-store.repository.d.ts.map +1 -0
- package/dist/lib/event-store/repositories/postgres-event-store.repository.js +145 -0
- package/dist/lib/event-store/repositories/postgres-event-store.repository.js.map +1 -0
- package/dist/lib/event-store/schema/postgres.schema.d.ts +15 -0
- package/dist/lib/event-store/schema/postgres.schema.d.ts.map +1 -0
- package/dist/lib/event-store/schema/postgres.schema.js +68 -0
- package/dist/lib/event-store/schema/postgres.schema.js.map +1 -0
- package/dist/lib/event-store/strategies/abstract-postgres-connection.strategy.d.ts +21 -0
- package/dist/lib/event-store/strategies/abstract-postgres-connection.strategy.d.ts.map +1 -0
- package/dist/lib/event-store/strategies/abstract-postgres-connection.strategy.js +36 -0
- package/dist/lib/event-store/strategies/abstract-postgres-connection.strategy.js.map +1 -0
- package/dist/lib/event-store/strategies/custom-repository.strategy.d.ts +20 -0
- package/dist/lib/event-store/strategies/custom-repository.strategy.d.ts.map +1 -0
- package/dist/lib/event-store/strategies/custom-repository.strategy.js +38 -0
- package/dist/lib/event-store/strategies/custom-repository.strategy.js.map +1 -0
- package/dist/lib/event-store/strategies/event-store-strategy.interface.d.ts +10 -0
- package/dist/lib/event-store/strategies/event-store-strategy.interface.d.ts.map +1 -0
- package/dist/lib/event-store/strategies/event-store-strategy.interface.js +3 -0
- package/dist/lib/event-store/strategies/event-store-strategy.interface.js.map +1 -0
- package/dist/lib/event-store/strategies/existing-pool.strategy.d.ts +14 -0
- package/dist/lib/event-store/strategies/existing-pool.strategy.d.ts.map +1 -0
- package/dist/lib/event-store/strategies/existing-pool.strategy.js +24 -0
- package/dist/lib/event-store/strategies/existing-pool.strategy.js.map +1 -0
- package/dist/lib/event-store/strategies/index.d.ts +17 -0
- package/dist/lib/event-store/strategies/index.d.ts.map +1 -0
- package/dist/lib/event-store/strategies/index.js +50 -0
- package/dist/lib/event-store/strategies/index.js.map +1 -0
- package/dist/lib/event-store/strategies/persistence-consumer.strategy.d.ts +11 -0
- package/dist/lib/event-store/strategies/persistence-consumer.strategy.d.ts.map +1 -0
- package/dist/lib/event-store/strategies/persistence-consumer.strategy.js +32 -0
- package/dist/lib/event-store/strategies/persistence-consumer.strategy.js.map +1 -0
- package/dist/lib/event-store/strategies/postgres-schema-manager.d.ts +11 -0
- package/dist/lib/event-store/strategies/postgres-schema-manager.d.ts.map +1 -0
- package/dist/lib/event-store/strategies/postgres-schema-manager.js +25 -0
- package/dist/lib/event-store/strategies/postgres-schema-manager.js.map +1 -0
- package/dist/lib/event-store/strategies/schema-manager.interface.d.ts +10 -0
- package/dist/lib/event-store/strategies/schema-manager.interface.d.ts.map +1 -0
- package/dist/lib/{interfaces/event-handler.interface.js → event-store/strategies/schema-manager.interface.js} +1 -1
- package/dist/lib/event-store/strategies/schema-manager.interface.js.map +1 -0
- package/dist/lib/event-store/strategies/url-connection.strategy.d.ts +14 -0
- package/dist/lib/event-store/strategies/url-connection.strategy.d.ts.map +1 -0
- package/dist/lib/event-store/strategies/url-connection.strategy.js +26 -0
- package/dist/lib/event-store/strategies/url-connection.strategy.js.map +1 -0
- package/dist/lib/interfaces/event-consumer.interface.d.ts +90 -11
- package/dist/lib/interfaces/event-consumer.interface.d.ts.map +1 -1
- package/dist/lib/interfaces/event-store.interface.d.ts +157 -0
- package/dist/lib/interfaces/event-store.interface.d.ts.map +1 -0
- package/dist/lib/interfaces/event-store.interface.js +23 -0
- package/dist/lib/interfaces/event-store.interface.js.map +1 -0
- package/dist/lib/interfaces/index.d.ts +1 -0
- package/dist/lib/interfaces/index.d.ts.map +1 -1
- package/dist/lib/interfaces/index.js +1 -0
- package/dist/lib/interfaces/index.js.map +1 -1
- package/dist/lib/nest-mediator.module.d.ts +20 -0
- package/dist/lib/nest-mediator.module.d.ts.map +1 -1
- package/dist/lib/nest-mediator.module.js +54 -6
- package/dist/lib/nest-mediator.module.js.map +1 -1
- package/dist/lib/services/event.bus.d.ts +35 -7
- package/dist/lib/services/event.bus.d.ts.map +1 -1
- package/dist/lib/services/event.bus.js +133 -56
- package/dist/lib/services/event.bus.js.map +1 -1
- package/dist/lib/services/mediator.bus.d.ts +13 -4
- package/dist/lib/services/mediator.bus.d.ts.map +1 -1
- package/dist/lib/services/mediator.bus.js +35 -5
- package/dist/lib/services/mediator.bus.js.map +1 -1
- package/package.json +44 -7
- package/dist/lib/interfaces/event-handler.interface.d.ts +0 -24
- package/dist/lib/interfaces/event-handler.interface.d.ts.map +0 -1
- package/dist/lib/interfaces/event-handler.interface.js.map +0 -1
- package/dist/tsconfig.lib.tsbuildinfo +0 -1
package/README.md
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
# NestJS Mediator
|
|
2
2
|
|
|
3
|
-
A lightweight CQRS
|
|
3
|
+
A lightweight CQRS mediator for NestJS — start simple, add event persistence when you need it, scale to full event sourcing when you're ready.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
- **
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
- Zero
|
|
7
|
+
- **CQRS** — Commands, Queries, and Domain Events with type-safe handlers
|
|
8
|
+
- **Three architecture modes** — Simple (no DB), Audit (event log), Source (event sourcing)
|
|
9
|
+
- **Zero-boilerplate event sourcing** — `@ForAggregate` + `@DomainEvent` eliminate all wiring
|
|
10
|
+
- **Critical & Non-Critical consumers** — Sequential saga-style or fire-and-forget
|
|
11
|
+
- **Saga compensation** — Automatic rollback via `applyCompensatingEvent()`
|
|
12
|
+
- **Pipeline Behaviors** — Cross-cutting concerns (logging, validation, caching, retry)
|
|
13
|
+
- **Flexible Event Store** — PostgreSQL-backed, bring your own pool or repository
|
|
14
|
+
- **Optimistic Concurrency** — Sequence-based version control with `ConcurrencyError`
|
|
15
|
+
- **Correlation & Causation IDs** — Automatic distributed tracing via `AsyncLocalStorage`
|
|
16
|
+
- **Zero config** — Decorator-based auto-discovery, built on NestJS DI
|
|
17
17
|
|
|
18
18
|
## Installation
|
|
19
19
|
|
|
@@ -21,9 +21,7 @@ A lightweight CQRS (Command Query Responsibility Segregation) mediator pattern i
|
|
|
21
21
|
npm install @rolandsall24/nest-mediator
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
This library requires TypeScript decorators to be enabled. Add the following to your `tsconfig.json`:
|
|
24
|
+
**TypeScript configuration** — enable decorators in `tsconfig.json`:
|
|
27
25
|
|
|
28
26
|
```json
|
|
29
27
|
{
|
|
@@ -34,296 +32,303 @@ This library requires TypeScript decorators to be enabled. Add the following to
|
|
|
34
32
|
}
|
|
35
33
|
```
|
|
36
34
|
|
|
37
|
-
##
|
|
38
|
-
|
|
39
|
-
Version 0.7.0 introduces **Domain Events** with Critical and Non-Critical consumer support.
|
|
40
|
-
|
|
41
|
-
### What's New
|
|
42
|
-
|
|
43
|
-
- `IEvent` interface for domain events
|
|
44
|
-
- `IEventConsumer<TEvent>` interface for event consumers
|
|
45
|
-
- `ICriticalEventConsumer<TEvent>` interface for critical consumers with optional compensation
|
|
46
|
-
- `@EventHandler(EventClass)` decorator to register consumers
|
|
47
|
-
- `@Critical({ order: n })` decorator for critical consumers (run sequentially)
|
|
48
|
-
- `@NonCritical()` decorator for non-critical consumers (fire-and-forget)
|
|
49
|
-
- `mediatorBus.publish(event)` method to publish events
|
|
50
|
-
- `EventCriticality` enum (`CRITICAL`, `NON_CRITICAL`)
|
|
51
|
-
- **Saga-style compensation**: Critical consumers can define a `compensate()` method that runs in reverse order when a subsequent consumer fails
|
|
52
|
-
- Internal architecture refactoring (MediatorBus now delegates to CommandBus, QueryBus, EventBus)
|
|
53
|
-
|
|
54
|
-
### Backward Compatibility
|
|
55
|
-
|
|
56
|
-
- **`IEventHandler` renamed to `IEventConsumer`**: `IEventHandler` is now a deprecated type alias. Update your imports:
|
|
57
|
-
|
|
58
|
-
- **MediatorBus API unchanged**: The `send()`, `query()`, and `publish()` methods work exactly as before
|
|
59
|
-
- **No breaking changes**: Existing command/query code continues to work without modifications
|
|
60
|
-
|
|
61
|
-
---
|
|
62
|
-
|
|
63
|
-
## Upgrading to v0.6.0
|
|
64
|
-
|
|
65
|
-
Version 0.6.0 introduces **Type-Specific Pipeline Behaviors** - behaviors that only apply to specific request types.
|
|
66
|
-
|
|
67
|
-
### What's New
|
|
35
|
+
## Choose Your Architecture
|
|
68
36
|
|
|
69
|
-
|
|
70
|
-
- Behaviors can target specific command/query types without manual `instanceof` checks
|
|
71
|
-
- Full backward compatibility - existing behaviors work unchanged
|
|
37
|
+
NestJS Mediator grows with your application. Start simple, add what you need.
|
|
72
38
|
|
|
73
|
-
|
|
39
|
+
| | **Simple** | **Audit** | **Source** |
|
|
40
|
+
|---|---|---|---|
|
|
41
|
+
| **Database required** | No | PostgreSQL | PostgreSQL |
|
|
42
|
+
| **State storage** | Your choice (in-memory, any DB) | Your tables (e.g., `orders`) | Event store only |
|
|
43
|
+
| **Event persistence** | None | Events logged alongside state | Events ARE the state |
|
|
44
|
+
| **Aggregates** | Not needed | Not needed | `AggregateRoot` + `AggregateRepository` |
|
|
45
|
+
| **Concurrency control** | Your responsibility | Your responsibility | Built-in (optimistic) |
|
|
46
|
+
| **Use when** | Prototyping, simple apps | Production apps needing audit trail | Domain-driven, event-sourced systems |
|
|
74
47
|
|
|
75
|
-
```typescript
|
|
76
|
-
import { Injectable } from '@nestjs/common';
|
|
77
|
-
import { IPipelineBehavior, PipelineBehavior, Handle } from '@rolandsall24/nest-mediator';
|
|
78
|
-
import { CreateUserCommand } from './create-user.command';
|
|
79
|
-
|
|
80
|
-
@Injectable()
|
|
81
|
-
@PipelineBehavior({ priority: 100, scope: 'command' })
|
|
82
|
-
export class CreateUserValidationBehavior
|
|
83
|
-
implements IPipelineBehavior<CreateUserCommand, void>
|
|
84
|
-
{
|
|
85
|
-
@Handle() // <-- Enables type inference from method signature
|
|
86
|
-
async handle(
|
|
87
|
-
request: CreateUserCommand,
|
|
88
|
-
next: () => Promise<void>,
|
|
89
|
-
): Promise<void> {
|
|
90
|
-
// This behavior ONLY runs for CreateUserCommand
|
|
91
|
-
// No instanceof check needed!
|
|
92
|
-
if (!request.email.includes('@')) {
|
|
93
|
-
throw new Error('Invalid email');
|
|
94
|
-
}
|
|
95
|
-
return next();
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
48
|
```
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
3. The library reads this metadata at registration time and filters behaviors by request type
|
|
105
|
-
|
|
106
|
-
### No Migration Required
|
|
107
|
-
|
|
108
|
-
Existing behaviors without `@Handle()` on the method continue to work exactly as before - they apply to all requests matching their scope.
|
|
49
|
+
Simple ──────────► Audit ──────────► Source
|
|
50
|
+
No DB + event log + event sourcing
|
|
51
|
+
Just CQRS + traceability + aggregates
|
|
52
|
+
+ audit trail + concurrency control
|
|
53
|
+
```
|
|
109
54
|
|
|
110
55
|
---
|
|
111
56
|
|
|
112
|
-
##
|
|
57
|
+
## Mode 1: Simple (No Database Required)
|
|
113
58
|
|
|
114
|
-
|
|
59
|
+
Pure CQRS with commands, queries, and domain events. No event store, no database — just clean separation of concerns.
|
|
115
60
|
|
|
116
|
-
|
|
61
|
+
### Module Setup
|
|
117
62
|
|
|
118
63
|
```typescript
|
|
119
64
|
import { Module } from '@nestjs/common';
|
|
120
65
|
import { NestMediatorModule } from '@rolandsall24/nest-mediator';
|
|
121
|
-
import { CreateUserCommandHandler } from './handlers/create-user.handler';
|
|
122
|
-
import { GetUserQueryHandler } from './handlers/get-user-query.handler';
|
|
123
|
-
|
|
124
|
-
@Module({
|
|
125
|
-
imports: [
|
|
126
|
-
// Basic setup
|
|
127
|
-
NestMediatorModule.forRoot(),
|
|
128
|
-
],
|
|
129
|
-
providers: [
|
|
130
|
-
// Add your handlers to the providers array
|
|
131
|
-
// They will be automatically discovered by the mediator
|
|
132
|
-
CreateUserCommandHandler,
|
|
133
|
-
GetUserQueryHandler,
|
|
134
|
-
],
|
|
135
|
-
})
|
|
136
|
-
export class AppModule {}
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
Or with built-in pipeline behaviors enabled:
|
|
140
66
|
|
|
141
|
-
```typescript
|
|
142
67
|
@Module({
|
|
143
68
|
imports: [
|
|
144
69
|
NestMediatorModule.forRoot({
|
|
145
|
-
enableLogging: true,
|
|
146
|
-
enableValidation: true,
|
|
147
|
-
enableExceptionHandling: true, // Centralized exception logging
|
|
70
|
+
enableLogging: true,
|
|
71
|
+
enableValidation: true,
|
|
148
72
|
}),
|
|
149
73
|
],
|
|
150
74
|
providers: [
|
|
151
|
-
|
|
152
|
-
|
|
75
|
+
CreateUserHandler,
|
|
76
|
+
GetUserHandler,
|
|
77
|
+
SendWelcomeEmailConsumer,
|
|
153
78
|
],
|
|
154
79
|
})
|
|
155
80
|
export class AppModule {}
|
|
156
81
|
```
|
|
157
82
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
## Usage
|
|
161
|
-
|
|
162
|
-
### Commands
|
|
163
|
-
|
|
164
|
-
Commands are used for operations that change state (create, update, delete).
|
|
165
|
-
|
|
166
|
-
#### 1. Define a Command
|
|
83
|
+
### Define Commands & Handlers
|
|
167
84
|
|
|
168
85
|
```typescript
|
|
169
|
-
import { ICommand } from '@rolandsall24/nest-mediator';
|
|
86
|
+
import { ICommand, ICommandHandler, CommandHandler, MediatorBus } from '@rolandsall24/nest-mediator';
|
|
170
87
|
|
|
88
|
+
// Command — a simple data container
|
|
171
89
|
export class CreateUserCommand implements ICommand {
|
|
172
90
|
constructor(
|
|
173
91
|
public readonly email: string,
|
|
174
92
|
public readonly name: string,
|
|
175
|
-
public readonly age: number
|
|
176
93
|
) {}
|
|
177
94
|
}
|
|
95
|
+
|
|
96
|
+
// Handler — contains the business logic
|
|
97
|
+
@Injectable()
|
|
98
|
+
@CommandHandler(CreateUserCommand)
|
|
99
|
+
export class CreateUserHandler implements ICommandHandler<CreateUserCommand> {
|
|
100
|
+
constructor(private readonly mediatorBus: MediatorBus) {}
|
|
101
|
+
|
|
102
|
+
async execute(command: CreateUserCommand): Promise<void> {
|
|
103
|
+
const userId = randomUUID();
|
|
104
|
+
// Save user to your database...
|
|
105
|
+
|
|
106
|
+
// Publish domain event to trigger side effects
|
|
107
|
+
await this.mediatorBus.publish(
|
|
108
|
+
new UserCreatedEvent(userId, command.email, command.name),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
178
112
|
```
|
|
179
113
|
|
|
180
|
-
|
|
114
|
+
### Define Queries & Handlers
|
|
181
115
|
|
|
182
116
|
```typescript
|
|
183
|
-
import {
|
|
184
|
-
|
|
185
|
-
|
|
117
|
+
import { IQuery, IQueryHandler, QueryHandler } from '@rolandsall24/nest-mediator';
|
|
118
|
+
|
|
119
|
+
export class GetUserQuery implements IQuery {
|
|
120
|
+
constructor(public readonly userId: string) {}
|
|
121
|
+
}
|
|
186
122
|
|
|
187
123
|
@Injectable()
|
|
188
|
-
@
|
|
189
|
-
export class
|
|
124
|
+
@QueryHandler(GetUserQuery)
|
|
125
|
+
export class GetUserHandler implements IQueryHandler<GetUserQuery, UserDto> {
|
|
126
|
+
async execute(query: GetUserQuery): Promise<UserDto> {
|
|
127
|
+
// Fetch and return user...
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Define Events & Consumers
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
import { IEvent, IEventConsumer, EventHandler, NonCritical } from '@rolandsall24/nest-mediator';
|
|
136
|
+
|
|
137
|
+
export class UserCreatedEvent implements IEvent {
|
|
190
138
|
constructor(
|
|
191
|
-
|
|
192
|
-
|
|
139
|
+
public readonly userId: string,
|
|
140
|
+
public readonly email: string,
|
|
141
|
+
public readonly name: string,
|
|
193
142
|
) {}
|
|
143
|
+
}
|
|
194
144
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
//
|
|
201
|
-
// email: command.email,
|
|
202
|
-
// name: command.name,
|
|
203
|
-
// age: command.age,
|
|
204
|
-
// });
|
|
145
|
+
@Injectable()
|
|
146
|
+
@EventHandler(UserCreatedEvent)
|
|
147
|
+
@NonCritical()
|
|
148
|
+
export class SendWelcomeEmailConsumer implements IEventConsumer<UserCreatedEvent> {
|
|
149
|
+
async handle(event: UserCreatedEvent): Promise<void> {
|
|
150
|
+
// Send welcome email — fire-and-forget
|
|
205
151
|
}
|
|
206
152
|
}
|
|
207
153
|
```
|
|
208
154
|
|
|
209
|
-
|
|
155
|
+
### Use from Controllers
|
|
210
156
|
|
|
211
157
|
```typescript
|
|
212
|
-
import { Controller, Post, Body } from '@nestjs/common';
|
|
213
|
-
import { MediatorBus } from '@rolandsall24/nest-mediator';
|
|
214
|
-
import { CreateUserCommand } from './commands/create-user.command';
|
|
215
|
-
|
|
216
158
|
@Controller('users')
|
|
217
159
|
export class UserController {
|
|
218
160
|
constructor(private readonly mediator: MediatorBus) {}
|
|
219
161
|
|
|
220
162
|
@Post()
|
|
221
|
-
async create(@Body() body:
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
body.name,
|
|
225
|
-
body.age
|
|
226
|
-
);
|
|
163
|
+
async create(@Body() body: CreateUserDto) {
|
|
164
|
+
await this.mediator.send(new CreateUserCommand(body.email, body.name));
|
|
165
|
+
}
|
|
227
166
|
|
|
228
|
-
|
|
167
|
+
@Get(':id')
|
|
168
|
+
async getById(@Param('id') id: string) {
|
|
169
|
+
return this.mediator.query(new GetUserQuery(id));
|
|
229
170
|
}
|
|
230
171
|
}
|
|
231
172
|
```
|
|
232
173
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
Queries are used for operations that read data without changing state.
|
|
174
|
+
---
|
|
236
175
|
|
|
237
|
-
|
|
176
|
+
## Mode 2: Audit (Event Logging)
|
|
238
177
|
|
|
239
|
-
|
|
240
|
-
import { IQuery } from '@rolandsall24/nest-mediator';
|
|
178
|
+
Everything from Simple mode, plus **every domain event is automatically persisted** to a PostgreSQL table. Your application still manages state in its own tables — the event log provides traceability, debugging, and compliance.
|
|
241
179
|
|
|
242
|
-
|
|
243
|
-
constructor(public readonly userId: string) {}
|
|
244
|
-
}
|
|
245
|
-
```
|
|
180
|
+
### What changes from Simple mode
|
|
246
181
|
|
|
247
|
-
|
|
182
|
+
1. Add `eventStore` config with `mode: 'audit'`
|
|
183
|
+
2. That's it — events are persisted automatically
|
|
248
184
|
|
|
249
185
|
```typescript
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
186
|
+
@Module({
|
|
187
|
+
imports: [
|
|
188
|
+
NestMediatorModule.forRoot({
|
|
189
|
+
enableLogging: true,
|
|
190
|
+
eventStore: {
|
|
191
|
+
type: 'postgres',
|
|
192
|
+
url: process.env.DATABASE_URL,
|
|
193
|
+
mode: 'audit',
|
|
194
|
+
tableName: 'audit_events', // optional, defaults to 'domain_events'
|
|
195
|
+
},
|
|
196
|
+
}),
|
|
197
|
+
],
|
|
198
|
+
providers: [
|
|
199
|
+
// Your application manages state in its own tables
|
|
200
|
+
{ provide: ORDER_PERSISTOR, useClass: PostgresOrderAdapter },
|
|
201
|
+
|
|
202
|
+
CreateOrderHandler,
|
|
203
|
+
GetOrderHandler,
|
|
204
|
+
ReserveInventoryHandler,
|
|
205
|
+
ProcessPaymentHandler,
|
|
206
|
+
],
|
|
207
|
+
})
|
|
208
|
+
export class AppModule {}
|
|
257
209
|
```
|
|
258
210
|
|
|
259
|
-
|
|
211
|
+
### How it works
|
|
260
212
|
|
|
261
|
-
|
|
262
|
-
import { Injectable } from '@nestjs/common';
|
|
263
|
-
import { QueryHandler, IQueryHandler } from '@rolandsall24/nest-mediator';
|
|
264
|
-
import { GetUserByIdQuery } from '../queries/get-user-by-id.query';
|
|
265
|
-
import { UserDto } from '../dtos/user.dto';
|
|
213
|
+
Your command handler saves state to your own table, then publishes a domain event. The library automatically persists the event to the audit table before dispatching to consumers.
|
|
266
214
|
|
|
215
|
+
```typescript
|
|
267
216
|
@Injectable()
|
|
268
|
-
@
|
|
269
|
-
export class
|
|
217
|
+
@CommandHandler(CreateOrderCommand)
|
|
218
|
+
export class CreateOrderHandler implements ICommandHandler<CreateOrderCommand> {
|
|
270
219
|
constructor(
|
|
271
|
-
|
|
272
|
-
|
|
220
|
+
@Inject(ORDER_PERSISTOR) private readonly orderPersistor: IOrderPersistor,
|
|
221
|
+
private readonly mediatorBus: MediatorBus,
|
|
273
222
|
) {}
|
|
274
223
|
|
|
275
|
-
async execute(
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
224
|
+
async execute(command: CreateOrderCommand): Promise<void> {
|
|
225
|
+
const orderId = `order-${Date.now()}`;
|
|
226
|
+
|
|
227
|
+
// 1. Save to YOUR orders table (source of truth)
|
|
228
|
+
await this.orderPersistor.save({
|
|
229
|
+
orderId,
|
|
230
|
+
customerId: command.customerId,
|
|
231
|
+
items: command.items,
|
|
232
|
+
total: command.total,
|
|
233
|
+
status: 'placed',
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// 2. Publish event — automatically logged to audit_events table
|
|
237
|
+
await this.mediatorBus.publish(
|
|
238
|
+
new OrderPlacedEvent(orderId, command.customerId, command.items, command.total),
|
|
239
|
+
);
|
|
290
240
|
}
|
|
291
241
|
}
|
|
292
242
|
```
|
|
293
243
|
|
|
294
|
-
|
|
244
|
+
### Optional: `@DomainEvent` for richer audit logs
|
|
295
245
|
|
|
296
|
-
|
|
297
|
-
import { Controller, Get, Param } from '@nestjs/common';
|
|
298
|
-
import { MediatorBus } from '@rolandsall24/nest-mediator';
|
|
299
|
-
import { GetUserByIdQuery } from './queries/get-user-by-id.query';
|
|
300
|
-
import { UserDto } from './dtos/user.dto';
|
|
246
|
+
In audit mode, `@DomainEvent` is **purely informational** — it does not change runtime behavior. Adding it populates the `aggregate_type` and `aggregate_id` columns in the event store, which makes querying and filtering your audit log significantly easier.
|
|
301
247
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
248
|
+
```typescript
|
|
249
|
+
// Without @DomainEvent — events are still persisted, but aggregate_type and aggregate_id are null
|
|
250
|
+
export class OrderPlacedEvent implements IEvent {
|
|
251
|
+
constructor(public readonly orderId: string, public readonly total: number) {}
|
|
252
|
+
}
|
|
305
253
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
return user;
|
|
311
|
-
}
|
|
254
|
+
// With @DomainEvent — aggregate_type='Order', aggregate_id=orderId in the audit table
|
|
255
|
+
@DomainEvent('Order', 'orderId')
|
|
256
|
+
export class OrderPlacedEvent implements IEvent {
|
|
257
|
+
constructor(public readonly orderId: string, public readonly total: number) {}
|
|
312
258
|
}
|
|
313
259
|
```
|
|
314
260
|
|
|
315
|
-
|
|
261
|
+
This is optional. Events are logged either way. The decorator just gives you better data for queries like *"show me all events for order X"* or *"show me all Order events"*.
|
|
262
|
+
|
|
263
|
+
### What you get
|
|
264
|
+
|
|
265
|
+
Every event is stored with full context:
|
|
266
|
+
|
|
267
|
+
| Column | Description |
|
|
268
|
+
|--------|-------------|
|
|
269
|
+
| `event_id` | Unique event identifier |
|
|
270
|
+
| `event_type` | Class name (e.g., `OrderPlacedEvent`) |
|
|
271
|
+
| `aggregate_type` | Aggregate name from `@DomainEvent` (if present) |
|
|
272
|
+
| `aggregate_id` | Aggregate ID from `@DomainEvent` (if present) |
|
|
273
|
+
| `correlation_id` | Groups all events from the same business transaction |
|
|
274
|
+
| `causation_id` | Points to the parent event that caused this one |
|
|
275
|
+
| `payload` | Full event data as JSON |
|
|
276
|
+
| `occurred_at` | Timestamp |
|
|
277
|
+
|
|
278
|
+
### When to use Audit mode
|
|
279
|
+
|
|
280
|
+
- You need an **audit trail** for compliance or debugging
|
|
281
|
+
- You want to **trace event chains** (correlation/causation IDs)
|
|
282
|
+
- You're happy managing state in your own tables
|
|
283
|
+
- You want to **answer "what happened?"** without changing your architecture
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## Mode 3: Source (Event Sourcing)
|
|
288
|
+
|
|
289
|
+
Events **are** the data. There are no state tables. Application state is rebuilt by replaying events from the event store.
|
|
290
|
+
|
|
291
|
+
### What changes from Audit mode
|
|
292
|
+
|
|
293
|
+
1. Change `mode: 'audit'` to `mode: 'source'`
|
|
294
|
+
2. Define an `AggregateRoot` with business rules
|
|
295
|
+
3. Add an `AggregateRepository` with `@ForAggregate` (one line)
|
|
296
|
+
4. Gain **optimistic concurrency control** for free
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
@Module({
|
|
300
|
+
imports: [
|
|
301
|
+
NestMediatorModule.forRoot({
|
|
302
|
+
enableLogging: true,
|
|
303
|
+
eventStore: {
|
|
304
|
+
type: 'postgres',
|
|
305
|
+
url: process.env.DATABASE_URL,
|
|
306
|
+
mode: 'source',
|
|
307
|
+
tableName: 'domain_events',
|
|
308
|
+
},
|
|
309
|
+
}),
|
|
310
|
+
],
|
|
311
|
+
providers: [
|
|
312
|
+
OrderAggregateRepository,
|
|
316
313
|
|
|
317
|
-
|
|
314
|
+
PlaceOrderHandler,
|
|
315
|
+
CancelOrderHandler,
|
|
316
|
+
GetOrderHandler,
|
|
317
|
+
ReserveInventoryHandler,
|
|
318
|
+
ProcessPaymentHandler,
|
|
319
|
+
],
|
|
320
|
+
})
|
|
321
|
+
export class AppModule {}
|
|
322
|
+
```
|
|
318
323
|
|
|
319
|
-
|
|
320
|
-
- **Non-critical consumers**: Run in parallel after critical consumers complete. Fire-and-forget.
|
|
324
|
+
### Mark Events with `@DomainEvent`
|
|
321
325
|
|
|
322
|
-
|
|
326
|
+
The `@DomainEvent` decorator associates events with aggregates and registers them in a global event registry. **Required** for source mode (used for aggregate hydration). **Optional** for audit mode (only populates `aggregate_type`/`aggregate_id` columns for easier querying — has no effect on runtime behavior).
|
|
323
327
|
|
|
324
328
|
```typescript
|
|
325
|
-
import { IEvent } from '@rolandsall24/nest-mediator';
|
|
329
|
+
import { IEvent, DomainEvent } from '@rolandsall24/nest-mediator';
|
|
326
330
|
|
|
331
|
+
@DomainEvent('Order', 'orderId')
|
|
327
332
|
export class OrderPlacedEvent implements IEvent {
|
|
328
333
|
constructor(
|
|
329
334
|
public readonly orderId: string,
|
|
@@ -332,1350 +337,646 @@ export class OrderPlacedEvent implements IEvent {
|
|
|
332
337
|
public readonly total: number,
|
|
333
338
|
) {}
|
|
334
339
|
}
|
|
340
|
+
|
|
341
|
+
@DomainEvent('Order', 'orderId')
|
|
342
|
+
export class OrderCancelledEvent implements IEvent {
|
|
343
|
+
constructor(
|
|
344
|
+
public readonly orderId: string,
|
|
345
|
+
public readonly reason: string,
|
|
346
|
+
) {}
|
|
347
|
+
}
|
|
335
348
|
```
|
|
336
349
|
|
|
337
|
-
|
|
350
|
+
The first argument is the aggregate type name (must match `aggregateType` on your aggregate). The second is the property name holding the aggregate ID. The `@DomainEvent` decorator also auto-registers the event class so `AggregateRepository` can deserialize stored events without any manual wiring.
|
|
351
|
+
|
|
352
|
+
### Define an Aggregate
|
|
338
353
|
|
|
339
|
-
|
|
354
|
+
The aggregate encapsulates business rules and tracks state changes as events.
|
|
340
355
|
|
|
341
356
|
```typescript
|
|
342
|
-
import {
|
|
343
|
-
import { EventHandler, IEventConsumer, Critical } from '@rolandsall24/nest-mediator';
|
|
344
|
-
import { OrderPlacedEvent } from './order-placed.event';
|
|
357
|
+
import { AggregateRoot } from '@rolandsall24/nest-mediator';
|
|
345
358
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
private
|
|
359
|
+
export class OrderAggregate extends AggregateRoot<string> {
|
|
360
|
+
private _orderId!: string;
|
|
361
|
+
private _customerId!: string;
|
|
362
|
+
private _items: { productId: string; quantity: number }[] = [];
|
|
363
|
+
private _total: number = 0;
|
|
364
|
+
private _status: 'placed' | 'cancelled' = 'placed';
|
|
351
365
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
// Throw error if validation fails - stops the event processing
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
```
|
|
366
|
+
readonly aggregateType = 'Order';
|
|
367
|
+
get id() { return this._orderId; }
|
|
368
|
+
get status() { return this._status; }
|
|
359
369
|
|
|
360
|
-
|
|
370
|
+
// ── Command methods (enforce business rules, emit events) ──
|
|
361
371
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
372
|
+
static create(
|
|
373
|
+
orderId: string,
|
|
374
|
+
customerId: string,
|
|
375
|
+
items: { productId: string; quantity: number }[],
|
|
376
|
+
total: number,
|
|
377
|
+
): OrderAggregate {
|
|
378
|
+
if (!items.length) throw new Error('Order must have at least one item');
|
|
379
|
+
const order = new OrderAggregate();
|
|
380
|
+
order.apply(new OrderPlacedEvent(orderId, customerId, items, total));
|
|
381
|
+
return order;
|
|
382
|
+
}
|
|
366
383
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
384
|
+
cancel(reason: string): void {
|
|
385
|
+
if (this._status === 'cancelled') {
|
|
386
|
+
throw new Error(`Order ${this._orderId} is already cancelled`);
|
|
387
|
+
}
|
|
388
|
+
this.apply(new OrderCancelledEvent(this._orderId, reason));
|
|
389
|
+
}
|
|
372
390
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
391
|
+
// ── Event handlers (update state from events) ──
|
|
392
|
+
|
|
393
|
+
applyOrderPlacedEvent(event: OrderPlacedEvent): void {
|
|
394
|
+
this._orderId = event.orderId;
|
|
395
|
+
this._customerId = event.customerId;
|
|
396
|
+
this._items = event.items;
|
|
397
|
+
this._total = event.total;
|
|
398
|
+
this._status = 'placed';
|
|
376
399
|
}
|
|
377
400
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
this.logger.warn(`[COMPENSATE] Releasing inventory for order ${event.orderId}`);
|
|
381
|
-
// Release the reserved inventory
|
|
401
|
+
applyOrderCancelledEvent(event: OrderCancelledEvent): void {
|
|
402
|
+
this._status = 'cancelled';
|
|
382
403
|
}
|
|
383
404
|
}
|
|
384
405
|
```
|
|
385
406
|
|
|
386
|
-
|
|
407
|
+
#### The `applyXxxEvent` Convention
|
|
408
|
+
|
|
409
|
+
When you call `this.apply(new OrderPlacedEvent(...))`, the base class automatically looks for a method named `apply` + the event class name — in this case `applyOrderPlacedEvent`. This convention is used in two scenarios:
|
|
410
|
+
|
|
411
|
+
1. **New events** — when a command method calls `this.apply(event)`, the handler updates state and the event is tracked as uncommitted.
|
|
412
|
+
2. **Replayed events** — when loading from history via `loadFromHistory()`, the same handlers rebuild state from stored events.
|
|
413
|
+
|
|
414
|
+
This means your aggregate's state mutation logic is written once and works for both paths. If a handler is missing, the library logs a warning for new events and silently skips during replay.
|
|
415
|
+
|
|
416
|
+
### Define an Aggregate Repository
|
|
417
|
+
|
|
418
|
+
With `@ForAggregate`, the repository is a one-liner. The decorator tells the base class which aggregate to instantiate, and the `@DomainEvent` registry handles event deserialization automatically.
|
|
387
419
|
|
|
388
420
|
```typescript
|
|
389
|
-
import { Injectable
|
|
390
|
-
import {
|
|
391
|
-
import { OrderPlacedEvent } from './order-placed.event';
|
|
421
|
+
import { Injectable } from '@nestjs/common';
|
|
422
|
+
import { AggregateRepository, ForAggregate } from '@rolandsall24/nest-mediator';
|
|
392
423
|
|
|
393
424
|
@Injectable()
|
|
394
|
-
@
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
425
|
+
@ForAggregate(OrderAggregate)
|
|
426
|
+
export class OrderAggregateRepository
|
|
427
|
+
extends AggregateRepository<OrderAggregate, string> {}
|
|
428
|
+
```
|
|
398
429
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
430
|
+
That's it. No constructor, no overrides. The base class provides:
|
|
431
|
+
|
|
432
|
+
- **`findById(id)`** — loads events from the store, deserializes them via the `@DomainEvent` registry, and replays them to rebuild aggregate state
|
|
433
|
+
- **`getById(id)`** — same as `findById` but throws if the aggregate doesn't exist
|
|
434
|
+
- **`save(aggregate)`** — publishes each uncommitted event through the event bus (persistence + consumers)
|
|
435
|
+
|
|
436
|
+
Need custom logic? Every method is overridable:
|
|
404
437
|
|
|
405
|
-
|
|
438
|
+
```typescript
|
|
406
439
|
@Injectable()
|
|
407
|
-
@
|
|
408
|
-
export class
|
|
409
|
-
|
|
410
|
-
|
|
440
|
+
@ForAggregate(OrderAggregate)
|
|
441
|
+
export class OrderAggregateRepository
|
|
442
|
+
extends AggregateRepository<OrderAggregate, string>
|
|
443
|
+
{
|
|
444
|
+
// Override any method for custom behavior
|
|
445
|
+
protected deserializeEvent(eventType: string, payload: Record<string, unknown>): IEvent {
|
|
446
|
+
// Custom deserialization logic
|
|
411
447
|
}
|
|
412
448
|
}
|
|
413
449
|
```
|
|
414
450
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
The recommended pattern is to publish domain events from command handlers after the main operation succeeds:
|
|
451
|
+
### Use from Handlers
|
|
418
452
|
|
|
419
453
|
```typescript
|
|
420
|
-
import { Injectable, Logger } from '@nestjs/common';
|
|
421
|
-
import { CommandHandler, ICommandHandler, MediatorBus } from '@rolandsall24/nest-mediator';
|
|
422
|
-
import { PlaceOrderCommand } from './place-order.command';
|
|
423
|
-
import { OrderPlacedEvent } from '../events/order-placed.event';
|
|
424
|
-
|
|
425
454
|
@Injectable()
|
|
426
455
|
@CommandHandler(PlaceOrderCommand)
|
|
427
456
|
export class PlaceOrderHandler implements ICommandHandler<PlaceOrderCommand> {
|
|
428
|
-
private readonly
|
|
429
|
-
|
|
430
|
-
constructor(private readonly mediatorBus: MediatorBus) {}
|
|
457
|
+
constructor(private readonly orderRepository: OrderAggregateRepository) {}
|
|
431
458
|
|
|
432
459
|
async execute(command: PlaceOrderCommand): Promise<void> {
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
// 2. Publish domain event after successful processing
|
|
440
|
-
// Critical consumers run sequentially, non-critical run in background
|
|
441
|
-
const result = await this.mediatorBus.publish(
|
|
442
|
-
new OrderPlacedEvent(
|
|
443
|
-
orderId,
|
|
444
|
-
command.customerId,
|
|
445
|
-
command.items,
|
|
446
|
-
command.total,
|
|
447
|
-
),
|
|
460
|
+
const order = OrderAggregate.create(
|
|
461
|
+
`order-${Date.now()}`,
|
|
462
|
+
command.customerId,
|
|
463
|
+
command.items,
|
|
464
|
+
command.total,
|
|
448
465
|
);
|
|
449
466
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
);
|
|
467
|
+
// Save publishes the event -> persistence -> critical consumers -> non-critical
|
|
468
|
+
await this.orderRepository.save(order);
|
|
453
469
|
}
|
|
470
|
+
}
|
|
454
471
|
|
|
455
|
-
|
|
456
|
-
|
|
472
|
+
@Injectable()
|
|
473
|
+
@CommandHandler(CancelOrderCommand)
|
|
474
|
+
export class CancelOrderHandler implements ICommandHandler<CancelOrderCommand> {
|
|
475
|
+
constructor(private readonly orderRepository: OrderAggregateRepository) {}
|
|
476
|
+
|
|
477
|
+
async execute(command: CancelOrderCommand): Promise<void> {
|
|
478
|
+
// Load aggregate (replays events -> rebuilds state + version)
|
|
479
|
+
const order = await this.orderRepository.findById(command.orderId);
|
|
480
|
+
if (!order) throw new OrderNotFoundException(command.orderId);
|
|
481
|
+
|
|
482
|
+
// Apply business rules, record OrderCancelledEvent
|
|
483
|
+
order.cancel(command.reason);
|
|
484
|
+
|
|
485
|
+
// Save with concurrency check — throws ConcurrencyError if version conflict
|
|
486
|
+
await this.orderRepository.save(order);
|
|
457
487
|
}
|
|
458
488
|
}
|
|
459
489
|
```
|
|
460
490
|
|
|
461
|
-
|
|
491
|
+
### Optimistic Concurrency
|
|
462
492
|
|
|
463
|
-
|
|
464
|
-
publish(OrderPlacedEvent)
|
|
465
|
-
│
|
|
466
|
-
├─► Critical Consumers (sequential, awaited)
|
|
467
|
-
│ ├─► ValidateInventoryConsumer (order: 1) ✓
|
|
468
|
-
│ ├─► ReserveInventoryConsumer (order: 2) ✓ [has compensate()]
|
|
469
|
-
│ ├─► CreateOrderRecordConsumer (order: 3) ✓ [has compensate()]
|
|
470
|
-
│ └─► ChargePaymentConsumer (order: 4) ✗ FAILS
|
|
471
|
-
│
|
|
472
|
-
│ On failure → Run compensations in REVERSE order:
|
|
473
|
-
│ ├─► CreateOrderRecordConsumer.compensate() - deletes order
|
|
474
|
-
│ └─► ReserveInventoryConsumer.compensate() - releases inventory
|
|
475
|
-
│ Then throw original error
|
|
476
|
-
│
|
|
477
|
-
└─► Non-Critical Consumers (parallel, fire-and-forget)
|
|
478
|
-
├─► SendOrderConfirmationConsumer
|
|
479
|
-
├─► NotifyWarehouseConsumer
|
|
480
|
-
└─► TrackAnalyticsConsumer
|
|
493
|
+
In source mode, each event stored for an aggregate carries a sequence number. When two requests load the same aggregate simultaneously, both see version N. The first to save writes version N+1 successfully. The second gets a `ConcurrencyError` because the version has advanced.
|
|
481
494
|
|
|
482
|
-
|
|
483
|
-
|
|
495
|
+
```
|
|
496
|
+
Request A: load(order) -> version 3 -> cancel() -> save() -> writes seq 4 ok
|
|
497
|
+
Request B: load(order) -> version 3 -> cancel() -> save() -> expected 3, found 4 -> ConcurrencyError
|
|
484
498
|
```
|
|
485
499
|
|
|
486
|
-
|
|
500
|
+
The `example-source` project includes a `POST /orders/test-concurrency` endpoint that demonstrates this by placing an order and firing two cancel commands simultaneously.
|
|
487
501
|
|
|
488
|
-
|
|
502
|
+
### When to use Source mode
|
|
489
503
|
|
|
490
|
-
|
|
491
|
-
|
|
504
|
+
- You need a **complete history** of every state change
|
|
505
|
+
- You want **optimistic concurrency** without manual locking
|
|
506
|
+
- Your domain is complex enough to benefit from **aggregates and business rules**
|
|
507
|
+
- You want to **rebuild state** at any point in time
|
|
508
|
+
- You're building a **domain-driven** system where events are first-class citizens
|
|
509
|
+
|
|
510
|
+
---
|
|
511
|
+
|
|
512
|
+
## Core Concepts
|
|
513
|
+
|
|
514
|
+
### Domain Events
|
|
515
|
+
|
|
516
|
+
Domain events notify other parts of the system when something happens. Consumers come in two types:
|
|
517
|
+
|
|
518
|
+
**Critical consumers** — run sequentially in order. Must succeed. Support compensation.
|
|
492
519
|
|
|
520
|
+
```typescript
|
|
493
521
|
@Injectable()
|
|
494
522
|
@EventHandler(OrderPlacedEvent)
|
|
495
|
-
@Critical({ order:
|
|
496
|
-
export class
|
|
523
|
+
@Critical({ order: 1 })
|
|
524
|
+
export class ReserveInventoryHandler implements ICriticalEventConsumer<OrderPlacedEvent> {
|
|
497
525
|
async handle(event: OrderPlacedEvent): Promise<void> {
|
|
498
|
-
//
|
|
499
|
-
await this.orderRepository.create({
|
|
500
|
-
id: event.orderId,
|
|
501
|
-
customerId: event.customerId,
|
|
502
|
-
items: event.items,
|
|
503
|
-
total: event.total,
|
|
504
|
-
});
|
|
526
|
+
// Reserve inventory — must succeed before payment
|
|
505
527
|
}
|
|
506
528
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
529
|
+
// Called if a SUBSEQUENT critical consumer fails
|
|
530
|
+
async applyCompensatingEvent(event: OrderPlacedEvent): Promise<IEvent> {
|
|
531
|
+
return new InventoryReleasedEvent(event.orderId);
|
|
510
532
|
}
|
|
511
533
|
}
|
|
512
534
|
```
|
|
513
535
|
|
|
514
|
-
**
|
|
515
|
-
- Only called when a **subsequent** critical consumer fails (not if this consumer fails)
|
|
516
|
-
- Runs in **reverse order** (last succeeded → first succeeded)
|
|
517
|
-
- Receives the same event instance passed to `handle()`
|
|
518
|
-
- Should be **idempotent** - safe to run multiple times
|
|
519
|
-
- Errors in compensations are logged but don't stop other compensations
|
|
520
|
-
- Non-critical consumers don't need compensation (they're fire-and-forget)
|
|
521
|
-
|
|
522
|
-
#### 6. Register Event Consumers
|
|
523
|
-
|
|
524
|
-
Add consumers to your module providers - they're auto-discovered via `@EventHandler`:
|
|
536
|
+
**Non-critical consumers** — fire-and-forget. Failures are logged but don't affect the result.
|
|
525
537
|
|
|
526
538
|
```typescript
|
|
527
|
-
@
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
ReserveInventoryConsumer,
|
|
536
|
-
CreateOrderRecordConsumer,
|
|
537
|
-
SendOrderConfirmationConsumer,
|
|
538
|
-
NotifyWarehouseConsumer,
|
|
539
|
-
TrackAnalyticsConsumer,
|
|
540
|
-
],
|
|
541
|
-
})
|
|
542
|
-
export class AppModule {}
|
|
539
|
+
@Injectable()
|
|
540
|
+
@EventHandler(OrderPlacedEvent)
|
|
541
|
+
@NonCritical()
|
|
542
|
+
export class SendConfirmationEmailHandler implements IEventConsumer<OrderPlacedEvent> {
|
|
543
|
+
async handle(event: OrderPlacedEvent): Promise<void> {
|
|
544
|
+
// Send email — failure doesn't block the order
|
|
545
|
+
}
|
|
546
|
+
}
|
|
543
547
|
```
|
|
544
548
|
|
|
545
|
-
|
|
549
|
+
Consumers without `@Critical` or `@NonCritical` default to non-critical.
|
|
546
550
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
### Project Structure
|
|
551
|
+
### Event Execution Flow
|
|
550
552
|
|
|
551
553
|
```
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
│ └── user/
|
|
571
|
-
│ └── user-persistence.adapter.ts
|
|
572
|
-
├── presentation/
|
|
573
|
-
│ └── user/
|
|
574
|
-
│ ├── create-user-api.request.ts
|
|
575
|
-
│ ├── user-api.response.ts
|
|
576
|
-
│ └── user.controller.ts
|
|
577
|
-
└── app.module.ts
|
|
554
|
+
publish(OrderPlacedEvent)
|
|
555
|
+
|
|
|
556
|
+
|-> System Phase (event store persistence)
|
|
557
|
+
|
|
|
558
|
+
|-> Critical Consumers (sequential, awaited)
|
|
559
|
+
| |-> ReserveInventoryHandler (order: 1) ok [has compensation]
|
|
560
|
+
| |-> ProcessPaymentHandler (order: 2) FAILS
|
|
561
|
+
| |
|
|
562
|
+
| | On failure -> compensations in REVERSE order:
|
|
563
|
+
| | '-> ReserveInventoryHandler.applyCompensatingEvent()
|
|
564
|
+
| | -> publishes InventoryReleasedEvent
|
|
565
|
+
| | -> persisted, dispatched to its own consumers
|
|
566
|
+
| '-> Throw original error
|
|
567
|
+
|
|
|
568
|
+
'-> Non-Critical Consumers (parallel, fire-and-forget)
|
|
569
|
+
|-> SendConfirmationEmailHandler
|
|
570
|
+
'-> TrackAnalyticsHandler
|
|
571
|
+
Only runs if ALL critical consumers succeed
|
|
578
572
|
```
|
|
579
573
|
|
|
580
|
-
###
|
|
574
|
+
### Saga-Style Compensation
|
|
581
575
|
|
|
582
|
-
|
|
576
|
+
Critical consumers can return compensating events when a later consumer in the chain fails. The library publishes these events through the full event flow — persisted, dispatched, traceable.
|
|
583
577
|
|
|
584
578
|
```typescript
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
public readonly age: number,
|
|
591
|
-
public readonly createdAt: Date
|
|
592
|
-
) {}
|
|
579
|
+
@Injectable()
|
|
580
|
+
@EventHandler(OrderPlacedEvent)
|
|
581
|
+
@Critical({ order: 1 })
|
|
582
|
+
export class ReserveInventoryHandler implements ICriticalEventConsumer<OrderPlacedEvent> {
|
|
583
|
+
constructor(private readonly mediatorBus: MediatorBus) {}
|
|
593
584
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
name: string;
|
|
598
|
-
age: number;
|
|
599
|
-
}): User {
|
|
600
|
-
const now = new Date();
|
|
601
|
-
return new User(
|
|
602
|
-
params.id,
|
|
603
|
-
params.email,
|
|
604
|
-
params.name,
|
|
605
|
-
params.age,
|
|
606
|
-
now
|
|
607
|
-
);
|
|
585
|
+
async handle(event: OrderPlacedEvent): Promise<void> {
|
|
586
|
+
// Reserve inventory...
|
|
587
|
+
await this.mediatorBus.publish(new InventoryReservedEvent(event.orderId, event.items));
|
|
608
588
|
}
|
|
609
|
-
}
|
|
610
|
-
```
|
|
611
589
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
export class DomainException extends Error {
|
|
616
|
-
constructor(message: string) {
|
|
617
|
-
super(message);
|
|
618
|
-
this.name = this.constructor.name;
|
|
590
|
+
// Return the compensating event — don't perform side effects here
|
|
591
|
+
async applyCompensatingEvent(event: OrderPlacedEvent): Promise<IEvent> {
|
|
592
|
+
return new InventoryReleasedEvent(event.orderId);
|
|
619
593
|
}
|
|
620
594
|
}
|
|
621
|
-
```
|
|
622
|
-
|
|
623
|
-
#### domain/exceptions/user-not-found.exception.ts
|
|
624
595
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
export class
|
|
629
|
-
|
|
630
|
-
|
|
596
|
+
// Dedicated consumer where the actual rollback logic lives
|
|
597
|
+
@Injectable()
|
|
598
|
+
@EventHandler(InventoryReleasedEvent)
|
|
599
|
+
export class HandleInventoryReleasedHandler implements IEventConsumer<InventoryReleasedEvent> {
|
|
600
|
+
async handle(event: InventoryReleasedEvent): Promise<void> {
|
|
601
|
+
// Release the reserved inventory
|
|
631
602
|
}
|
|
632
603
|
}
|
|
633
604
|
```
|
|
634
605
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
import { ICommand } from '@rolandsall24/nest-mediator';
|
|
606
|
+
**Compensation rules:**
|
|
607
|
+
- Runs in **reverse order** (last succeeded -> first succeeded)
|
|
608
|
+
- The returned event is published through the full event flow (persisted + dispatched)
|
|
609
|
+
- Should be **idempotent**
|
|
610
|
+
- Errors in compensations are logged but don't stop other compensations
|
|
641
611
|
|
|
642
|
-
|
|
643
|
-
constructor(
|
|
644
|
-
public readonly email: string,
|
|
645
|
-
public readonly name: string,
|
|
646
|
-
public readonly age: number
|
|
647
|
-
) {}
|
|
648
|
-
}
|
|
649
|
-
```
|
|
612
|
+
### Correlation & Causation IDs
|
|
650
613
|
|
|
651
|
-
|
|
614
|
+
The library automatically tracks two IDs through `AsyncLocalStorage` — no configuration needed.
|
|
652
615
|
|
|
653
|
-
|
|
654
|
-
import { User } from '../../domain/entities/user';
|
|
616
|
+
**Correlation ID** groups every event that stems from a single business transaction. When a command enters the system, a new `correlation_id` (UUID) is generated and propagated to every event published during that transaction, including events published by event handlers themselves.
|
|
655
617
|
|
|
656
|
-
|
|
657
|
-
save(user: User): Promise<User>;
|
|
658
|
-
findById(id: string): Promise<User | null>;
|
|
659
|
-
}
|
|
618
|
+
**Causation ID** creates a parent-child link between events. When an event handler publishes a new event, the child's `causation_id` is set to the parent event's `event_id`. This lets you reconstruct the full causal chain.
|
|
660
619
|
|
|
661
|
-
|
|
620
|
+
```
|
|
621
|
+
PlaceOrderCommand correlation: abc
|
|
622
|
+
'-> OrderPlacedEvent correlation: abc, causation: null
|
|
623
|
+
|-> InventoryReservedEvent correlation: abc, causation: <OrderPlacedEvent.id>
|
|
624
|
+
'-> PaymentChargedEvent correlation: abc, causation: <OrderPlacedEvent.id>
|
|
625
|
+
'-> ReceiptGeneratedEvent correlation: abc, causation: <PaymentChargedEvent.id>
|
|
662
626
|
```
|
|
663
627
|
|
|
664
|
-
|
|
628
|
+
Both IDs are stored in the event store automatically. This gives you:
|
|
629
|
+
- **Transaction tracing** — query all events with the same `correlation_id` to see everything that happened in one business operation
|
|
630
|
+
- **Causal ordering** — follow `causation_id` chains to understand what triggered what
|
|
631
|
+
- **Debugging** — when something fails, trace the full event chain that led to the failure
|
|
665
632
|
|
|
666
|
-
|
|
667
|
-
import { Injectable, Inject } from '@nestjs/common';
|
|
668
|
-
import { CommandHandler, ICommandHandler } from '@rolandsall24/nest-mediator';
|
|
669
|
-
import { randomUUID } from 'crypto';
|
|
670
|
-
import { CreateUserCommand } from './create-user.command';
|
|
671
|
-
import { User } from '../../domain/entities/user';
|
|
672
|
-
import { UserPersistor, USER_PERSISTOR } from './user-persistor.port';
|
|
673
|
-
|
|
674
|
-
@Injectable()
|
|
675
|
-
@CommandHandler(CreateUserCommand)
|
|
676
|
-
export class CreateUserCommandHandler implements ICommandHandler<CreateUserCommand> {
|
|
677
|
-
constructor(
|
|
678
|
-
@Inject(USER_PERSISTOR)
|
|
679
|
-
private readonly userPersistor: UserPersistor
|
|
680
|
-
) {}
|
|
681
|
-
|
|
682
|
-
async execute(command: CreateUserCommand): Promise<void> {
|
|
683
|
-
const id = randomUUID();
|
|
684
|
-
|
|
685
|
-
const user = User.create({
|
|
686
|
-
id,
|
|
687
|
-
email: command.email,
|
|
688
|
-
name: command.name,
|
|
689
|
-
age: command.age,
|
|
690
|
-
});
|
|
691
|
-
|
|
692
|
-
await this.userPersistor.save(user);
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
```
|
|
633
|
+
---
|
|
696
634
|
|
|
697
|
-
|
|
635
|
+
## Pipeline Behaviors
|
|
698
636
|
|
|
699
|
-
|
|
700
|
-
import { IQuery } from '@rolandsall24/nest-mediator';
|
|
637
|
+
Behaviors wrap around command and query handlers for cross-cutting concerns.
|
|
701
638
|
|
|
702
|
-
|
|
703
|
-
constructor(public readonly id: string) {}
|
|
704
|
-
}
|
|
705
|
-
```
|
|
706
|
-
|
|
707
|
-
#### application/user/get-user.handler.ts
|
|
639
|
+
### Built-in Behaviors
|
|
708
640
|
|
|
709
641
|
```typescript
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
@Injectable()
|
|
718
|
-
@QueryHandler(GetUserQuery)
|
|
719
|
-
export class GetUserQueryHandler implements IQueryHandler<GetUserQuery, User> {
|
|
720
|
-
constructor(
|
|
721
|
-
@Inject(USER_PERSISTOR)
|
|
722
|
-
private readonly userPersistor: UserPersistor
|
|
723
|
-
) {}
|
|
724
|
-
|
|
725
|
-
async execute(query: GetUserQuery): Promise<User> {
|
|
726
|
-
const user = await this.userPersistor.findById(query.id);
|
|
727
|
-
|
|
728
|
-
if (!user) {
|
|
729
|
-
throw new UserNotFoundException(query.id);
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
return user;
|
|
733
|
-
}
|
|
734
|
-
}
|
|
642
|
+
NestMediatorModule.forRoot({
|
|
643
|
+
enableLogging: true, // Log requests with timing
|
|
644
|
+
enableValidation: true, // class-validator validation
|
|
645
|
+
enableExceptionHandling: true, // Centralized error logging
|
|
646
|
+
enablePerformanceTracking: true, // Slow request warnings
|
|
647
|
+
performanceThresholdMs: 500,
|
|
648
|
+
})
|
|
735
649
|
```
|
|
736
650
|
|
|
737
|
-
|
|
651
|
+
| Behavior | Priority | Description |
|
|
652
|
+
|----------|----------|-------------|
|
|
653
|
+
| `ExceptionHandlingBehavior` | -100 | Catches and logs exceptions |
|
|
654
|
+
| `LoggingBehavior` | 0 | Logs request handling with timing |
|
|
655
|
+
| `PerformanceBehavior` | 10 | Warns on slow requests |
|
|
656
|
+
| `ValidationBehavior` | 100 | Validates using class-validator |
|
|
738
657
|
|
|
739
|
-
|
|
658
|
+
### Custom Behaviors
|
|
740
659
|
|
|
741
660
|
```typescript
|
|
742
|
-
import { Injectable } from '@nestjs/common';
|
|
743
|
-
import { UserPersistor } from '../../../application/user/user-persistor.port';
|
|
744
|
-
import { User } from '../../../domain/entities/user';
|
|
745
|
-
|
|
746
661
|
@Injectable()
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
async
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
async findById(id: string): Promise<User | null> {
|
|
757
|
-
return this.users.get(id) || null;
|
|
662
|
+
@PipelineBehavior({ priority: 50, scope: 'command' })
|
|
663
|
+
export class AuditLoggingBehavior<TRequest, TResponse>
|
|
664
|
+
implements IPipelineBehavior<TRequest, TResponse>
|
|
665
|
+
{
|
|
666
|
+
async handle(request: TRequest, next: () => Promise<TResponse>): Promise<TResponse> {
|
|
667
|
+
console.log(`Executing ${request.constructor.name}`);
|
|
668
|
+
const result = await next();
|
|
669
|
+
console.log(`Completed ${request.constructor.name}`);
|
|
670
|
+
return result;
|
|
758
671
|
}
|
|
759
672
|
}
|
|
760
673
|
```
|
|
761
674
|
|
|
762
|
-
###
|
|
763
|
-
|
|
764
|
-
#### presentation/user/create-user-api.request.ts
|
|
765
|
-
|
|
766
|
-
```typescript
|
|
767
|
-
export class CreateUserApiRequest {
|
|
768
|
-
email: string;
|
|
769
|
-
name: string;
|
|
770
|
-
age: number;
|
|
771
|
-
}
|
|
772
|
-
```
|
|
773
|
-
|
|
774
|
-
#### presentation/user/user-api.response.ts
|
|
775
|
-
|
|
776
|
-
```typescript
|
|
777
|
-
export class UserApiResponse {
|
|
778
|
-
id: string;
|
|
779
|
-
email: string;
|
|
780
|
-
name: string;
|
|
781
|
-
age: number;
|
|
782
|
-
createdAt: Date;
|
|
783
|
-
}
|
|
784
|
-
```
|
|
675
|
+
### Type-Specific Behaviors
|
|
785
676
|
|
|
786
|
-
|
|
677
|
+
Add `@Handle()` to make a behavior only run for a specific request type:
|
|
787
678
|
|
|
788
679
|
```typescript
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
@Controller('users')
|
|
797
|
-
export class UserController {
|
|
798
|
-
constructor(private readonly mediator: MediatorBus) {}
|
|
799
|
-
|
|
800
|
-
@Post()
|
|
801
|
-
async create(@Body() request: CreateUserApiRequest): Promise<void> {
|
|
802
|
-
const command = new CreateUserCommand(
|
|
803
|
-
request.email,
|
|
804
|
-
request.name,
|
|
805
|
-
request.age
|
|
806
|
-
);
|
|
807
|
-
|
|
808
|
-
await this.mediator.send(command);
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
@Get(':id')
|
|
812
|
-
async getById(@Param('id') id: string): Promise<UserApiResponse> {
|
|
813
|
-
const query = new GetUserQuery(id);
|
|
814
|
-
const user = await this.mediator.query(query);
|
|
815
|
-
|
|
816
|
-
return {
|
|
817
|
-
id: user.id,
|
|
818
|
-
email: user.email,
|
|
819
|
-
name: user.name,
|
|
820
|
-
age: user.age,
|
|
821
|
-
createdAt: user.createdAt,
|
|
822
|
-
};
|
|
680
|
+
@Injectable()
|
|
681
|
+
@PipelineBehavior({ priority: 95, scope: 'command' })
|
|
682
|
+
export class CreateUserValidation implements IPipelineBehavior<CreateUserCommand, void> {
|
|
683
|
+
@Handle() // Only runs for CreateUserCommand
|
|
684
|
+
async handle(request: CreateUserCommand, next: () => Promise<void>): Promise<void> {
|
|
685
|
+
if (!request.email.includes('@')) throw new Error('Invalid email');
|
|
686
|
+
return next();
|
|
823
687
|
}
|
|
824
688
|
}
|
|
825
689
|
```
|
|
826
690
|
|
|
827
|
-
###
|
|
828
|
-
|
|
829
|
-
#### app.module.ts
|
|
691
|
+
### Skip Behaviors
|
|
830
692
|
|
|
831
693
|
```typescript
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
import { UserController } from './presentation/user/user.controller';
|
|
835
|
-
import { CreateUserCommandHandler } from './application/user/create-user.handler';
|
|
836
|
-
import { GetUserQueryHandler } from './application/user/get-user.handler';
|
|
837
|
-
import { USER_PERSISTOR } from './application/user/user-persistor.port';
|
|
838
|
-
import { UserPersistenceAdapter } from './infrastructure/persistence/user/user-persistence.adapter';
|
|
839
|
-
|
|
840
|
-
@Module({
|
|
841
|
-
imports: [
|
|
842
|
-
// Enable pipeline behaviors for logging, validation, and error handling
|
|
843
|
-
NestMediatorModule.forRoot({
|
|
844
|
-
enableLogging: true,
|
|
845
|
-
enableValidation: true,
|
|
846
|
-
enableExceptionHandling: true,
|
|
847
|
-
}),
|
|
848
|
-
],
|
|
849
|
-
controllers: [UserController],
|
|
850
|
-
providers: [
|
|
851
|
-
// Infrastructure
|
|
852
|
-
{
|
|
853
|
-
provide: USER_PERSISTOR,
|
|
854
|
-
useClass: UserPersistenceAdapter,
|
|
855
|
-
},
|
|
856
|
-
// Handlers - automatically discovered and registered by the mediator
|
|
857
|
-
CreateUserCommandHandler,
|
|
858
|
-
GetUserQueryHandler,
|
|
859
|
-
],
|
|
860
|
-
})
|
|
861
|
-
export class AppModule {}
|
|
862
|
-
```
|
|
863
|
-
|
|
864
|
-
### Key Benefits
|
|
865
|
-
|
|
866
|
-
1. **Domain Layer**: Pure business logic, framework-agnostic
|
|
867
|
-
- Entities contain business rules and invariants
|
|
868
|
-
- Domain exceptions represent business errors
|
|
869
|
-
|
|
870
|
-
2. **Application Layer**: Use cases and business workflows
|
|
871
|
-
- Commands/Queries define application operations
|
|
872
|
-
- Handlers orchestrate domain objects and ports
|
|
873
|
-
- Ports (interfaces) define contracts for infrastructure
|
|
874
|
-
|
|
875
|
-
3. **Infrastructure Layer**: Technical implementations
|
|
876
|
-
- Adapters implement port interfaces
|
|
877
|
-
- Database, external services, file systems, etc.
|
|
878
|
-
|
|
879
|
-
4. **Presentation Layer**: API interface
|
|
880
|
-
- Controllers handle HTTP concerns
|
|
881
|
-
- DTOs for API request/response
|
|
882
|
-
- No business logic
|
|
883
|
-
|
|
884
|
-
This separation enables:
|
|
885
|
-
- Easy testing (mock ports/adapters)
|
|
886
|
-
- Technology independence (swap databases/frameworks)
|
|
887
|
-
- Clear boundaries and responsibilities
|
|
888
|
-
- Scalable architecture for growing applications
|
|
889
|
-
|
|
890
|
-
## API Reference
|
|
891
|
-
|
|
892
|
-
### Interfaces
|
|
893
|
-
|
|
894
|
-
#### `ICommand`
|
|
895
|
-
|
|
896
|
-
Marker interface for commands.
|
|
897
|
-
|
|
898
|
-
```typescript
|
|
899
|
-
export interface ICommand {}
|
|
900
|
-
```
|
|
901
|
-
|
|
902
|
-
#### `ICommandHandler<TCommand>`
|
|
903
|
-
|
|
904
|
-
Interface for command handlers.
|
|
905
|
-
|
|
906
|
-
```typescript
|
|
907
|
-
export interface ICommandHandler<TCommand extends ICommand> {
|
|
908
|
-
execute(command: TCommand): Promise<void>;
|
|
909
|
-
}
|
|
910
|
-
```
|
|
911
|
-
|
|
912
|
-
#### `IQuery`
|
|
913
|
-
|
|
914
|
-
Marker interface for queries.
|
|
694
|
+
@SkipBehavior(PerformanceBehavior)
|
|
695
|
+
export class HighFrequencyCommand implements ICommand {}
|
|
915
696
|
|
|
916
|
-
|
|
917
|
-
export
|
|
697
|
+
@SkipBehavior([PerformanceBehavior, LoggingBehavior])
|
|
698
|
+
export class HealthCheckQuery implements IQuery {}
|
|
918
699
|
```
|
|
919
700
|
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
Interface for query handlers.
|
|
701
|
+
### Priority Guidelines
|
|
923
702
|
|
|
924
|
-
```typescript
|
|
925
|
-
export interface IQueryHandler<TQuery extends IQuery, TResult = any> {
|
|
926
|
-
execute(query: TQuery): Promise<TResult>;
|
|
927
|
-
}
|
|
928
703
|
```
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
```typescript
|
|
935
|
-
export interface IPipelineBehavior<TRequest = any, TResponse = any> {
|
|
936
|
-
handle(request: TRequest, next: () => Promise<TResponse>): Promise<TResponse>;
|
|
937
|
-
}
|
|
938
|
-
```
|
|
939
|
-
|
|
940
|
-
#### `IEvent`
|
|
941
|
-
|
|
942
|
-
Marker interface for domain events.
|
|
943
|
-
|
|
944
|
-
```typescript
|
|
945
|
-
export interface IEvent {}
|
|
704
|
+
-100 to -1: Exception handling (outermost)
|
|
705
|
+
0 to 99: Logging, performance tracking
|
|
706
|
+
100 to 199: Validation
|
|
707
|
+
200+: Transaction / unit of work (innermost)
|
|
946
708
|
```
|
|
947
709
|
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
Interface for event consumers (non-critical or critical without compensation).
|
|
710
|
+
---
|
|
951
711
|
|
|
952
|
-
|
|
953
|
-
export interface IEventConsumer<TEvent extends IEvent> {
|
|
954
|
-
handle(event: TEvent): Promise<void>;
|
|
955
|
-
}
|
|
956
|
-
```
|
|
712
|
+
## Event Store Configuration
|
|
957
713
|
|
|
958
|
-
|
|
714
|
+
The event store is flexible — you control how it connects, what repository it uses, and what table it writes to.
|
|
959
715
|
|
|
960
|
-
|
|
716
|
+
### Connection Options
|
|
961
717
|
|
|
962
718
|
```typescript
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
*/
|
|
969
|
-
compensate?(event: TEvent): Promise<void>;
|
|
719
|
+
// Option 1: Library manages the connection pool
|
|
720
|
+
eventStore: {
|
|
721
|
+
type: 'postgres',
|
|
722
|
+
url: 'postgres://user:pass@localhost:5432/mydb',
|
|
723
|
+
mode: 'source',
|
|
970
724
|
}
|
|
971
|
-
```
|
|
972
725
|
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
export interface EventPublishResult {
|
|
979
|
-
totalHandlers: number;
|
|
980
|
-
criticalSucceeded: number;
|
|
981
|
-
nonCriticalDispatched: number;
|
|
982
|
-
compensationsRun: number; // Number of compensations executed on failure
|
|
726
|
+
// Option 2: Reuse an existing connection pool from your app
|
|
727
|
+
eventStore: {
|
|
728
|
+
type: 'postgres',
|
|
729
|
+
useExistingPool: 'DATABASE_POOL', // your DI token
|
|
730
|
+
mode: 'audit',
|
|
983
731
|
}
|
|
984
|
-
```
|
|
985
|
-
|
|
986
|
-
#### `EventCriticality`
|
|
987
|
-
|
|
988
|
-
Enum for event consumer criticality.
|
|
989
732
|
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
733
|
+
// Option 3: Bring your own IEventStoreRepository implementation
|
|
734
|
+
eventStore: {
|
|
735
|
+
type: 'postgres',
|
|
736
|
+
url: 'postgres://...', // used only for schema creation
|
|
737
|
+
useExistingRepository: 'MY_EVENT_STORE', // your DI token
|
|
738
|
+
mode: 'source',
|
|
994
739
|
}
|
|
995
740
|
```
|
|
996
741
|
|
|
997
|
-
###
|
|
998
|
-
|
|
999
|
-
#### `@CommandHandler(command)`
|
|
1000
|
-
|
|
1001
|
-
Marks a class as a command handler.
|
|
1002
|
-
|
|
1003
|
-
- **Parameters**: `command` - The command class this handler handles
|
|
1004
|
-
- **Usage**: Apply to handler classes that implement `ICommandHandler`
|
|
1005
|
-
|
|
1006
|
-
#### `@QueryHandler(query)`
|
|
1007
|
-
|
|
1008
|
-
Marks a class as a query handler.
|
|
1009
|
-
|
|
1010
|
-
- **Parameters**: `query` - The query class this handler handles
|
|
1011
|
-
- **Usage**: Apply to handler classes that implement `IQueryHandler`
|
|
1012
|
-
|
|
1013
|
-
#### `@PipelineBehavior(options?)`
|
|
1014
|
-
|
|
1015
|
-
Marks a class as a pipeline behavior.
|
|
1016
|
-
|
|
1017
|
-
- **Parameters**:
|
|
1018
|
-
- `options.priority` - Execution order (lower numbers execute first, default: 0)
|
|
1019
|
-
- `options.scope` - `'command'`, `'query'`, or `'all'` (default: `'all'`)
|
|
1020
|
-
- **Usage**: Apply to behavior classes that implement `IPipelineBehavior`
|
|
1021
|
-
|
|
1022
|
-
#### `@Handle()`
|
|
1023
|
-
|
|
1024
|
-
Method decorator that enables automatic request type inference for pipeline behaviors.
|
|
742
|
+
### Custom Event Store Repository
|
|
1025
743
|
|
|
1026
|
-
-
|
|
1027
|
-
- **Usage**: Apply to the `handle` method to make the behavior type-specific
|
|
744
|
+
If the built-in PostgreSQL repository doesn't fit your needs, implement `IEventStoreRepository` and register it yourself:
|
|
1028
745
|
|
|
1029
746
|
```typescript
|
|
1030
|
-
// Generic behavior - applies to ALL requests in scope
|
|
1031
747
|
@Injectable()
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
748
|
+
export class MyEventStoreRepository implements IEventStoreRepository {
|
|
749
|
+
async saveEvent(event: StoredEvent): Promise<void> { /* ... */ }
|
|
750
|
+
async appendEvents(aggregateType: string, aggregateId: string,
|
|
751
|
+
events: StoredEvent[], expectedVersion: number): Promise<void> { /* ... */ }
|
|
752
|
+
async getEventsForAggregate(aggregateType: string,
|
|
753
|
+
aggregateId: string): Promise<StoredEvent[]> { /* ... */ }
|
|
754
|
+
async getNextSequence(aggregateType: string,
|
|
755
|
+
aggregateId: string): Promise<number> { /* ... */ }
|
|
1039
756
|
}
|
|
1040
757
|
|
|
1041
|
-
//
|
|
1042
|
-
@Injectable()
|
|
1043
|
-
@PipelineBehavior({ priority: 100, scope: 'command' })
|
|
1044
|
-
export class CreateUserValidationBehavior
|
|
1045
|
-
implements IPipelineBehavior<CreateUserCommand, void> {
|
|
1046
|
-
@Handle() // <-- Enables type inference
|
|
1047
|
-
async handle(request: CreateUserCommand, next: () => Promise<void>) {
|
|
1048
|
-
// Only runs for CreateUserCommand
|
|
1049
|
-
return next();
|
|
1050
|
-
}
|
|
1051
|
-
}
|
|
1052
|
-
```
|
|
1053
|
-
|
|
1054
|
-
#### `@SkipBehavior(behavior | behaviors[])`
|
|
1055
|
-
|
|
1056
|
-
Excludes specific pipeline behaviors from a command or query.
|
|
1057
|
-
|
|
1058
|
-
- **Parameters**:
|
|
1059
|
-
- Single behavior class: `@SkipBehavior(PerformanceBehavior)`
|
|
1060
|
-
- Array of behavior classes: `@SkipBehavior([PerformanceBehavior, LoggingBehavior])`
|
|
1061
|
-
- **Usage**: Apply to command or query classes
|
|
1062
|
-
- **Works with**: Both built-in behaviors and custom behaviors
|
|
1063
|
-
|
|
1064
|
-
#### `@EventHandler(event)`
|
|
1065
|
-
|
|
1066
|
-
Marks a class as an event consumer.
|
|
1067
|
-
|
|
1068
|
-
- **Parameters**: `event` - The event class this consumer handles
|
|
1069
|
-
- **Usage**: Apply to consumer classes that implement `IEventConsumer`
|
|
1070
|
-
|
|
1071
|
-
#### `@Critical(options?)`
|
|
1072
|
-
|
|
1073
|
-
Marks an event consumer as critical. Critical consumers run sequentially in order.
|
|
1074
|
-
|
|
1075
|
-
- **Parameters**:
|
|
1076
|
-
- `options.order` - Execution order among critical consumers (lower numbers first, default: 0)
|
|
1077
|
-
- **Usage**: Apply to consumer classes alongside `@EventHandler`
|
|
1078
|
-
- **Behavior**: If a critical consumer fails, remaining critical consumers are skipped and non-critical consumers don't run
|
|
1079
|
-
|
|
1080
|
-
#### `@NonCritical()`
|
|
1081
|
-
|
|
1082
|
-
Marks an event consumer as non-critical. Non-critical consumers run in parallel after critical consumers complete.
|
|
1083
|
-
|
|
1084
|
-
- **Parameters**: None
|
|
1085
|
-
- **Usage**: Apply to consumer classes alongside `@EventHandler`
|
|
1086
|
-
- **Behavior**: Fire-and-forget - failures are logged but don't affect the publish result
|
|
1087
|
-
- **Note**: Consumers without `@Critical` or `@NonCritical` default to non-critical
|
|
1088
|
-
|
|
1089
|
-
### Services
|
|
1090
|
-
|
|
1091
|
-
#### `MediatorBus`
|
|
1092
|
-
|
|
1093
|
-
The main service for sending commands, queries, and events.
|
|
1094
|
-
|
|
1095
|
-
##### Methods
|
|
1096
|
-
|
|
1097
|
-
**`send<TCommand>(command: TCommand): Promise<void>`**
|
|
1098
|
-
|
|
1099
|
-
Sends a command to its registered handler.
|
|
1100
|
-
|
|
1101
|
-
- **Parameters**: `command` - The command instance to execute
|
|
1102
|
-
- **Returns**: Promise that resolves when the command is executed
|
|
1103
|
-
- **Throws**: `HandlerNotFoundException` if no handler is registered for the command
|
|
1104
|
-
|
|
1105
|
-
**`query<TQuery, TResult>(query: TQuery): Promise<TResult>`**
|
|
1106
|
-
|
|
1107
|
-
Executes a query through its registered handler.
|
|
1108
|
-
|
|
1109
|
-
- **Parameters**: `query` - The query instance to execute
|
|
1110
|
-
- **Returns**: Promise that resolves with the query result
|
|
1111
|
-
- **Throws**: `HandlerNotFoundException` if no handler is registered for the query
|
|
1112
|
-
|
|
1113
|
-
**`publish<TEvent>(event: TEvent): Promise<EventPublishResult>`**
|
|
1114
|
-
|
|
1115
|
-
Publishes an event to all registered consumers.
|
|
1116
|
-
|
|
1117
|
-
- **Parameters**: `event` - The event instance to publish
|
|
1118
|
-
- **Returns**: Promise with `EventPublishResult` containing:
|
|
1119
|
-
- `totalHandlers` - Total number of consumers
|
|
1120
|
-
- `criticalSucceeded` - Number of critical consumers that completed
|
|
1121
|
-
- `nonCriticalDispatched` - Number of non-critical consumers dispatched
|
|
1122
|
-
- **Throws**: Error if any critical consumer fails
|
|
1123
|
-
|
|
1124
|
-
### Module Configuration
|
|
1125
|
-
|
|
1126
|
-
#### `NestMediatorModule.forRoot()`
|
|
1127
|
-
|
|
1128
|
-
Basic module registration with no built-in behaviors.
|
|
1129
|
-
|
|
1130
|
-
#### `NestMediatorModule.forRoot(options)`
|
|
1131
|
-
|
|
1132
|
-
Module registration with configuration options.
|
|
1133
|
-
|
|
1134
|
-
| Option | Type | Default | Description |
|
|
1135
|
-
|--------|------|---------|-------------|
|
|
1136
|
-
| `enableLogging` | boolean | false | Enable request/response logging |
|
|
1137
|
-
| `enableValidation` | boolean | false | Enable class-validator validation |
|
|
1138
|
-
| `enableExceptionHandling` | boolean | false | Enable centralized exception logging |
|
|
1139
|
-
| `enablePerformanceTracking` | boolean | false | Enable slow request warnings |
|
|
1140
|
-
| `performanceThresholdMs` | number | 500 | Threshold for slow request warnings |
|
|
1141
|
-
|
|
1142
|
-
## Pipeline Behaviors
|
|
1143
|
-
|
|
1144
|
-
Pipeline behaviors allow you to add cross-cutting concerns (like logging, validation, caching) that execute around every command and query handler.
|
|
1145
|
-
|
|
1146
|
-
### Enabling Built-in Behaviors
|
|
1147
|
-
|
|
1148
|
-
```typescript
|
|
1149
|
-
import { Module } from '@nestjs/common';
|
|
1150
|
-
import { NestMediatorModule } from '@rolandsall24/nest-mediator';
|
|
1151
|
-
|
|
758
|
+
// Register in your module
|
|
1152
759
|
@Module({
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
enableLogging: true, // Logs request handling with timing
|
|
1156
|
-
enableValidation: true, // Validates requests using class-validator
|
|
1157
|
-
enableExceptionHandling: true, // Centralized exception logging
|
|
1158
|
-
enablePerformanceTracking: true, // Warns on slow requests
|
|
1159
|
-
performanceThresholdMs: 500, // Threshold for slow request warnings
|
|
1160
|
-
}),
|
|
760
|
+
providers: [
|
|
761
|
+
{ provide: 'MY_EVENT_STORE', useClass: MyEventStoreRepository },
|
|
1161
762
|
],
|
|
1162
763
|
})
|
|
1163
|
-
export class
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
| `ValidationBehavior` | 100 | Validates requests using class-validator |
|
|
1174
|
-
|
|
1175
|
-
### Creating Custom Behaviors
|
|
1176
|
-
|
|
1177
|
-
```typescript
|
|
1178
|
-
import { Injectable } from '@nestjs/common';
|
|
1179
|
-
import { IPipelineBehavior, PipelineBehavior } from '@rolandsall24/nest-mediator';
|
|
1180
|
-
|
|
1181
|
-
@Injectable()
|
|
1182
|
-
@PipelineBehavior({ priority: 50, scope: 'all' })
|
|
1183
|
-
export class MyCustomBehavior<TRequest, TResponse>
|
|
1184
|
-
implements IPipelineBehavior<TRequest, TResponse> {
|
|
1185
|
-
|
|
1186
|
-
async handle(
|
|
1187
|
-
request: TRequest,
|
|
1188
|
-
next: () => Promise<TResponse>
|
|
1189
|
-
): Promise<TResponse> {
|
|
1190
|
-
// Pre-processing logic
|
|
1191
|
-
console.log('Before handler:', request);
|
|
1192
|
-
|
|
1193
|
-
// Call the next behavior or handler
|
|
1194
|
-
const response = await next();
|
|
1195
|
-
|
|
1196
|
-
// Post-processing logic
|
|
1197
|
-
console.log('After handler:', response);
|
|
1198
|
-
|
|
1199
|
-
return response;
|
|
1200
|
-
}
|
|
1201
|
-
}
|
|
1202
|
-
```
|
|
1203
|
-
|
|
1204
|
-
### Behavior Options
|
|
1205
|
-
|
|
1206
|
-
```typescript
|
|
1207
|
-
@PipelineBehavior({
|
|
1208
|
-
priority: 50, // Lower numbers execute first (outermost)
|
|
1209
|
-
scope: 'command', // 'command', 'query', or 'all'
|
|
764
|
+
export class PersistenceModule {}
|
|
765
|
+
|
|
766
|
+
// Reference in config
|
|
767
|
+
NestMediatorModule.forRoot({
|
|
768
|
+
eventStore: {
|
|
769
|
+
type: 'postgres',
|
|
770
|
+
url: process.env.DATABASE_URL, // schema creation only
|
|
771
|
+
useExistingRepository: 'MY_EVENT_STORE',
|
|
772
|
+
mode: 'source',
|
|
773
|
+
},
|
|
1210
774
|
})
|
|
1211
775
|
```
|
|
1212
776
|
|
|
1213
|
-
|
|
1214
|
-
- `-100 to -1`: Exception handling (outermost)
|
|
1215
|
-
- `0 to 99`: Logging, performance tracking
|
|
1216
|
-
- `100 to 199`: Validation
|
|
1217
|
-
- `200+`: Transaction/Unit of Work (innermost)
|
|
1218
|
-
|
|
1219
|
-
### Registering Custom Behaviors
|
|
777
|
+
The library creates the schema using a temporary connection, then delegates all operations to your repository. The `AggregateRepository` base class injects the `EVENT_STORE_REPOSITORY` token, which resolves to whatever you provide.
|
|
1220
778
|
|
|
1221
|
-
Custom
|
|
779
|
+
### Custom Repository Class
|
|
1222
780
|
|
|
1223
|
-
|
|
1224
|
-
@Module({
|
|
1225
|
-
imports: [NestMediatorModule.forRoot()],
|
|
1226
|
-
providers: [MyCustomBehavior], // Auto-discovered via @PipelineBehavior decorator
|
|
1227
|
-
})
|
|
1228
|
-
export class AppModule {}
|
|
1229
|
-
```
|
|
1230
|
-
|
|
1231
|
-
### Skipping Behaviors for Specific Commands/Queries
|
|
1232
|
-
|
|
1233
|
-
Use `@SkipBehavior` to exclude specific behaviors from a command or query:
|
|
781
|
+
Alternatively, pass a class and let the library instantiate it with the pool:
|
|
1234
782
|
|
|
1235
783
|
```typescript
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
} from '@rolandsall24/nest-mediator';
|
|
1242
|
-
|
|
1243
|
-
// Skip a single behavior
|
|
1244
|
-
@SkipBehavior(PerformanceBehavior)
|
|
1245
|
-
export class HighFrequencyCommand implements ICommand {
|
|
1246
|
-
// This command will not trigger performance tracking
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
// Skip multiple behaviors
|
|
1250
|
-
@SkipBehavior([PerformanceBehavior, LoggingBehavior])
|
|
1251
|
-
export class HealthCheckQuery implements IQuery {
|
|
1252
|
-
// This query skips both performance and logging behaviors
|
|
784
|
+
eventStore: {
|
|
785
|
+
type: 'postgres',
|
|
786
|
+
url: process.env.DATABASE_URL,
|
|
787
|
+
repository: MyCustomPostgresEventStore, // must implement IEventStoreRepository
|
|
788
|
+
mode: 'source',
|
|
1253
789
|
}
|
|
1254
790
|
```
|
|
1255
791
|
|
|
1256
|
-
|
|
792
|
+
### Full Configuration Reference
|
|
1257
793
|
|
|
1258
794
|
```typescript
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
//
|
|
1265
|
-
|
|
795
|
+
NestMediatorModule.forRoot({
|
|
796
|
+
enableLogging?: boolean, // default: false
|
|
797
|
+
enableValidation?: boolean, // default: false
|
|
798
|
+
enableExceptionHandling?: boolean, // default: false
|
|
799
|
+
enablePerformanceTracking?: boolean, // default: false
|
|
800
|
+
performanceThresholdMs?: number, // default: 500
|
|
801
|
+
eventStore?: {
|
|
802
|
+
type: 'postgres',
|
|
803
|
+
url?: string, // library-managed pool
|
|
804
|
+
useExistingPool?: string, // reuse your pool (DI token)
|
|
805
|
+
useExistingRepository?: string, // bring your own repo (DI token)
|
|
806
|
+
repository?: Type<IEventStoreRepository>, // custom repo class
|
|
807
|
+
mode?: 'audit' | 'source', // default: 'audit'
|
|
808
|
+
tableName?: string, // default: 'domain_events'
|
|
809
|
+
},
|
|
810
|
+
})
|
|
1266
811
|
```
|
|
1267
812
|
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
Behaviors support full NestJS dependency injection:
|
|
813
|
+
---
|
|
1271
814
|
|
|
1272
|
-
|
|
1273
|
-
import { Injectable, Logger } from '@nestjs/common';
|
|
1274
|
-
import { IPipelineBehavior, PipelineBehavior } from '@rolandsall24/nest-mediator';
|
|
815
|
+
## API Reference
|
|
1275
816
|
|
|
1276
|
-
|
|
1277
|
-
@Injectable()
|
|
1278
|
-
export class AuditService {
|
|
1279
|
-
private readonly logger = new Logger(AuditService.name);
|
|
817
|
+
### Interfaces
|
|
1280
818
|
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
819
|
+
| Interface | Description |
|
|
820
|
+
|-----------|-------------|
|
|
821
|
+
| `ICommand` | Marker interface for commands |
|
|
822
|
+
| `ICommandHandler<T>` | `execute(command: T): Promise<void>` |
|
|
823
|
+
| `IQuery` | Marker interface for queries |
|
|
824
|
+
| `IQueryHandler<T, R>` | `execute(query: T): Promise<R>` |
|
|
825
|
+
| `IEvent` | Marker interface for events |
|
|
826
|
+
| `IEventConsumer<T>` | `handle(event: T): Promise<void>` |
|
|
827
|
+
| `ICriticalEventConsumer<T>` | Extends `IEventConsumer` with `applyCompensatingEvent()` |
|
|
828
|
+
| `IPipelineBehavior<T, R>` | `handle(request: T, next: () => Promise<R>): Promise<R>` |
|
|
829
|
+
| `IEventStoreRepository` | `saveEvent()`, `appendEvents()`, `getEventsForAggregate()`, `getNextSequence()` |
|
|
830
|
+
| `AggregateRoot<TId>` | Base class for event-sourced aggregates |
|
|
831
|
+
| `AggregateRepository<T, TId>` | Base class for aggregate repositories |
|
|
1286
832
|
|
|
1287
|
-
|
|
1288
|
-
@Injectable()
|
|
1289
|
-
@PipelineBehavior({ priority: 50, scope: 'command' })
|
|
1290
|
-
export class AuditLoggingBehavior<TRequest, TResponse>
|
|
1291
|
-
implements IPipelineBehavior<TRequest, TResponse> {
|
|
833
|
+
### Decorators
|
|
1292
834
|
|
|
1293
|
-
|
|
835
|
+
| Decorator | Target | Description |
|
|
836
|
+
|-----------|--------|-------------|
|
|
837
|
+
| `@CommandHandler(CommandClass)` | Class | Registers a command handler |
|
|
838
|
+
| `@QueryHandler(QueryClass)` | Class | Registers a query handler |
|
|
839
|
+
| `@EventHandler(EventClass)` | Class | Registers an event consumer |
|
|
840
|
+
| `@Critical({ order: n })` | Class | Marks consumer as critical (sequential) |
|
|
841
|
+
| `@NonCritical()` | Class | Marks consumer as non-critical (fire-and-forget) |
|
|
842
|
+
| `@DomainEvent(aggregate, idProp)` | Class | Associates event with aggregate + registers in event registry. Required for source mode; optional in audit mode (adds `aggregate_type`/`aggregate_id` to logs) |
|
|
843
|
+
| `@ForAggregate(AggregateClass)` | Class | Wires an `AggregateRepository` to its aggregate (zero-boilerplate) |
|
|
844
|
+
| `@PipelineBehavior(options)` | Class | Registers a pipeline behavior |
|
|
845
|
+
| `@Handle()` | Method | Enables type-specific behavior filtering |
|
|
846
|
+
| `@SkipBehavior(behavior)` | Class | Excludes behaviors from a command/query |
|
|
847
|
+
|
|
848
|
+
### MediatorBus
|
|
849
|
+
|
|
850
|
+
| Method | Description |
|
|
851
|
+
|--------|-------------|
|
|
852
|
+
| `send<T>(command: T)` | Execute a command |
|
|
853
|
+
| `query<T, R>(query: T)` | Execute a query |
|
|
854
|
+
| `publish<T>(event: T)` | Publish an event to all consumers |
|
|
1294
855
|
|
|
1295
|
-
|
|
1296
|
-
request: TRequest,
|
|
1297
|
-
next: () => Promise<TResponse>,
|
|
1298
|
-
): Promise<TResponse> {
|
|
1299
|
-
const requestName = request.constructor.name;
|
|
856
|
+
---
|
|
1300
857
|
|
|
1301
|
-
|
|
858
|
+
## Example Projects
|
|
1302
859
|
|
|
1303
|
-
|
|
860
|
+
The repository includes two complete example projects demonstrating clean architecture (`domain/`, `application/`, `infrastructure/`, `presentation/`):
|
|
1304
861
|
|
|
1305
|
-
|
|
862
|
+
### `example-audit/`
|
|
1306
863
|
|
|
1307
|
-
|
|
1308
|
-
}
|
|
1309
|
-
}
|
|
864
|
+
**Audit mode** — Orders stored in a PostgreSQL `orders` table. Events logged to `audit_events` for traceability.
|
|
1310
865
|
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
AuditService, // The service
|
|
1316
|
-
AuditLoggingBehavior, // The behavior (auto-discovered)
|
|
1317
|
-
],
|
|
1318
|
-
})
|
|
1319
|
-
export class AppModule {}
|
|
866
|
+
```bash
|
|
867
|
+
cd example-audit
|
|
868
|
+
docker compose up -d # PostgreSQL on port 5433
|
|
869
|
+
DATABASE_URL=postgres://postgres:postgres@localhost:5433/audit_example npm run start:dev
|
|
1320
870
|
```
|
|
1321
871
|
|
|
1322
|
-
|
|
872
|
+
| Endpoint | Description |
|
|
873
|
+
|----------|-------------|
|
|
874
|
+
| `POST /orders` | Create an order (saved to `orders` table + event logged) |
|
|
875
|
+
| `POST /orders/:id/cancel` | Cancel an order |
|
|
876
|
+
| `GET /orders/:id` | Get order from `orders` table |
|
|
877
|
+
| `GET /orders` | List all orders |
|
|
878
|
+
| `GET /internals/events` | View audit event log |
|
|
1323
879
|
|
|
1324
|
-
|
|
880
|
+
### `example-source/`
|
|
1325
881
|
|
|
1326
|
-
|
|
1327
|
-
@Injectable()
|
|
1328
|
-
@PipelineBehavior({ priority: -50, scope: 'command' })
|
|
1329
|
-
export class RetryBehavior<TRequest, TResponse>
|
|
1330
|
-
implements IPipelineBehavior<TRequest, TResponse> {
|
|
1331
|
-
|
|
1332
|
-
private readonly maxRetries = 3;
|
|
1333
|
-
|
|
1334
|
-
async handle(
|
|
1335
|
-
request: TRequest,
|
|
1336
|
-
next: () => Promise<TResponse>,
|
|
1337
|
-
): Promise<TResponse> {
|
|
1338
|
-
let lastError: Error;
|
|
1339
|
-
|
|
1340
|
-
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
|
|
1341
|
-
try {
|
|
1342
|
-
return await next();
|
|
1343
|
-
} catch (error) {
|
|
1344
|
-
lastError = error as Error;
|
|
1345
|
-
|
|
1346
|
-
// Don't retry validation errors
|
|
1347
|
-
if (error.name?.includes('Validation')) throw error;
|
|
1348
|
-
|
|
1349
|
-
if (attempt < this.maxRetries) {
|
|
1350
|
-
const delay = 100 * Math.pow(2, attempt - 1); // Exponential backoff
|
|
1351
|
-
await new Promise(resolve => setTimeout(resolve, delay));
|
|
1352
|
-
}
|
|
1353
|
-
}
|
|
1354
|
-
}
|
|
882
|
+
**Source mode** — No `orders` table. State rebuilt entirely from events in `domain_events`.
|
|
1355
883
|
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
884
|
+
```bash
|
|
885
|
+
cd example-source
|
|
886
|
+
docker compose up -d # PostgreSQL on port 5434
|
|
887
|
+
DATABASE_URL=postgres://postgres:postgres@localhost:5434/source_example npm run start:dev
|
|
1359
888
|
```
|
|
1360
889
|
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
private cache = new Map<string, { data: any; expiry: number }>();
|
|
890
|
+
| Endpoint | Description |
|
|
891
|
+
|----------|-------------|
|
|
892
|
+
| `POST /orders` | Place an order (event persisted, state rebuilt from events) |
|
|
893
|
+
| `POST /orders/:id/cancel` | Cancel via aggregate pattern (load -> validate -> save) |
|
|
894
|
+
| `GET /orders/:id` | Get order state (rebuilt by replaying events) |
|
|
895
|
+
| `GET /orders/:id/events` | View raw event stream for an order |
|
|
896
|
+
| `POST /orders/test-concurrency` | Demo: fires two cancels simultaneously, one gets `ConcurrencyError` |
|
|
1370
897
|
|
|
1371
|
-
|
|
1372
|
-
request: TRequest,
|
|
1373
|
-
next: () => Promise<TResponse>,
|
|
1374
|
-
): Promise<TResponse> {
|
|
1375
|
-
const key = JSON.stringify(request);
|
|
1376
|
-
const cached = this.cache.get(key);
|
|
898
|
+
---
|
|
1377
899
|
|
|
1378
|
-
|
|
1379
|
-
return cached.data; // Cache hit
|
|
1380
|
-
}
|
|
900
|
+
## Migrating to v1.0.0
|
|
1381
901
|
|
|
1382
|
-
|
|
902
|
+
### `AggregateRepository` — zero-boilerplate with `@ForAggregate`
|
|
1383
903
|
|
|
1384
|
-
|
|
1385
|
-
data: result,
|
|
1386
|
-
expiry: Date.now() + 30000, // 30 second TTL
|
|
1387
|
-
});
|
|
1388
|
-
|
|
1389
|
-
return result;
|
|
1390
|
-
}
|
|
1391
|
-
}
|
|
1392
|
-
```
|
|
904
|
+
The `AggregateRepository` no longer requires a constructor, `aggregateType`, `createEmptyAggregate()`, or `deserializeEvent()` overrides. Use the `@ForAggregate` decorator instead.
|
|
1393
905
|
|
|
1394
|
-
|
|
906
|
+
**Before:**
|
|
1395
907
|
|
|
1396
908
|
```typescript
|
|
1397
909
|
@Injectable()
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
constructor(private readonly authService: AuthService) {}
|
|
1403
|
-
|
|
1404
|
-
private readonly adminOnlyCommands = ['DeleteUserCommand'];
|
|
1405
|
-
|
|
1406
|
-
async handle(
|
|
1407
|
-
request: TRequest,
|
|
1408
|
-
next: () => Promise<TResponse>,
|
|
1409
|
-
): Promise<TResponse> {
|
|
1410
|
-
const requestName = request.constructor.name;
|
|
910
|
+
export class OrderAggregateRepository
|
|
911
|
+
extends AggregateRepository<OrderAggregate, string>
|
|
912
|
+
{
|
|
913
|
+
protected readonly aggregateType = 'Order';
|
|
1411
914
|
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
915
|
+
constructor(
|
|
916
|
+
@Inject(EVENT_STORE_REPOSITORY) eventStore: IEventStoreRepository,
|
|
917
|
+
mediatorBus: MediatorBus,
|
|
918
|
+
) {
|
|
919
|
+
super(eventStore, mediatorBus);
|
|
920
|
+
}
|
|
1415
921
|
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
}
|
|
1420
|
-
}
|
|
922
|
+
protected createEmptyAggregate(): OrderAggregate {
|
|
923
|
+
return new OrderAggregate();
|
|
924
|
+
}
|
|
1421
925
|
|
|
1422
|
-
|
|
926
|
+
protected deserializeEvent(eventType: string, payload: Record<string, unknown>): IEvent {
|
|
927
|
+
const types = { OrderPlacedEvent, OrderCancelledEvent };
|
|
928
|
+
const EventClass = types[eventType];
|
|
929
|
+
if (!EventClass) throw new Error(`Unknown event type: ${eventType}`);
|
|
930
|
+
return Object.assign(Object.create(EventClass.prototype), payload);
|
|
1423
931
|
}
|
|
1424
932
|
}
|
|
1425
933
|
```
|
|
1426
934
|
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
By default, behaviors apply to all requests matching their scope. To create a behavior that only applies to specific request types, add `@Handle()` to the `handle` method:
|
|
935
|
+
**After:**
|
|
1430
936
|
|
|
1431
937
|
```typescript
|
|
1432
|
-
import { Injectable } from '@nestjs/common';
|
|
1433
|
-
import { IPipelineBehavior, PipelineBehavior, Handle } from '@rolandsall24/nest-mediator';
|
|
1434
|
-
import { CreateUserCommand } from './create-user.command';
|
|
1435
|
-
|
|
1436
938
|
@Injectable()
|
|
1437
|
-
@
|
|
1438
|
-
export class
|
|
1439
|
-
|
|
1440
|
-
{
|
|
1441
|
-
@Handle() // Enables type inference from method signature
|
|
1442
|
-
async handle(
|
|
1443
|
-
request: CreateUserCommand,
|
|
1444
|
-
next: () => Promise<void>,
|
|
1445
|
-
): Promise<void> {
|
|
1446
|
-
// This behavior ONLY runs for CreateUserCommand instances
|
|
1447
|
-
// No manual instanceof check needed!
|
|
1448
|
-
|
|
1449
|
-
const errors: string[] = [];
|
|
1450
|
-
|
|
1451
|
-
if (!request.name || request.name.length < 2) {
|
|
1452
|
-
errors.push('Name must be at least 2 characters');
|
|
1453
|
-
}
|
|
1454
|
-
|
|
1455
|
-
if (!request.email || !request.email.includes('@')) {
|
|
1456
|
-
errors.push('Valid email is required');
|
|
1457
|
-
}
|
|
1458
|
-
|
|
1459
|
-
if (errors.length > 0) {
|
|
1460
|
-
throw new Error(`Validation failed: ${errors.join(', ')}`);
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
return next();
|
|
1464
|
-
}
|
|
1465
|
-
}
|
|
939
|
+
@ForAggregate(OrderAggregate)
|
|
940
|
+
export class OrderAggregateRepository
|
|
941
|
+
extends AggregateRepository<OrderAggregate, string> {}
|
|
1466
942
|
```
|
|
1467
943
|
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
1. The `@Handle()` decorator on the `handle` method triggers TypeScript to emit `design:paramtypes` metadata
|
|
1471
|
-
2. At module initialization, the library reads this metadata to determine the request type (`CreateUserCommand`)
|
|
1472
|
-
3. During pipeline execution, the behavior is only included when `request instanceof CreateUserCommand` is true
|
|
1473
|
-
|
|
1474
|
-
**Requirements:**
|
|
1475
|
-
- TypeScript `emitDecoratorMetadata: true` must be enabled in tsconfig.json
|
|
1476
|
-
- The request parameter must be a concrete class (not an interface or `any`)
|
|
944
|
+
The base class now uses property injection (`@Inject`) for `eventStore` and `eventBus`, derives `aggregateType` from the aggregate class, and uses the `@DomainEvent` registry for automatic event deserialization. All methods remain overridable for custom logic.
|
|
1477
945
|
|
|
1478
|
-
|
|
946
|
+
### `compensate()` -> `applyCompensatingEvent()`
|
|
1479
947
|
|
|
1480
|
-
|
|
1481
|
-
|---------------------|------------------|
|
|
1482
|
-
| Behavior runs for ALL commands | Behavior runs ONLY for specified type |
|
|
1483
|
-
| Must use `instanceof` check inside handler | No `instanceof` check needed |
|
|
1484
|
-
| Generic `<TRequest, TResponse>` | Specific type like `<CreateUserCommand, void>` |
|
|
948
|
+
The `compensate()` method on `ICriticalEventConsumer` is **deprecated**. It performed side effects directly, which were not captured in the event store and not traceable.
|
|
1485
949
|
|
|
1486
|
-
|
|
950
|
+
Use `applyCompensatingEvent()` instead — it returns a compensating event that the library publishes through the full event flow (persisted, dispatched, traceable).
|
|
1487
951
|
|
|
1488
|
-
|
|
952
|
+
**Before (deprecated):**
|
|
1489
953
|
|
|
1490
954
|
```typescript
|
|
1491
|
-
|
|
1492
|
-
//
|
|
1493
|
-
|
|
1494
|
-
// PerformanceBehavior: priority 10, scope 'all'
|
|
1495
|
-
// ValidationBehavior: priority 100, scope 'all'
|
|
1496
|
-
|
|
1497
|
-
// Custom behaviors:
|
|
1498
|
-
// RetryBehavior: priority -50, scope 'command'
|
|
1499
|
-
// CachingBehavior: priority 5, scope 'query'
|
|
1500
|
-
// AuthorizationBehavior: priority 25, scope 'all'
|
|
1501
|
-
// AuditLoggingBehavior: priority 50, scope 'command'
|
|
1502
|
-
```
|
|
1503
|
-
|
|
1504
|
-
**For a Command:**
|
|
1505
|
-
```
|
|
1506
|
-
Request → Exception(-100) → Retry(-50) → Logging(0) → Performance(10) → Authorization(25) → Audit(50) → Validation(100) → Handler
|
|
1507
|
-
```
|
|
1508
|
-
|
|
1509
|
-
**For a Query:**
|
|
1510
|
-
```
|
|
1511
|
-
Request → Exception(-100) → Logging(0) → Caching(5) → Performance(10) → Authorization(25) → Validation(100) → Handler
|
|
955
|
+
async compensate(event: OrderPlacedEvent): Promise<void> {
|
|
956
|
+
await this.inventoryService.release(event.orderId); // Side effect, not persisted
|
|
957
|
+
}
|
|
1512
958
|
```
|
|
1513
959
|
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
When `enableValidation` is true, requests are validated using class-validator (if installed):
|
|
960
|
+
**After:**
|
|
1517
961
|
|
|
1518
962
|
```typescript
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
export class CreateUserCommand implements ICommand {
|
|
1523
|
-
@IsEmail()
|
|
1524
|
-
email: string;
|
|
1525
|
-
|
|
1526
|
-
@IsString()
|
|
1527
|
-
@MinLength(2)
|
|
1528
|
-
name: string;
|
|
1529
|
-
|
|
1530
|
-
constructor(email: string, name: string) {
|
|
1531
|
-
this.email = email;
|
|
1532
|
-
this.name = name;
|
|
1533
|
-
}
|
|
963
|
+
async applyCompensatingEvent(event: OrderPlacedEvent): Promise<IEvent> {
|
|
964
|
+
return new InventoryReleasedEvent(event.orderId); // Event published through full flow
|
|
1534
965
|
}
|
|
1535
966
|
|
|
1536
|
-
//
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
The `next()` function is a delegate that invokes the next behavior in the pipeline (or the final handler). Here's how it works:
|
|
1542
|
-
|
|
1543
|
-
```
|
|
1544
|
-
┌─────────────────────────────────────────────────────────────────────────┐
|
|
1545
|
-
│ REQUEST FLOW (→) │
|
|
1546
|
-
│ │
|
|
1547
|
-
│ Request │
|
|
1548
|
-
│ │ │
|
|
1549
|
-
│ ▼ │
|
|
1550
|
-
│ ┌──────────────────────┐ │
|
|
1551
|
-
│ │ Behavior A │ 1. Pre-processing (before next()) │
|
|
1552
|
-
│ │ (priority: -100) │ - Wrap in try/catch │
|
|
1553
|
-
│ │ │ - Start timer │
|
|
1554
|
-
│ │ return next() ──────┼──► │
|
|
1555
|
-
│ └──────────────────────┘ │
|
|
1556
|
-
│ │ │
|
|
1557
|
-
│ ▼ │
|
|
1558
|
-
│ ┌──────────────────────┐ │
|
|
1559
|
-
│ │ Behavior B │ 2. Pre-processing │
|
|
1560
|
-
│ │ (priority: 0) │ - Log request │
|
|
1561
|
-
│ │ │ │
|
|
1562
|
-
│ │ return next() ──────┼──► │
|
|
1563
|
-
│ └──────────────────────┘ │
|
|
1564
|
-
│ │ │
|
|
1565
|
-
│ ▼ │
|
|
1566
|
-
│ ┌──────────────────────┐ │
|
|
1567
|
-
│ │ Behavior C │ │
|
|
1568
|
-
│ │ (priority: 100) │ │
|
|
1569
|
-
│ │ │ │
|
|
1570
|
-
│ │ return next() ──────┼──►
|
|
1571
|
-
│ └──────────────────────┘ │
|
|
1572
|
-
│ │
|
|
1573
|
-
│ │ │
|
|
1574
|
-
│ ▼ │
|
|
1575
|
-
│ ┌──────────────────────┐ │
|
|
1576
|
-
│ │ HANDLER │ │
|
|
1577
|
-
│ │ execute(request) │ │
|
|
1578
|
-
│ │ │ │
|
|
1579
|
-
│ │ return result ◄─────┼──┤
|
|
1580
|
-
│ └──────────────────────┘ │
|
|
1581
|
-
│ │
|
|
1582
|
-
└─────────────────────────────────────────────────────────────────────────┘
|
|
1583
|
-
|
|
1584
|
-
┌─────────────────────────────────────────────────────────────────────────┐
|
|
1585
|
-
│ RESPONSE FLOW (←) │
|
|
1586
|
-
│ │
|
|
1587
|
-
│ ┌──────────────────────┐ │
|
|
1588
|
-
│ │ Behavior A │ 4. Post-processing (after next() returns) │
|
|
1589
|
-
│ │ (priority: -100) │ - Catch errors │
|
|
1590
|
-
│ │ │ - Calculate duration │
|
|
1591
|
-
│ │ ◄── result ─────────┼── │
|
|
1592
|
-
│ └──────────────────────┘ │
|
|
1593
|
-
│ │ ▲ │
|
|
1594
|
-
│ ▼ │ │
|
|
1595
|
-
│ Response ┌──────────────────────┐ │
|
|
1596
|
-
│ │ Behavior B │ 3. Post-processing │
|
|
1597
|
-
│ │ (priority: 0) │ - Log response │
|
|
1598
|
-
│ │ │ - Log duration │
|
|
1599
|
-
│ │ ◄── result ─────────┼── │
|
|
1600
|
-
│ └──────────────────────┘ │
|
|
1601
|
-
│ ▲ │
|
|
1602
|
-
│ │ │
|
|
1603
|
-
│ ┌──────────────────────┐ │
|
|
1604
|
-
│ │ Behavior C │ │
|
|
1605
|
-
│ │ (priority: 100) │ │
|
|
1606
|
-
│ │ │ │
|
|
1607
|
-
│ │ ◄── result ─────────┼──┤
|
|
1608
|
-
│ └──────────────────────┘ │
|
|
1609
|
-
│ │
|
|
1610
|
-
└─────────────────────────────────────────────────────────────────────────┘
|
|
1611
|
-
```
|
|
1612
|
-
|
|
1613
|
-
**Code Example:**
|
|
1614
|
-
|
|
1615
|
-
```typescript
|
|
1616
|
-
@Injectable()
|
|
1617
|
-
@PipelineBehavior({ priority: 0 })
|
|
1618
|
-
export class LoggingBehavior<TRequest, TResponse>
|
|
1619
|
-
implements IPipelineBehavior<TRequest, TResponse>
|
|
1620
|
-
{
|
|
1621
|
-
async handle(
|
|
1622
|
-
request: TRequest,
|
|
1623
|
-
next: () => Promise<TResponse> // Delegate to next behavior/handler
|
|
1624
|
-
): Promise<TResponse> {
|
|
1625
|
-
// ═══════════════════════════════════════════
|
|
1626
|
-
// PRE-PROCESSING (runs BEFORE the handler)
|
|
1627
|
-
// ═══════════════════════════════════════════
|
|
1628
|
-
const start = Date.now();
|
|
1629
|
-
console.log(`→ Handling ${request.constructor.name}...`);
|
|
1630
|
-
|
|
1631
|
-
// ═══════════════════════════════════════════
|
|
1632
|
-
// CALL NEXT (invokes next behavior or handler)
|
|
1633
|
-
// ═══════════════════════════════════════════
|
|
1634
|
-
const response = await next();
|
|
1635
|
-
|
|
1636
|
-
// ═══════════════════════════════════════════
|
|
1637
|
-
// POST-PROCESSING (runs AFTER the handler)
|
|
1638
|
-
// ═══════════════════════════════════════════
|
|
1639
|
-
const duration = Date.now() - start;
|
|
1640
|
-
console.log(`← Handled ${request.constructor.name} in ${duration}ms`);
|
|
1641
|
-
|
|
1642
|
-
return response;
|
|
967
|
+
// Dedicated consumer for the compensating event
|
|
968
|
+
@EventHandler(InventoryReleasedEvent)
|
|
969
|
+
export class HandleInventoryReleasedHandler implements IEventConsumer<InventoryReleasedEvent> {
|
|
970
|
+
async handle(event: InventoryReleasedEvent): Promise<void> {
|
|
971
|
+
await this.inventoryService.release(event.orderId); // Logic lives here now
|
|
1643
972
|
}
|
|
1644
973
|
}
|
|
1645
974
|
```
|
|
1646
975
|
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
1. **`next()` is a function** that returns a `Promise` - you must `await` it
|
|
1650
|
-
2. **Code before `await next()`** runs during the request phase (pre-processing)
|
|
1651
|
-
3. **Code after `await next()`** runs during the response phase (post-processing)
|
|
1652
|
-
4. **Lower priority = outer wrapper** - executes first on request, last on response
|
|
1653
|
-
5. **Higher priority = inner wrapper** - executes last on request, first on response
|
|
1654
|
-
6. **If you don't call `next()`**, the handler never executes (useful for short-circuiting)
|
|
1655
|
-
7. **Exceptions propagate outward** through the `await next()` chain
|
|
1656
|
-
|
|
1657
|
-
### Pipeline Execution Order
|
|
1658
|
-
|
|
1659
|
-
With behaviors at priorities -100, 0, 10, 100:
|
|
1660
|
-
|
|
1661
|
-
```
|
|
1662
|
-
Request → ExceptionHandling(-100) → Logging(0) → Performance(10) → Validation(100) → Handler
|
|
1663
|
-
Response ← ExceptionHandling(-100) ← Logging(0) ← Performance(10) ← Validation(100) ← Handler
|
|
1664
|
-
```
|
|
1665
|
-
|
|
1666
|
-
## Best Practices
|
|
1667
|
-
|
|
1668
|
-
1. **Keep Commands and Queries Simple**: They should be simple data containers with minimal logic.
|
|
1669
|
-
|
|
1670
|
-
2. **One Handler Per Command/Query**: Each command or query should have exactly one handler.
|
|
1671
|
-
|
|
1672
|
-
3. **Use Dependency Injection**: Inject required services into your handlers through the constructor.
|
|
1673
|
-
|
|
1674
|
-
4. **Type Safety**: Always specify the return type for queries using the generic parameters.
|
|
1675
|
-
|
|
1676
|
-
5. **Error Handling**: Implement proper error handling in your handlers.
|
|
976
|
+
### Other Changes
|
|
1677
977
|
|
|
1678
|
-
|
|
978
|
+
- **`IEventHandler` renamed to `IEventConsumer`** — `IEventHandler` is a deprecated alias
|
|
979
|
+
- **MediatorBus API unchanged** — `send()`, `query()`, `publish()` work as before
|
|
1679
980
|
|
|
1680
981
|
## License
|
|
1681
982
|
|