0%
November 9, 2024

ApplicationEventPublisher with Decorators for Domain Driven Design

decorators

typescript

Result (Code Simplification)

Code Simplified!

Preface

From a previous blog post

In this article we have implemented

  • an ApplicationEventPublisher and
  • addEventHandler that essentially adds a eventName to (() => Promise<void>)[] pair In this article we aim at simplifying it as in the result image above, which looks exactly the same as how spring boot works without any boilerplate code.
compilerOptions of tsconfig.json
  "compilerOptions": {
    "target": "ES2015",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    ...
  }
Stage-3 or Stage-2 Decorators?

As of now (2024-11-09) the development of decorators in javascript has reached stage3 with breaking changes from stage2.

The npm package reflect-metadata that returns the reflection of the metadata of class and class-method relies heavily on stage2-decorators and therefore we have to set:

  • --experimentalDecorators and
  • --emitDecoratorMetadata to ensure we are using stage2-decorators.

decorators.ts with a simple example

The imports and Constants
import "reflect-metadata"

export type ListenerMetadata = {
    methodName: string;
    handler: Function;
    eventName: string;
    order: number;
    nextEvent: string
}

export const NEXT_LISTENER_METADATA_KEY = "nextListener";
export const LISTENER_METADATA_KEY = "listeners";
const LISTENER_ORDER_KEY = "order"
  • reflect-metadata is used to get the metadata of classes and methods inside of a decorator function.

  • LISTENER_METADATA_KEY and LISTENER_ORDER_KEY defines the key for setting a key-value pair whose value is to be the metadata of a class, or a method.

  • As we shall see later, when we want to store method level data, we use

    Reflect.defineMetadata(LISTENER_ORDER_KEY, methodLevelData, target, propertyKey);

    where target is the class, propertyKey is the name of a method, the tuple

    (LISTENER_METADATA_KEY, class, methodname)

    defines a key for the methodLevelMetaData.

  • Similarly, when we want to store class level data, we use

    Reflect.defineMetadata(LISTENER_METADATA_KEY, classLevelData, target.constructor);

    where (LISTENER_METADATA_KEY, target.constructor) defines a key for the classLevelMetaData.

    It should be clear target is the class-Object in this context.

@order
export function order(order: number) {
    return function (...args: any[]) {
        const [target, propertyKey, descriptor] = args;
        Reflect.defineMetadata(LISTENER_ORDER_KEY, order, target, propertyKey);
        return descriptor;
    };
}
@nextEvent
export function nextEvent(nextClass: Function) {
    return function (...args:any[]) {
        const [target, propertyKey] = args;
        Reflect.defineMetadata(NEXT_LISTENER_METADATA_KEY, nextClass, target, propertyKey);
    };
}
@listener
export function listener(...args: any[]) {
    const [target, propertyKey, descriptor] = args;
    const eventName = Reflect.getMetadata("design:paramtypes", target, propertyKey)[0].name;
    const existingListeners: ListenerMetadata[] =
        Reflect.getMetadata(LISTENER_METADATA_KEY, target.constructor) || [];
    const order: number = Reflect.getMetadata(LISTENER_ORDER_KEY, target, propertyKey) || 0;
    const nextEvent = (Reflect.getMetadata(NEXT_LISTENER_METADATA_KEY, target, propertyKey)?.name || "") as string ;
    

    existingListeners.push({
        methodName: propertyKey.toString(),
        handler: descriptor.value!,
        eventName: eventName,
        order,
        nextEvent
    });

    Reflect.defineMetadata(LISTENER_METADATA_KEY, existingListeners, target.constructor);

    return descriptor;
}

Note that we have added order and nextEvent here, which means that @order and @nextEvent should be executed before @listener, this is achieved by

    @listener
    @order(1)               // or   @nextEvent(NextEvent)
    @nextEvent(NextEvent)   //      @order(1)
    async someMethod() {
        ...
    }

because decorators are executed in bottom-up manner.

Let's print out the metadata
// Test
class InputParam { }
class AnotherParam { }

class Example {
    // decorator executes from bottom to top
    @listener
    @order(1)
    method1(param: InputParam) {
        console.log("Method 1:", param);
    }
    @listener
    @order(2)
    method2(param: AnotherParam) {
        console.log("Method 2:", param);
    }
}

class BetterExample extends InputParam {}

const listeners: ListenerMetadata[] = Reflect.getMetadata(LISTENER_METADATA_KEY, Example);
console.log(listeners);

We get the following listeners' metadata:

[
  {
    methodName: 'method1',
    handler: [Function: method1],
    eventName: 'InputParam',
    order: 1
  },
  {
    methodName: 'method2',
    handler: [Function: method2],
    eventName: 'AnotherParam',
    order: 2
  }
]

This provides all enough information to register listeners to the events.

Simplified applicationEventPublisher and ApplicationEvent

1import { db } from "@src/db/kysely/database";
2
3export type ContextBaseType = {
4    userEmail: string
5}
6
7export class ApplicationEvent<
8    DataType,
9    ContextType extends ContextBaseType = ContextBaseType,
10> {
11    constructor(public data: DataType, public ctx: ContextType) { }
12}
  • Unlike previous post in ApplicationEvent we have replaced ResultType by ContextType.

  • This Context is supposed to be shared by all events within a cycle (those that would be chained together).

    Imagine a fallback loop (starts from SomethingFailedEvent) would be infinite, we need some value in the context to stop us from retrying indefinitely.

15export type EventHandler<Event = any> = (event: Event) => void | Promise<void>;
16type OrderedEventHandler<Event = any> = { handle: EventHandler<Event>; order: number };
17
18class ApplicationEventPublisher {
19    private handlers: Map<string, OrderedEventHandler[]> = new Map<string, OrderedEventHandler[]>();
20
21    constructor() {
22    }
23
24    async publishEvent<D, C extends ContextBaseType>(event: ApplicationEvent<D, C>): Promise<void> {
25        const eventName = Object.getPrototypeOf(event).constructor.name as string;
26        const handlers = this?.handlers?.get(eventName)?.sort((a, b) => a.order - b.order);
27        if (handlers) {
28            for (const handler of handlers) {
29                const handle = handler.handle as EventHandler<typeof event>;
30                await handle(event);
31                await db.insertInto("event_store").values({
32                    event_type: eventName,
33                    payload: { data: event.data, ctx: event.ctx }
34                }).execute();
35            }
36        }
37    }
38
39    addEventListener = <T>(evenType: string, handler: EventHandler<T>, order: number = 1): void => {
40        if (!this.handlers.has(evenType)) {
41            this.handlers.set(evenType, []);
42        }
43        this.handlers.get(evenType)!.push({ handle: handler, order });
44    };
45}
46
47export const applicationEventPublisher = new ApplicationEventPublisher();
48export default ApplicationEventPublisher;

The highlighted block of codes is responsible for storing historical event data. Storing into memory-store such as redis and flush it back to our PostgreSQL by bulk insert is much more appropriate.

Class DecoratedListenersRegister: Register the decorated listeners

The register method

By the previous concrete example (the console.log(listeners); in the previous section right above) the register method is straight forward:

1export default class DecoratedListenersRegister {
2    private listenersRecord: ListenerMetadata[] = [];
3
4    register(listenerClass: Object) {
5        const listeners: ListenerMetadata[] = Reflect.getMetadata(
6            LISTENER_METADATA_KEY,
7            listenerClass
8        );
9
10        for (const listener of listeners) {
11            // nextEvent is not used here, we add it here to remind
12            // the readers we have this value available!
13            const { nextEvent, methodName, eventName, handler, order } = listener
14            this.listenersRecord.push(listener)
15            applicationEventPublisher.addEventListener(
16                eventName,
17                handler as EventHandler<any>,
18                order
19            )
20        }
21    }
22
The plotRelation method
Implementation of plotRelation
  • We will make use of the Node for doubly linked list defined in this blog post.

  • Since our services are decomposed into many small events, some may consider the services as highly-decoupled to the extent that is hard to follow.

  • One solution is to add @nextEvent(NextEvent) attribute to indicate the NextEvent class to be the next target, from that we can plot the relation for easy-debugging (and also see all the events available in the system!)

  • Let's make use of the nextEvent property to chain all the connected events.

Disclaimer 1. I am not proficient in data-structure and algorithm, I am using doubly-linked list just because I feel that appendLeft and appendRight should be appropriate methods for connecting separated "events" ( and into , for example)

Disclaimer 2. The following for-loops may produce redundancies, any improvement to reduce unnecessary loops is welcome.

23    plotRelation() {
24        const items = this.listenersRecord.map(item => new Node({ data: item, uuid: uuidv4() }))
25        const results: Node<{ data: ListenerMetadata, uuid: string }>[] = [];
26        const freezedNodes = Object.freeze(cloneDeep(items));
27        // don't mutate freezedNodes as we need to loop through completely
28        for (const currNode of freezedNodes) {
29            for (let j = 0; j < freezedNodes.length; j++) {
30                for (const connectedItem of currNode.traverse()) {
31                    const eventNamesToSkip = currNode.traverse().map(node => node.value.data.eventName);
32                    for (const traverseItem of freezedNodes) {
33                        if (!eventNamesToSkip.includes(traverseItem.value.data.eventName)) {
34                            if (traverseItem.value.data.nextEvent === connectedItem.value.data.eventName) {
35                                connectedItem.appendLeft(traverseItem);
36                                const removeIndex = items.findIndex(item => item.value.uuid === traverseItem.value.uuid);
37                                items.splice(removeIndex, 1);
38                            }
39                            else if (connectedItem.value.data.nextEvent === traverseItem.value.data.eventName) {
40                                connectedItem.appendRight(traverseItem);
41                                const removeIndex = items.findIndex(item => item.value.uuid === traverseItem.value.uuid);
42                                items.splice(removeIndex, 1);
43                            }
44                        }
45                    }
46                }
47            }
48            const shouldIgnore = results
49                .map(node => node.getHead().value.data.eventName)
50                .includes(currNode.getHead().value.data.eventName)
51            if (!shouldIgnore) {
52                results.push(currNode);
53            }
54        }
55
56        const lifeyCycles: string[] = [];
57        for (const item of results) {
58            let eventLifeCycle = "Start --> ";
59            const events = item.getHead().traverse();
60            events.forEach((node, i) => {
61                const { eventName, nextEvent, methodName } = node.value.data;
62                const nextEventNode = freezedNodes.find(n => n.value.data.eventName === nextEvent);
63                const nextMethodName = nextEventNode?.value.data.methodName || ""
64                if (i === 0) {
65                    eventLifeCycle += `${eventName} (${methodName})`;
66                    if (nextEvent) {
67                        eventLifeCycle += `\n      --> ${nextEvent} (${nextMethodName})`
68                    }
69                } else {
70                    if (nextEvent) {
71                        eventLifeCycle += `\n      --> ${nextEvent} (${nextMethodName})`
72                    }
73                }
74            })
75            if (events.length === 1) {
76                eventLifeCycle += " --> End"
77            } else {
78                eventLifeCycle += "\n      --> End"
79            }
80            lifeyCycles.push(eventLifeCycle);
81        }
82
83        const eventsRelation = lifeyCycles.join("\n");
84        return eventsRelation;
85    }
86}
The result of visualized event-cycles
Start --> IssueSummaryUuidRelationInsertedEvent (insertRelIssueToSummaryUuid) --> End
Start --> WalkOpenedEvent (openWalkOn) --> End
Start --> WalkClosedEvent (closeWalkOn) --> End
Start --> IssueUpdatedEvent (updateIssueOn) --> End
Start --> LLMSummaryPlaceholderCreatedEvent (createLLMPlaceHolderOn) --> End
Start --> LLMSummaryStep1GenerationCommand (generateFreshLLMSummaryOn)
      --> LLMSummaryStep2TranslateAndSaveCommand (transateAndSaveFreshSummaryOn)
      --> LLMSummaryStep3GeneateAndSendExcelCommand (generateAndSendExcelOn)
      --> End
Start --> LLMSyncToAzureCommand (syncToAzureOn) --> End
Start --> LLMSummaryRegenerationCommand (regenerateSummaryOn) --> End
Start --> TranslateAndSaveRegeneratedSummaryCommand (translateAndSaveRegeneratedSummaryOn) --> End