🛰️

NestJSでドメインイベントを実装する

2022/03/13に公開

ドメインイベントを利用すると「~して、~する」といったユースケースを実現できるようになります。具体的にどのようにすればドメインイベントを実装できるか解説します。

ドメインイベントを導入する前のサンプル

まずは、ドメインイベントを導入していないコメントサービスを見てみます。このサービスはコメントを作成・更新・削除するAPIを提供します。

まずはモジュールです。

comment/comment.module.ts
import { Module } from '@nestjs/common';

import { CommentController } from './comment.controller';
import { CommentService } from './comment.service';

@Module({
  imports: [],
  controllers: [CommentController],
  providers: [
    CommentService
  ],
  exports: [CommentService],
})
export class CommentModule {}

次にコントローラです。

comment/comment.controller.ts
import { Controller, Post, Put, Delete, Param, Body, ParseIntPipe } from '@nestjs/common';
import { CommentService } from './comment.service'

@Controller('comments')
export class CommentController {
  constructor(
    private commentService: CommentService,
  ) {}
  
  @Post('/')
  async create(@Param('content') content: string): Promise<void> {
    await this.commentService.create({
      content,
    });
  }
  
  @Put(':createdAt')
  async update(@Param('createdAt', ParseIntPipe) createdAt: number, @Body('content') content: string): Promise<void> {
    await this.commentService.update({
      createdAt,
    });
  }
  
  @Delete(':createdAt')
  async del(@Param('createdAt', ParseIntPipe) createdAt: number): Promise<void> {
    await this.commentService.del({
      createdAt,
    });
  }
}

次にサービスです。commentReposioryの実装は今回は関係ないので省略します。

comment/comment.service.ts
import { Injectable } from '@nestjs/common';
import { Comment } from './entity/comment'

@Injectable()
export class CommentService {

  async create(args: { content: string }): Promise<void> {
    const comment = new Comment({ content, createdAt: new Date() });
    await this.commentReposiory.save(comment);
  }
  
  async update(args: { createdAt: number, content: string }): Promise<void> {
    // createdAtをもとにdbからデータ取得
    const comment = await this.commentRepository.findByCreatedAt({ createdAt });
    // データ更新
    comment.updateContent(content);
    // dbに保存
    await this.commentRepository.update(comment);
  }
  
  async del(args: { createdAt: number }): Promise<void> {
    // createdAtをもとにdbからデータ取得
    const comment = await this.commentRepository.findByCreatedAt({ createdAt });
    // ドメインルールチェック
    if(!comment.canBeDeleted()){
      throw new Error('コメントが削除できません。');
    }
    // dbから削除
    await this.commentRepository.delete({ createdAt });
  }
}

さいごにentityです。

comment/entity/comment.ts
export class Comment {
  private comment: string;
  
  private createdAt: Date;
  
  constructor(args: { comment: string; createdAt: Date;}){
    this.comment = args.comment;
    this.createdAt = args.createdAt;
  }
  
  canBeDeleted(): boolean {
    return true;
  }
}

サンプルは以上です。ここにドメインイベントを追加していきます。

ドメインイベントを準備

まずはドメインイベント名を決めます。それをenum型か文字列リテラルで保持します。ぼくはString based enumが好きなので以下のコードでもそちらを使ってます。これはenumのようにもアクセスできるし、直接文字列を使ってもコンパイルエラーにならないようにできます。使い勝手いいのでおすすめです。

 const Direction = stringToEnum([
  'North', 'South', 'East', 'West'
 ]);
 type Direction = keyof typeof Direction;
 
 const a: Direction = Direction.North; // enumのようにアクセスできる。文字列リテラルだけではこれは不可能。
 const b: Direction = 'North' // 直接の文字列でもコンパイルエラーにならない。enum型ではこれは不可能。
share/util.ts
/**
 * ```
 * const Direction = stringToEnum([
 *  'North', 'South', 'East', 'West'
 * ]);
 *
 * keyofでDirectionのキーを抽出して
 * typeofでUnion Typeを生成する
 * type Direction = keyof typeof Direction;
 * let direction: Direction;
 *
 * direction = Direction.North;
 * direction = 'North'; // Works!!
 * direction = 'AnythingElse'; // Error
 * ```
 * @param o enumにしたいstring配列
 */
export const stringToEnum = <T extends string>(
  o: readonly T[],
): { [K in T]: K } => {
  return o.reduce((accumulator, currentValue) => {
    accumulator[currentValue] = currentValue;

    return accumulator;
  }, Object.create(null));
};

ドメインイベント名決定とインターフェース作成

イベントのベースとなるインターフェースとイベント名を保持したenumを定義します。

share/baseEvent.ts
import { stringToEnum } from './util';

export const EventType = stringToEnum([
  'comment.created',
  'comment.udpated',
  'comment.deleted',
]);
export type EventType = keyof typeof EventType;

export interface DomainEvent {
  readonly type: EventType;
  readonly createdAt: Date;
  readonly data: Record<string, unknown>;
}

ドメインイベント作成

次にコメント作成イベント、コメント更新イベント、コメント削除イベントを作ります。

comment/events/comment.created.event.ts
import { DomainEvent, EventType } from '../../share/baseEvent';
import { Comment } from '../entity/comment';

export class CommentCreatedEvent implements DomainEvent {
  readonly createdAt: Date;

 // インスタンス化後にイベント名を保持する。TSは残念ながらstaticなinterfaceを作成できないため、あとの`static get type()`だけでは型エラーになる。
  readonly type: Extract<
    EventType,
    typeof CommentCreatedEvent.type
  >;

  readonly data: {
    comment: Comment;
  };

  constructor(args: {
    comment: Comment
    createdAt?: Date;
  }) {
    this.createdAt = args.createdAt ?? new Date();
    this.type = CommentCreatedEvent.type;
    this.data = {
      comment: args.comment,
    };
  }

  // インスタンス化しなくてもイベント名にアクセスできるようにする
  static get type(): EventType {
    return EventType['comment.created'];
  }
}
comment/events/comment.updated.event.ts
import { DomainEvent, EventType } from '../../share/baseEvent';
import { Comment } from '../entity/comment';

export class CommentUpdatedEvent implements DomainEvent {
  readonly createdAt: Date;

 // インスタンス化後にイベント名を保持する。TSは残念ながらstaticなinterfaceを作成できないため、あとの`static get type()`だけでは型エラーになる。
  readonly type: Extract<
    EventType,
    typeof CommentUpdatedEvent.type
  >;

  readonly data: {
    comment: Comment;
  };

  constructor(args: {
    comment: Comment
    createdAt?: Date;
  }) {
    this.createdAt = args.createdAt ?? new Date();
    this.type = CommentUpdatedEvent.type;
    this.data = {
      comment: args.comment,
    };
  }

  // インスタンス化しなくてもイベント名にアクセスできるようにする
  static get type(): EventType {
    return EventType['comment.updated'];
  }
}
comment/events/comment.deleted.event.ts
import { DomainEvent, EventType } from '../../share/baseEvent';
import { Comment } from '../entity/comment';

export class CommentDeletedEvent implements DomainEvent {
  readonly createdAt: Date;

 // インスタンス化後にイベント名を保持する。TSは残念ながらstaticなinterfaceを作成できないため、あとの`static get type()`だけでは型エラーになる。
  readonly type: Extract<
    EventType,
    typeof CommentDeletedEvent.type
  >;

  readonly data: {
    comment: Comment;
  };

  constructor(args: {
    comment: Comment
    createdAt?: Date;
  }) {
    this.createdAt = args.createdAt ?? new Date();
    this.type = CommentDeletedEvent.type;
    this.data = {
      comment: args.comment,
    };
  }

  // インスタンス化しなくてもイベント名にアクセスできるようにする
  static get type(): EventType {
    return EventType['comment.deleted'];
  }
}

以上でイベントは完成です。

イベント登録サービス作成

つぎにイベント登録サービスを作ります。イベントのpub/sub自体はnestjsに組み込まれているEventEmitterを使います。以下の記事です。

https://docs.nestjs.com/techniques/events

まずはEventEmitterをインストールします。

$ npm i --save @nestjs/event-emitter

今回はEventEmitterのすべての機能を使わず、emitメソッドのみ使えるようにしたいと思います。そこで独自のEventRegisterServiceを作成します。

eventRegister/eventRegister.module.ts
import { Module, Global } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { EventRegisterService } from './eventRegister.service';

@Global()
@Module({
  imports: [
    EventEmitterModule.forRoot(),
  ],
  providers: [
    EventRegisterService
  ],
  exports: [EventRegisterService],
})
export class EventRegisterModule {}
app.module.ts
import { EventRegisterModule } from './eventRegister/eventRegister.module';

@Module({
  imports: [
    EventRegisterModule,
  ],
  providers: [],
  controllers: [],
})
export class AppModule implements NestModule {}
eventRegister/eventRegister.service.ts
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';

import { DomainEvent } from '../share/baseEvent';

@Injectable()
export class EventRegisterService {
  constructor(private eventEmitter: EventEmitter2) {}

  register(event: DomainEvent): void {
    this.eventEmitter.emit(event.type, event);
  }
}

これで準備完了です。

ドメインイベントを最初のサンプルに導入

ドメインイベントの登録

サービスに先程作ったイベントをEventRegisterServiceを使って登録します。

comment/event/comment.service.ts
import { Injectable } from '@nestjs/common';
import { Comment } from './entity/comment';
import { EventRegisterService } from '../eventRegister/eventRegister.service';
import { CommentCreatedEvent } from './events/comment.created.event';
import { CommentUpdatedEvent } from './events/comment.udpated.event';
import { CommentDeletedEvent } from './events/comment.deleted.event';

@Injectable()
export class CommentService {
  constructor(private eventRegister: EventRegisterService){};

  async create(args: { content: string }): Promise<void> {
    const comment = new Comment({ content, createdAt: new Date() });
    await this.commentReposiory.save(comment);
    
    this.eventRegister.register(new CommentCreatedEvent({ comment }));
  }
  
  async update(args: { createdAt: number, content: string }): Promise<void> {
    // createdAtをもとにdbからデータ取得
    const comment = await this.commentRepository.findByCreatedAt({ createdAt });
    // データ更新
    comment.updateContent(content);
    // dbに保存
    await this.commentRepository.update(comment);
    
    this.eventRegister.register(new CommentUpdatedEvent({ comment }));
  }
  
  async del(args: { createdAt: number }): Promise<void> {
    // createdAtをもとにdbからデータ取得
    const comment = await this.commentRepository.findByCreatedAt({ createdAt });
    // ドメインルールチェック
    if(!comment.canBeDeleted()){
      throw new Error('コメントが削除できません。');
    }
    // dbから削除
    await this.commentRepository.delete({ createdAt });
    
    this.eventRegister.register(new CommentDeletedEvent({ comment }));
  }
}

これらのイベントをリッスンするものを作ります。例えば、コメントが作成・更新・削除された際に、ユーザーに通知したいとします。

通知サービスは次のコードのようなものです。

notification/notification.module.ts
import { Module } from '@nestjs/common';

import { NotificationService } from './notification.service';

@Module({
  imports: [],
  controllers: [],
  providers: [
    NotificationService
  ],
  exports: [NotificationService],
})
export class NotificationModule {}
notification/notification.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class NotificationService {
  notify(args: { message: string; }){
    // 通知処理
  }
}

リスナー作成

リスナーを作成します。

notification/listeners/comment.created.listener.ts
import { CommentCreatedEvent } from '../../comment/events/comment.created.event';

@Injectable()
export class CommentCreatedListener {
  constructor(
    private notificationService: NotificationService,
  ) {}
  
  @OnEvent(CommentCreatedEvent.type)
  notify(event: CommentCreatedEvent): void {
    this.notificationService.notify({ message: event.data.comment.content }).catch((err) => {
      logger.error(`${String(err)}, event: ${JSON.stringify(event)}`);
    });
  }
}
notification/listeners/comment.updated.listener.ts
import { CommentUpdatedEvent } from '../../comment/events/comment.updated.event';

@Injectable()
export class CommentUpdatedListener {
  constructor(
    private notificationService: NotificationService,
  ) {}
  
  @OnEvent(CommentUpdatedEvent.type)
  notify(event: CommentUpdatedEvent): void {
    this.notificationService.notify({ message: event.data.comment.content }).catch((err) => {
      logger.error(`${String(err)}, event: ${JSON.stringify(event)}`);
    });
  }
}
notification/listeners/comment.deleted.listener.ts
import { CommentDeletedEvent } from '../../comment/events/comment.deleted.event';

@Injectable()
export class CommentDeletedListener {
  constructor(
    private notificationService: NotificationService,
  ) {}
  
  @OnEvent(CommentDeletedEvent.type)
  notify(event: CommentDeletedEvent): void {
    this.notificationService.notify({ message: 'コメントが削除されました!' }).catch((err) => {
      logger.error(`${String(err)}, event: ${JSON.stringify(event)}`);
    });
  }
}

リスナーをモジュールに登録します。

notification/notification.module.ts
import { Module } from '@nestjs/common';
import { CommentCreatedListener } from './listeners/comment.created.listener.ts';
import { CommentUpdatedListener } from './listeners/comment.updated.listener.ts';
import { CommentDeletedListener } from './listeners/comment.deleted.listener.ts';

import { NotificationService } from './notification.service';

@Module({
  imports: [],
  controllers: [],
  providers: [
    NotificationService,
    CommentCreatedListener,
    CommentUpdatedListener,
    CommentDeletedListener,
  ],
  exports: [NotificationService],
})
export class NotificationModule {}

これで、コメントが作成・更新・削除されたときに、通知が飛ぶように実装ができました!!

通知の設計に関してはこちらもどうぞ。
https://zenn.dev/dove/articles/f5569912778850

Discussion