Skip to content

Commit

Permalink
SPSH-618 Event System (#472)
Browse files Browse the repository at this point in the history
Adds the event-system
  • Loading branch information
marode-cap authored Apr 30, 2024
1 parent accb824 commit 30d1d32
Show file tree
Hide file tree
Showing 17 changed files with 588 additions and 3 deletions.
88 changes: 88 additions & 0 deletions docs/event-bus.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# EventModule

The [`EventModule`](/src/core/eventbus/event.module.ts) can be used to listen to and publish events across the whole application. This can be used for decoupling logic.

The event system stores a `RXJS.Subject` for every event that is subscribed to.
Every event handler is registered as an observer of that subject and when publishing an Event it is pushed into the correct subject.

A specific order of the event-handlers is not guaranteed and should not be relied upon.

## Publishing events

All events need to extend the [`BaseEvent`](/src/shared/events/base-event.ts). We place all events in [`/src/shared/events`](/src/shared/events).

BaseEvent uses a marker property to ensure, that object literals can never be given to the event-system. This is important, because the event-system assumes that events are always specific class instances and it uses the constructor to differentiate events.

```ts
export class UserCreationEvent extends BaseEvent {
public constructor(public readonly userId: string) {
super();
}
}
```

Events can be published using the [`EventService`](/src/core/eventbus/services/event.service.ts). Simply inject it in your classes and publish new events.

```ts
@Controller({ path: '/user' })
export class UserController {
public constructor(private readonly eventService: EventService) {}

@Post()
public createUser(@Body() user): void {
this.eventService.publish(new UserCreationEvent(user.id));
}
}
```

Publishing an event can never fail. If one or more of the registered observers throw an error, the error will be logged.

## Listening to events

In order to listen to events, you need to register you handlers with the event service.

Handlers have to be of type `(ev: MyEvent) => void | Promise<void>`.
Errors that are thrown inside of handler methods will be logged but won't crash the application.

You can register your handlers using the following methods:

### Using @EventHandler()

The [`@EventHandler(...)`](/src/core/eventbus/decorators/event-handler.decorator.ts)-Decorator can be used to decorate methods of providers or controllers. Event-handlers will be automatically discovered and registered by the [`EventDiscoveryService`](src/core/eventbus/services/event-discovery.service.ts).

Make sure to add the class to your module definition so it gets registered by NestJS.

```ts
@Injectable()
export class ExampleProvider {
@EventHandler(UserCreationEvent)
public syncEventHandler(event: UserCreationEvent): void {
// Will be called for every published UserCreationEvent
}

@EventHandler(UserCreationEvent)
public async asyncEventHandler(event: UserCreationEvent): Promise<void> {
// Will be called for every published UserCreationEvent
}
}
```

### Using the `.register`-method directly

You can also register event-handlers by using the `EventService.subscribe`-method. This approach is not recommended but may be useful in some niche situations.

```ts
@Injectable()
export class ExampleProvider {
public constructor(eventService: EventService) {
const handler = (ev: UserCreationEvent) => {
// [...]
};

eventService.subscribe(UserCreationEvent, handler);

// You can unregister events using 'unsubscribe'
eventService.unsubscribe(UserCreationEvent, handler);
}
}
```
19 changes: 16 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"@automapper/core": "^8.8.1",
"@automapper/nestjs": "^8.8.1",
"@faker-js/faker": "^8.4.1",
"@golevelup/nestjs-discovery": "^4.0.1",
"@mikro-orm/core": "^6.2.1",
"@mikro-orm/nestjs": "^5.2.3",
"@mikro-orm/postgresql": "^6.2.1",
Expand Down
32 changes: 32 additions & 0 deletions src/core/eventbus/decorators/event-handler.decorator.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/* eslint-disable max-classes-per-file */
import { BaseEvent } from '../../../shared/events/index.js';
import { EVENT_HANDLER_META } from '../types/metadata-key.js';
import { EventHandler } from './event-handler.decorator.js';

class TestEvent extends BaseEvent {
public constructor() {
super();
}
}

class TestClass {
@EventHandler(TestEvent)
public handler(_ev: TestEvent): void {}

// @ts-expect-error typechecker should detect invalid function signature
@EventHandler(TestEvent)
public invalidArgs(_ev: string): void {}

// @ts-expect-error typechecker should detect invalid function signature
@EventHandler(TestEvent)
public invalidArgs2(_ev: TestEvent, _xyz: string): void {}
}

describe('EventHandler decorator', () => {
it('should set metadata', () => {
// eslint-disable-next-line jest/unbound-method
const meta: unknown = Reflect.getMetadata(EVENT_HANDLER_META, TestClass.prototype.handler);

expect(meta).toBe(TestEvent);
});
});
11 changes: 11 additions & 0 deletions src/core/eventbus/decorators/event-handler.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { SetMetadata } from '@nestjs/common';

import { BaseEvent } from '../../../shared/events/index.js';
import { EVENT_HANDLER_META } from '../types/metadata-key.js';
import { Constructor, EventHandlerType, TypedMethodDecorator } from '../types/util.types.js';

export function EventHandler<Event extends BaseEvent, ClassType, MethodName extends keyof ClassType>(
eventClass: Constructor<Event>,
): TypedMethodDecorator<ClassType, MethodName, EventHandlerType<Event>> {
return SetMetadata(EVENT_HANDLER_META, eventClass);
}
29 changes: 29 additions & 0 deletions src/core/eventbus/event.module.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Test, TestingModule } from '@nestjs/testing';

import { ConfigTestModule } from '../../../test/utils/index.js';
import { EventModule } from './event.module.js';
import { EventService } from './services/event.service.js';

describe('EventModule', () => {
let module: TestingModule;

beforeAll(async () => {
module = await Test.createTestingModule({
imports: [EventModule, ConfigTestModule],
}).compile();

await module.init();
});

afterAll(async () => {
await module.close();
});

it('should be defined', () => {
expect(module).toBeDefined();
});

it('should export EventService', () => {
expect(module.get(EventService)).toBeDefined();
});
});
25 changes: 25 additions & 0 deletions src/core/eventbus/event.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { DiscoveryModule } from '@golevelup/nestjs-discovery';
import { Global, Module, OnApplicationBootstrap } from '@nestjs/common';

import { ClassLogger } from '../logging/class-logger.js';
import { LoggerModule } from '../logging/logger.module.js';
import { EventDiscoveryService } from './services/event-discovery.service.js';
import { EventService } from './services/event.service.js';

@Global()
@Module({
imports: [LoggerModule.register(EventModule.name), DiscoveryModule],
providers: [EventService, EventDiscoveryService],
exports: [EventService],
})
export class EventModule implements OnApplicationBootstrap {
public constructor(
private readonly logger: ClassLogger,
private readonly discoveryService: EventDiscoveryService,
) {}

public async onApplicationBootstrap(): Promise<void> {
const handlerCount: number = await this.discoveryService.registerEventHandlers();
this.logger.notice(`Registered ${handlerCount} event listeners`);
}
}
2 changes: 2 additions & 0 deletions src/core/eventbus/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './event.module.js';
export * from './services/event.service.js';
96 changes: 96 additions & 0 deletions src/core/eventbus/services/event-discovery.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// eslint-disable-next-line max-classes-per-file
import { DiscoveryModule } from '@golevelup/nestjs-discovery';
import { DeepMocked, createMock } from '@golevelup/ts-jest';
import { Controller, Injectable } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';

import { LoggingTestModule } from '../../../../test/utils/index.js';
import { BaseEvent } from '../../../shared/events/index.js';
import { EventHandler } from '../decorators/event-handler.decorator.js';
import { EventDiscoveryService } from './event-discovery.service.js';
import { EventService } from './event.service.js';

class TestEvent extends BaseEvent {
public constructor() {
super();
}
}

@Injectable()
class TestProvider {
@EventHandler(TestEvent)
public handleEvent(_event: TestEvent): void {}

public undecoratedMethod(): void {}
}

@Controller({})
class TestController {
@EventHandler(TestEvent)
public handleEvent(_event: TestEvent): void {}

public undecoratedMethod(): void {}
}

describe('EventService', () => {
let module: TestingModule;

let sut: EventDiscoveryService;
let eventServiceMock: DeepMocked<EventService>;

let testProvider: TestProvider;
let testController: TestController;

beforeAll(async () => {
module = await Test.createTestingModule({
imports: [LoggingTestModule, DiscoveryModule],
providers: [
EventDiscoveryService,
TestProvider,
{ provide: EventService, useValue: createMock<EventService>() },
],
controllers: [TestController],
}).compile();

await module.init();

sut = module.get(EventDiscoveryService);
eventServiceMock = module.get(EventService);

testProvider = module.get(TestProvider);
testController = module.get(TestController);
});

afterAll(async () => {
await module.close();
});

afterEach(() => {
jest.resetAllMocks();
});

it('should be defined', () => {
expect(sut).toBeDefined();
});

describe('registerEventHandlers', () => {
it('should discover and register all event handlers in controllers', async () => {
await sut.registerEventHandlers();

expect(eventServiceMock.subscribe).toHaveBeenCalledWith(TestEvent, testController.handleEvent);
});

it('should discover and register all event handlers in providers', async () => {
await sut.registerEventHandlers();

expect(eventServiceMock.subscribe).toHaveBeenCalledWith(TestEvent, testProvider.handleEvent);
});

it('should return the number of registered handlers', async () => {
const count: number = await sut.registerEventHandlers();

expect(eventServiceMock.subscribe).toHaveBeenCalledTimes(2);
expect(count).toBe(2);
});
});
});
49 changes: 49 additions & 0 deletions src/core/eventbus/services/event-discovery.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { DiscoveredMethodWithMeta, DiscoveryService } from '@golevelup/nestjs-discovery';
import { Injectable } from '@nestjs/common';

import { BaseEvent } from '../../../shared/events/index.js';
import { ClassLogger } from '../../logging/class-logger.js';
import { EVENT_HANDLER_META } from '../types/metadata-key.js';
import { Constructor, EventHandlerType } from '../types/util.types.js';
import { EventService } from './event.service.js';

type HandlerMethod = DiscoveredMethodWithMeta<Constructor<BaseEvent>>;

@Injectable()
export class EventDiscoveryService {
public constructor(
private readonly logger: ClassLogger,
private readonly discoveryService: DiscoveryService,
private readonly eventService: EventService,
) {}

private async discoverHandlerMethods(): Promise<HandlerMethod[]> {
const controllerMethods: HandlerMethod[] =
await this.discoveryService.controllerMethodsWithMetaAtKey(EVENT_HANDLER_META);

const providerMethods: HandlerMethod[] =
await this.discoveryService.providerMethodsWithMetaAtKey(EVENT_HANDLER_META);

return [...controllerMethods, ...providerMethods];
}

public async registerEventHandlers(): Promise<number> {
const results: HandlerMethod[] = await this.discoverHandlerMethods();

results.forEach((method: HandlerMethod) => {
const eventConstructor: Constructor<BaseEvent> = method.meta;
const eventHandler: EventHandlerType<BaseEvent> = method.discoveredMethod.handler;

this.eventService.subscribe(method.meta, eventHandler);

const parentClassName: string = method.discoveredMethod.parentClass.name;
const handlerMethodName: string = method.discoveredMethod.methodName;

this.logger.notice(
`Registered handler '${parentClassName}.${handlerMethodName}' for '${eventConstructor.name}'`,
);
});

return results.length;
}
}
Loading

0 comments on commit 30d1d32

Please sign in to comment.