ドメインイベントを Generator で表現する

ふと思いついたので書いた。

IDDD 本では Observer Pattern を使った基盤をドメインコードが利用しているが、これに似た機能を JavaScript の Generator/AsyncGenerator で再現してドメインイベントをより独立にする試み。

サンプルコードはこちらから

IDDD 本では Observer Pattern を使っていた。ドメインコードを呼び出す前に、アプリケーション内であらかじめ subscribe しておく。呼ばれたドメインコードは、内でドメインイベントを DomainEventPublisher で publish する。subscriber はそれを元に、別のアプリケーション操作を実行したりミドルウェアにメッセージを送信したりする。

翻って、JavaScript には Generator や AsyncGenerator がある。処理を停止させたり復帰させたりでき、その時に値をやり取りもできる。無限列挙したり非同期処理をしたりするのがよくある使い方と思われる。また、この機能により yield で途中経過を得ることもできる。(ジェネレータ自体はほかの言語にもあるが、return がなかったりする。その時は列挙という側面が強くなる。)

1
2
3
4
5
6
7
function* fibonacci() {
let pair = [0, 1];
while (true) {
yield pair[0];
pair = [pair[1], pair[0] + pair[1]];
}
}
1
2
3
4
5
6
7
8
9
async function* longProcess(initial) {
const first = await firstProcess(initial);
yield "1/3";
const middle = await middleProcess(first);
yield "2/3";
const last = await lastProcess(middle);
yield "3/3"
return last;
}

return は従来通りドメインコードのメソッドの戻り値とし、yield を途中経過や列挙でなく、ドメインイベントを表す、という表現が可能ではないかと考えた。
この利点は、基盤的なツールへの関心を一つ減らせることがある。ドメインがプログラミング上のみで必要になる外部コードに依存するよりも良いと考えている。副次的に、TypeScript の場合、Generator<DomeinEvent,TReturn> と(勝手に)表現でき、ドメインイベントを外部に型付けすることができる(例: AsyncGenerator<NewUserCreated | UserActivated, User>)。

domain
1
2
3
4
5
6
7
8
9
10
11
public static *createNew() {
yield new NewUserCreated(); // Domain event

return new User();
}

public *activate() {
this.#activated = true;

yield new UserActivated(); // Domain event
}

アプリケーションサービスではジェネレータの停止や復帰は必要のない側面なのでそれを無視(バイパス)して呼び出したい。こういう場所では yield* が使える。

サンプルコードでは表現できていないが、 Decorator のような仕組みを利用して、ドメインイベント (yield したもの)をアプリケーションサービスの代わりに何かのインフラにハンドルさせる。(Decorator の型をうまいこと設定できると良いのだが…) こうすることで、ドメインイベントに関心を持たないコントローラやイベントリスナ層からは普通のメソッドとしてアプリケーションサービスを呼べる。

application
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public async *createUser(firstName: string, lastName: string) {
const user = yield* User.createNew(firstName, lastName);

await this.#userRepository.save(user);

return user;
}

public async *activateUser(id: UserId) {
const user = await this.#repo.get(id);

yield* user.activate();

await this.#repo.save(user);
}

// 外からはドメインイベントがハンドルされていて各々こう見える
// public createUser(firstName: string, lastName: string): Promise<User>
// public activateUser(id: UserId): Promise<void>
controller or listener
1
2
const user = await this.#userService.createUser(firstName, lastName);
await this.#userService.activateUser(id);

これらの処理の基盤となる部分はコードを見ていただくとして、大体このようになっている。

Decorate でやりたいところは Proxy で実現している。途中で出てくる eventReceiver が Generator を「消費」してドメインイベントを MessageQueue に流す。(Decorator でうまいこと利用元で EventHandled 前を隠したいが難しい)

infrastructure
1
2
3
4
5
6
<T extends {}>(a: T): EventHandled<T> => new Proxy(a, {
get(target, p) {
// private field を利用するメソッドが呼べない問題を回避する
return (...args: any[]) => eventReceiver.receive(target[p](...args));
},
});
controller or listener layer
1
2
3
4
5
6
7
8
9
10
11
12
13
type EventHandled<T> = {
[K in keyof T]: T[K] extends (
...args: infer P
) => AsyncGenerator<any, infer R>
? (...args: P) => Promise<R>
: T[K] extends (...args: infer P) => Generator<any, infer R>
? (...args: P) => R
: T[K];
};
// () => T -> () => T
// Generator<E,T> -> () => T
// () => Promise<T> -> () => Promise<T>
// AsyncGenerator<E,T> -> () => Promise<T>

Observer Pattern と比べ、この表現方法では(基盤以外は)関心が各々より閉じていると思う。トランザクションが失敗したらイベントは無かったことにする、というケースも同様にできるはず。言語によってはインフラ層で型タイプやトレイとでおもむろにインフラをドメインコードに追加することもできるのだろう。