✍️

Node.js / Express / TypeScript / たぶんDDDでスケルトンのAPIを作った

2023/06/04に公開1

はじめに

Node.jsとExpressの組み合わせで、APIを作った経験はあるのですが、各レイヤーの責務が統一されていなかったり、別でKotlinやJavaとSpringBootでDDDを意識した構成にて開発していることを受けて、タイトルの組み合わせで知見を整理しながら、スケルトンを作ってみました!!

前提

  • 共通エラーハンドリングの機構はこれからなので、実装には含めていません

環境

  • Node v18.14.0
  • Express v4.18.2
  • TypeScript v5.1.3
  • TypeORM v0.3.16
  • InversifyJS v6.0.1

概要

  • 日報管理システムを題材として、ユーザーが登録した日報を全て返却するAPIの実装をおこなう

論理ER設計及びバックエンドの設計方針

主要コードの説明は後述いたしますが、まずはテーブルとバックエンドの設計内容を図示します

論理DB設計

ER図

BE設計方針

BE

実装内容

ディレクトリ構成

  • src配下のディレクトリ構成は以下のとおりです
├── src
│   ├── application  # アプリケーション層
│   │   ├── DailyReport
│   │   │   ├── DailyReportDto.ts
│   │   │   ├── DailyReportService.ts
│   │   │   ├── IDailyReportService.ts
│   │   │   └── ReportAttachmentDto.ts
│   │   └── User
│   │       └── UserDto.ts
│   ├── config  # 設定関連
│   │   ├── inversify.config.ts
│   │   ├── typeorm.config.ts
│   │   └── types.ts
│   ├── domain  # ドメイン層
│   │   ├── DailyReport
│   │   │   ├── DailyReport.ts
│   │   │   ├── IDailyReportRepository.ts
│   │   │   └── ReportAttachment.ts
│   │   └── User
│   │       ├── Email.ts  # ValueObject
│   │       └── User.ts
│   ├── infrastructure  # インフラストラクチャー層
│   │   ├── entity  # エンティティー定義
│   │   │   ├── DailyReportEntity.ts
│   │   │   ├── ReportAttachmentEntity.ts
│   │   │   └── UsersEntity.ts
│   │   ├── repository  # リポジトリクラス
│   │   │   ├── DailyReport
│   │   │   │   ├── DailyReportOperations.ts
│   │   │   │   └── DailyReportRepository.ts
│   │   │   └── common
│   │   │       ├── BaseMySqlOperations.ts
│   │   │       └── DataSourceManager.ts
│   │   └── router
│   │       ├── dailyReportRouter.ts  # ルーター
│   │       └── index.ts
│   ├── interface  # インターフェース / プレゼンテーション層
│   │   └── controller  # コントローラークラス
│   │       └── DailyReport
│   │           ├── DailyReportController.ts
│   │           └── DailyReportResponse.ts
│   └── setup.ts  # Expressでミドルウェア/ルーティングの設定を実施
│   └── index.ts  # エントリーポイント

ルーター

APIエンドポイントのリソースごとにルーター定義をします

import express from "express";
import { StatusCodes } from "http-status-codes";
import { container } from "@/config/inversify.config";
import { TYPES } from "@/config/types";
import DailyReportController from "@/interface/controller/DailyReport/DailyReportController";

export const createDailyReportRouter = () => {
  const router = express.Router();
  const dailyReportController = container.get<DailyReportController>(
    TYPES.DailyReportController
  );

  router.get("/daily-reports", async (req, res) => {
    const result = await dailyReportController.getDailyReport();
    res.status(StatusCodes.OK).json(result);
  });

  return router;
};
  • container.get<DailyReportController>(TYPES.DailyReportController)の部分ですが、InversifyJSを使ってDIコンテナから使用したいコンテナを取得しています
  • InversifyJSですが、フロントエンド(React/Next)開発でAPI接続部との実装箇所で、以前に有識者の方から教わって、疎結合にできることなどからとても良い体験ができたので今回のAPI開発でも取り入れてみました
  • NestJSを使って、APIを開発した経験もあるのですが、NestJSはフレームワークでありDIの機構が予め組み込まれておりますが、Node/Expressには、自作でDI機構を作るかInversifyJSのようなDIライブラリを導入する必要があります

DIコンテナの定義

DIで使用する識別子の定義
https://github.com/inversify/InversifyJS#step-1-declare-your-interfaces-and-types

const TYPES = {
  IDailyReportService: Symbol.for("IDailyReportService"),
  IDailyReportRepository: Symbol.for("IDailyReportRepository"),
  DailyReportController: Symbol.for("DailyReportController"),
  DataSourceManager: Symbol.for("DataSourceManager"),
};

export { TYPES };

コンテナの定義
https://github.com/inversify/InversifyJS#step-3-create-and-configure-a-container

const container = new Container();

container
  .bind<IDailyReportService>(TYPES.IDailyReportService)
  .to(DailyReportService)
  .inSingletonScope();
container
  .bind<IDailyReportRepository>(TYPES.IDailyReportRepository)
  .to(DailyReportRepository)
  .inSingletonScope();
container
  .bind<DailyReportController>(TYPES.DailyReportController)
  .to(DailyReportController)
  .inSingletonScope();
container
  .bind<DataSourceManager>(TYPES.DataSourceManager)
  .to(DataSourceManager)
  .inSingletonScope();

export { container };

インターフェース/プレゼンテーション層

コントローラークラスです。
方針としては、呼び出し対象のサービスクラスのインターフェースをコンストラクタインジェクションでDIします。

@injectable()
class DailyReportController {
  private _dailyReportService: IDailyReportService;

  constructor(
    @inject(TYPES.IDailyReportService)
    dailyReportService: IDailyReportService
  ) {
    this._dailyReportService = dailyReportService;
  }

  async getDailyReport(): Promise<DailyReportResponse[]> {
    const dailyReportDtos = await this._dailyReportService.getDailyReport();
    return dailyReportDtos.map(
      (dailyReportDto) => new DailyReportResponse(dailyReportDto)
    );
  }
}

export default DailyReportController;

サービスクラスからDTOクラスが返却されますので、以下のレスポンスクラスに詰め替えます。

export class DailyReportResponse {
  constructor(dailyReportDto: DailyReportDto) {
    this.id = dailyReportDto.id;
    this.title = dailyReportDto.title;
    this.content = dailyReportDto.content;
    this.user = new User(
      dailyReportDto.user.id,
      dailyReportDto.user.userName,
      dailyReportDto.user.email
    );
    this.publishedAt = dailyReportDto.publishedAt;
    this.editedAt = dailyReportDto.editedAt;
    this.reportAttachments = dailyReportDto.reportAttachments.map(
      (reportAttachment) =>
        new ReportAttachment(
          reportAttachment.id,
          reportAttachment.reportId,
          reportAttachment.filePath
        )
    );
  }

  public readonly id: number;
  public readonly title: string;
  public readonly content: string;
  public readonly user: User;
  public readonly publishedAt: Date;
  public readonly editedAt: Date;
  public readonly reportAttachments: ReportAttachment[];
}

class User {
  constructor(
    public readonly id: number,
    public readonly userName: string,
    public readonly email: string
  ) {}
}

class ReportAttachment {
  constructor(
    public readonly id: number,
    public readonly reportId: number,
    public readonly filePath: string
  ) {}
}

APIレスポンスとして、定義されているプロパティ名や値にクレンジング、変換、加工するようにしています。

アプリケーション層

ビジネスロジックを責務とするサービスクラスです。
方針としては、呼び出し対象のリポジトリクラスのインターフェースをコンストラクタインジェクションでDIします。

export default interface IDailyReportService {
  getDailyReport(): Promise<DailyReportDto[]>;
}
@injectable()
class DailyReportService implements IDailyReportService {
  private _dailyReportRepository: IDailyReportRepository;

  constructor(
    @inject(TYPES.IDailyReportRepository)
    dailyReportRepository: IDailyReportRepository
  ) {
    this._dailyReportRepository = dailyReportRepository;
  }

  public async getDailyReport(): Promise<DailyReportDto[]> {
    const dailyReportEntities =
      await this._dailyReportRepository.fetchDailyReport();
    return dailyReportEntities.map(
      (dailyReportEntity) =>
        new DailyReportDto(new DailyReport(dailyReportEntity))
    );
  }
}

export default DailyReportService;

リポジトリクラスからエンティティクラスが返却されますので、エンティティ→ドメイン→DTOへ詰め替えをします。

ドメイン層

値オブジェクト(ValueObject)の活用

プリミティブな型での管理の場合、定義した変数に値は格納できますが、業務仕様としてコード体系などが定められている場合に予期せぬ箇所でデータ異常が発見されるケースがあります。
どこまで、VOで管理するかは、開発するアプリケーション仕様や規模などに応じて、プロジェクトごとに意思決定をすれば良いと思います。
本スケルトンでは、メールアドレスをVO化しました。

/**
 * メールアドレスの値オブジェクト
 */
export class Email {
  private value: string;

  constructor(value: string) {
    if (value.length === 0 || value.length > 256) {
      throw new Error(
        "メールアドレスは1文字以上256文字以内である必要があります"
      );
    }
    const emailRegex =
      /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/;
    if (!emailRegex.test(value)) {
      throw new Error("メールアドレスの形式が間違っています");
    }
    this.value = value;
  }

  public getValue(): string {
    return this.value;
  }
}

コンストラクタで業務仕様に対するバリデーションを実施して、パスした場合のみインスタンスを生成できるようにします。
外から値を取得する必要があるため、getValueメソッドも用意しています。
ちなみに、Kotlinのデータクラスでは、ゲッターがデフォルトで用意されるため、定義する必要がないので、TSでは作っている時に忘れておりました。

export class DailyReport {
  constructor(dailyReportEntity: DailyReportEntity) {
    this.id = dailyReportEntity.id;
    this.title = dailyReportEntity.title;
    this.content = dailyReportEntity.content;
    this.user = new User(dailyReportEntity.user);
    this.publishedAt = dailyReportEntity.createdAt;
    this.editedAt = dailyReportEntity.updatedAt;
    this.reportAttachments = dailyReportEntity.reportAttachments.map(
      (reportAttachmentEntity) => new ReportAttachment(reportAttachmentEntity)
    );
  }

  public id: number;
  public title: string;
  public content: string;
  public user: User;
  public publishedAt: Date;
  public editedAt: Date;
  public reportAttachments: ReportAttachment[];
}

エンティティクラスを受け取って、ドメインへ変換しています。

リポジトリクラスのインターフェース定義

export default interface IDailyReportRepository {
  fetchDailyReport(): Promise<DailyReportEntity[]>;
}

インフラストラクチャー層

リポジトリクラスです。
TypeORMのエンティティー定義ファイル、DBの接続周りを責務とするDataSourceManagerやORMを活用してデータベースにクエリを発行することを責務とするDailyReportOperationsの内容は省略しますが、責務を分けた上で、リポジトリクラスでもDIをおこなっています

@injectable()
class DailyReportRepository implements IDailyReportRepository {
  private _dataSourceManager: DataSourceManager;
  private _dailyReportOperations: DailyReportOperations;

  constructor(
    @inject(TYPES.DataSourceManager)
    dataSourceManager: DataSourceManager
  ) {
    this._dataSourceManager = dataSourceManager;
    this._dailyReportOperations = new DailyReportOperations();
  }

  public async fetchDailyReport(): Promise<DailyReportEntity[]> {
    try {
      const db = await this._dataSourceManager.initialize();
      const response = await db.transaction((txManager) =>
        this._dailyReportOperations.fetchDailyReportCreatedDesc(txManager)
      );
      return response;
    } catch (error) {
      console.error("dataSource initialize error:", error);
      throw error;
    } finally {
      await this._dataSourceManager.destroy();
    }
  }
}

export default DailyReportRepository;

なお、TypeORMのTipsになりますが、transaction((txManager)部分で、EntityManagerを作成しています。トランザクション制御は業務仕様によって異なりますが、ひとつのトランザクション内で成功(コミット)と失敗(ロールバック)を管理する場合には、起点箇所から別のメソッドを呼び出す場合には、txManagerをリレーしていかないと同じトランザクション内で処理されないので注意が必要です。

そのため、データベースに対して、基本的な操作をおこなう基底クラスを定義する際に、EntityManagerを受領して、リポジトリを取得するメソッドを生やしています

export abstract class BaseMySqlOperations<T> {
  protected entityTarget: EntityTarget<T>;

  constructor(entityTarget: EntityTarget<T>) {
    this.entityTarget = entityTarget;
  }

  /**
   * リポジトリを取得する
   * @note
   * - EntityManagerを引数で受け取ることで、呼び出し元のトランザクション内でリポジトリを取得できる
   *
   * @param entityManager
   * @returns
   */
  public async getRepository(
    entityManager: EntityManager
  ): Promise<Repository<T>> {
    return entityManager.getRepository(this.entityTarget);
  }
  
  ...
  
}

APIを実行してみる

誰がいつ作成・編集した日報の一覧と日報に添付ファイルがあれば、n個をレスポンスされるようになっています。

% curl -X GET -H "Content-Type: application/json" http://localhost:8080/api/v1/daily-reports | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  1331  100  1331    0     0   2042      0 --:--:-- --:--:-- --:--:--  2069
[
  {
    "id": 1,
    "title": "Daily Report 1",
    "content": "This is the content of Daily Report 1",
    "user": {
      "id": 1,
      "userName": "testuser",
      "email": "testuser@example.com"
    },
    "publishedAt": "2023-06-02T06:29:09.000Z",
    "editedAt": null,
    "reportAttachments": [
      {
        "id": 1,
        "reportId": 1,
        "filePath": "/path/to/attachment1"
      }
    ]
  },
  {
    "id": 2,
    "title": "Daily Report 2",
    "content": "This is the content of Daily Report 2",
    "user": {
      "id": 1,
      "userName": "testuser",
      "email": "testuser@example.com"
    },
    "publishedAt": "2023-06-02T06:29:09.000Z",
    "editedAt": null,
    "reportAttachments": []
  },
  {
    "id": 3,
    "title": "Daily Report 3",
    "content": "This is the content of Daily Report 3",
    "user": {
      "id": 1,
      "userName": "testuser",
      "email": "testuser@example.com"
    },
    "publishedAt": "2023-06-02T06:29:09.000Z",
    "editedAt": null,
    "reportAttachments": [
      {
        "id": 2,
        "reportId": 3,
        "filePath": "/path/to/attachment2"
      }
    ]
  },
  {
    "id": 4,
    "title": "Daily Report 4",
    "content": "This is the content of Daily Report 4",
    "user": {
      "id": 1,
      "userName": "testuser",
      "email": "testuser@example.com"
    },
    "publishedAt": "2023-06-02T06:29:09.000Z",
    "editedAt": null,
    "reportAttachments": []
  },
  {
    "id": 5,
    "title": "Daily Report 5",
    "content": "This is the content of Daily Report 5",
    "user": {
      "id": 1,
      "userName": "testuser",
      "email": "testuser@example.com"
    },
    "publishedAt": "2023-06-02T06:29:09.000Z",
    "editedAt": null,
    "reportAttachments": [
      {
        "id": 3,
        "reportId": 5,
        "filePath": "/path/to/attachment3"
      }
    ]
  }
]

おわりに

クリーンアーキテクチャ、オニオンアーキテクチャ、レイヤードアーキテクチャなど色々あるかと思いますが、プロジェクト内でどのような方針でどこまで取り入れるかなどを議論しながら、取り入れていけば良いと思っています。

私は有識者の方から教わったり、ご助言くださったり、関連書籍を読んだりして、まだまだですが、自分に取り入れることが一旦はできるようになったので、とても感謝です。

最初は詰め替えが面倒だなとか思ったりしたのですが、各層での責務があるので、そこが統一されなくなると、モノとしては動いているけど、保守性に欠けてしまうので、今は意識しながら設計、実装をするようにしています。

また、疎結合にすることで、テストもし易くなるのは体験としてはありますし、自身の経験としては、最初はデータベースをPostgreSQLだったのですが、MongoDBに変更するとなった時に、主にインフラストラクチャー層だけを作り替えれば良かったので、本記事の設計方針で実装して恩恵をもろに受けることが以前できたことがあります!!

以上です。
本記事が何かの一助になれば幸いです。

Discussion

セカンドフィドルセカンドフィドル

とても参考になります!
github等でリソースを公開していただくことはかのうでしょうか?
エントリーポイント(index.ts)がどうなっているのかわかると入り口から下に掘って行けるため、理解が深まります。