360
311

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

関心の分離を意識してサーバーを作ってみる(TypeScript + Express)

Last updated at Posted at 2019-05-08

はじめに

こんにちは、都内でフロントエンドエンジニアをやっている @sadnessOjisan です。最近、自分のできる範囲を広げたいと思い、バックエンドエンジニアに転身しました。フロントエンドエンジニアがバックエンドを書けたら最高ですよね?歌って踊れて楽器が弾けるアイドルって感じがします(知らんけど)。

そんなカジュアルな感じでバックエンドエンジニアに転身したのですが、どうも僕が立てるサーバーは汚く柔軟性もなく見通しも悪くなってしまいがちです。どうせなら綺麗なサーバー立てたいと思い、良いサーバーを作るための技術や方法論を知りたいと調べていくと、「関心の分離」という言葉を知りました。そこで GW に、レイヤードアーキテクチャやクリーンアーキテクチャという耳かじった言葉を調べ、なんとかサンプルアプリを作れたので、そこで得た学びをここに残したいと思います。この記事では、関心の分離ができていることを確かめるために、レイヤー内で実装を差し替えても問題なく動くことを確認していきます。

また、挑戦しようと思ったきっかけは、Clean Architecture を Node.js+Typescript で実装してみる
という記事です。
このサンプルアプリでもこの記事で作られたものを参考にしています。スペースマーケットの nishio さん、ありがとうございました。

今回作ったサンプルのソースコードはこちらです。
https://github.com/sadnessOjisan/ts-clean

作るもの

Twitter っぽい API サーバーを作ります。User と Post に対して CRUD ができ、 Post は User にが紐づいているものです。

設計は、クリーンアーキテクチャをかなり意識したものにしたがいます。ソフトウェアを方針(ソフトウェアがどのように問題を解決するかを記したもの)と詳細(IO デバイス・データベース・ウェブシステムといった方針と外界を繋ぐもの)という捉え方をしたとき、一般的に詳細は方針に従った方が良いとされています。そのような設計をすることで、方針に従うものであれば詳細は自由に取り替えることができるからです。そのためどの DB, IO デバイスを使うか決まっていない場合でも開発を進めることもでき、柔軟性が手に入ります。

クリーンアーキテクチャでは、ソフトウェアの責務をレイヤーでわけ、詳細側の外部レイヤーは内側に依存するというルールを徹底することで、外側にあるものを取り外し可能にします。そこで、次の有名な図がよく参考にされます。
この図では依存関係は外側から内側にのみ向いています。

クリーンアーキテクチャ

このルールを徹底し、関心の分離を達成することで、

  • フレームワーク独立
  • テスト可能
  • UI 独立
  • データベース独立
  • 外部機能独立

といった恩恵を得ることができます。

そこで、クリーンアーキテクチャを意識した設計を取り入れることで、これらの恩恵を享受できることをこれから確かめていきましょう。

クリーンアーキテクチャ 自体の説明は、詳しくは Clean Architecture 達人に学ぶソフトウェアの構造と設計などを参照してください。

登場人物とフォルダ整理

いまから作るアプリ (https://github.com/sadnessOjisan/ts-clean)では、次の登場人物が登場します。
これらを次の章で実装します。

  • Domain
    ドメインは、アプリケーションにかかわらず、存在するものです。Post と User がここでいうドメインです。ここでは最重要ビジネスデータと最重要ビジネスルール(のちに説明します)というものを閉じ込めたファイルを作ります。

  • Application/usecase
    Application Business Rules(そのシステムにおけるルール・そのシステムが何をするかを表現したもの)を格納する場所です。たとえば、ユーザーが社会人だったらこのコンテンツを返す、学生だったらこのコンテンツを返すみたいな処理を書いていきます。

  • Infrastructure
    フレームワークや DB といったシステムの外側にあるものを扱うコードを格納する場所です。

    • DataBaseDriver
      システム外部の DB を使う場合、その DB の操作や設定を担当するコードがここに格納されます。
    • **Router
      システム外部からの呼び出しに応じて、controller を呼び出します。今回は Express というフレームワーク(システム外部にあるもの)を利用して実装するので、Infrastructure 扱いです。
  • Interface
    入力、永続化、表示といった、ユースケースと外部世界のやり取りを担当するものが当てはまります。

    • controller
      受け取ったリクエストを元にユースケースを呼び出し、その結果をレスポンスとして返す役割を持っています。これは url によって呼び出されるコントローラが変わります。

    • database
      DB とのやりとりを担当するものです。
      データベースを取り替えられることを示すために、サンプルアプリには memory 用 と mysql 用 の実装を用意しました。また、repository ディレクトリを作りそこにインターフェースや DTO を格納しました。正しくは application に入れべきだとは思うのですが、技術的な近さを考慮して database ディレクトリに入れました。ただしきちんとフォルダは分けているので、application に戻すことも容易です。

    • request
      API が受け取ったリクエストの整形やバリデーションを行う役割です。

    • serializer
      レスポンスの中身を作る役割です。

登場人物の依存関係

これらの登場人物の依存関係を、レイヤーを意識してつなげていきます。ここでいう依存とはソースコードの呼び出し関係のことを指します。依存関係があるかないかを、対象とするものを知っているかどうかで判断します。
例えばクラス A がクラス B のメソッドを呼び出していると、A は B について知っていることとなり、A は B に依存していると言えます。

クリーンアーキテクチャでは、円の外側が内側に依存するように作るため、内側にあるものから外側は呼んではいけません。内側にあるコードは外側にあるコードを知っていてはいけなく、内側から外側のコードを呼び出すことができないという制約を設けます。なので、依存しているかどうかの判断は、内側のコードにが外側にあるものの名前が出てくるかで判断すると良いでしょう。

それではさっそく作っていきましょう。

まずは トップレベル・最も内側にある Domain から作っていきましょう。

各レイヤーをどのように実装したか

Domain を作る

これは、最重要ビジネスデータと最重要ビジネスルール とも呼ばれているものです。このシステムの一番最上位にくるものであり、ソフトウェアの一番の骨格になるものです。どうして一番の骨格であるかと言えるかというと、Domain はこのシステムがなくても存在するものとして扱うべきだからです。システム開発はこの最上位オブジェクトに依存する形で、いろんな機能を肉付けていきます。

User と Post をそれぞれ作りましょう。まずは User を作ります。User はシステムの有無に関わらず、名前や年齢を持っています。それを最重要ビジネスデータとしてソースコードで表現しましょう。そのために User クラス を作ります。

class User {
  private _id: number;
  private _name: string;
  private _age: number;

  get name(): string {
    return this._name;
  }

  set name(name: string) {
    this._name = name;
  }

  //   中略

  constructor(id: number = null, name: string = null, age: number = null) {
    this._name = name;
    this._age = age;
    this._id = id;
  }
}

あと、このドメインが持つ制約も最重要ビジネスルール もこのファイルに書きましょう。ここでいう制約とはそのドメインが必ず満たすべきルールのことです。ここでは必ず名前は 1 文字以上という制約を入れます。

const UserBusinessRule = {
  isNameLengthValid(name: string): boolean {
    return name.length > 0;
  }
};

Clean Architecture 達人に学ぶソフトウェアの構造と設計によると、最重要ビジネスルール はクラスに閉じ込めなくても、関数だけを同じファイルに置いておけば良いです。

同じように Post も作りましょう

class Post {
  private _id: number;
  private _content: string;
  private _userId: number;

  get id(): number {
    return this._id;
  }

  set id(id: number) {
    this._id = id;
  }

  //   中略

  constructor(
    id: number = null,
    content: string = null,
    userId: number = null
  ) {
    this._id = id;
    this._content = content;
    this._userId = userId;
  }
}

const PostBusinessRule = {
  isPostLengthValid(postText: string): boolean {
    return postText.length > 0 && postText.length < 256;
  }
};

export { Post, PostBusinessRule };

UseCase を作る

今から作るシステムに期待されている処理を書いていくレイヤーです。そのシステムがどう使われるかを書いていくのでユースケースと呼ばれます。

今回は、ユーザー情報を検索する・登録する・更新する・削除するといったユースケースと、投稿情報を検索する・登録する・削除するというユースケースを実装します。たとえばユーザーを検索するユースケースは次のように定義されます。

class FindUserUseCase {
  private userRepository: IUserRepository;

  public constructor(userRepository: IUserRepository) {
    this.userRepository = userRepository;
  }

  public getUser(id: number): Promise<User> {
    return this.userRepository.find(id);
  }

  public getAllUsers(): Promise<User[]> {
    return this.userRepository.findAll();
  }
}

ユーザーを作成するユースケースはこのように定義されます。

class CreateUserUseCase {
  private userRepository: IUserRepository;

  public constructor(userRepository: IUserRepository) {
    this.userRepository = userRepository;
  }

  public createUser(user: User): Promise<User> {
    const userDTO = toCreateUserDTO(user);
    return this.userRepository.create(userDTO);
  }
}

コードにでてきた repository はなんでしょうか。これはデータベースとのやり取りを抽象化してくれているものです。UseCase クラスのコンストラクターに repository が渡され、それを usecase は使っています。

usecase の中で Repository クラスから repository を作らず、外から渡すようにしたことは理由があります。
先ほどの円を見てみましょう。

クリーンアーキテクチャ

内側は外側を知っていてはいけないのです。ここでは UseCase は内側であり、外側に repository があります。そのため内側の usecase から外側の repository を直接言及してはいけないのです。そのため外側で usecase をインスタンス化してもらい、そのとき一緒に repository も渡してもらう必要があります。

このパターンを Dependency Injection(DI) と言います。DI を使うことで、内側から外側を意識しなくてもいいようにできます。

Controller を作る

コントローラーは、受け取ったリクエストを元にユースケースを呼び出し、その結果をレスポンスとして作り出す役割を持っています。

async findUser(req: TFindUserRequest) {
    try {
      const reqBody = new FindUserRequest(req.body);
      const useCase = new FindUser(this.userRepository);
      let result = await useCase.getUser(reqBody.id);
      return this.userSerializer.user(result);
    } catch (error) {
      return this.userSerializer.error(error);
    }
  }

実は、ここで私は禁忌を犯しています。
なんと TFindUserRequest は Express の型定義ファイルを継承しています。

import { Request } from "express";

interface ITypedRequest<T> extends Request {
  params: T;
}

interface Params {
  id: string;
}

export type TFindUserRequest = ITypedRequest<Params>;

interface レイヤーで、infrastructure にあるべきフレームワーク (Express) へと言及しています。
原理的にはアウトなんでしょうが、型くらいだったら何かあった時も修正が容易そうだしいいかと思ってこうしています。
Express の request オブジェクトのうち、body や params 以外に何が必要になるかはシステムをもっと作り進めないと分からないので、なるべく express のオブジェクトをバラさずに引き回したかったからです。

この禁忌をおかしたくなければ、ルーターでreq.bodyを取り出し、request の値のみ コントローラー に渡して呼び出せば良いでしょう。
そうすれば コントローラー は Express を知ることなく実装できます。

Request を作る

先ほどのコントローラーに、

const reqBody = new FindUserRequest(req.body);

というコードがあります。これは request オブジェクトを作っています。リクエストの取り扱う際、そのまま受け取った値で取り扱っても良いのに、わざわざ Request クラスを作り、そのインスタンスとして request オブジェクトを作りました。その理由は、Request 情報を解釈する責務を持つ場所を作り、そこで request の妥当性を検証するロジックを書きたかったからです。Request クラスはバリデーションもします。そのコードはこのようになります。

export class FindUserRequest {
  private _id: number;

  public get id(): number {
    return this._id;
  }

  public constructor(params: Params) {
    const validId = this.validRequest(params);
    this._id = validId;
  }

  private validRequest(params: Params): number {
    const id = params.id;
    const numberId = Number(id);
    if (typeof numberId !== "number") {
      throw new Error(
        JSON.stringify({
          code: StatusCode.invalid,
          message: "不正なrequest idです"
        })
      );
    }
    return numberId;
  }
}

ここでは不正な request id (たとえば文字列の'a')を受け取ると、例外を投げるようにして弾いています。

Response(Serializer) を作る

コントローラーが受け取る値を処理する機能を作ったのであれば、コントローラーが返す値を作ってくれる機能も欲しくなります。そのような Response の作成を担当する場所として Serializer を作りました。これはドメインオブジェクトや DTO を受け取り、それを JSON に変換してくれるものです。

export class UserSerializer extends ApplicationSerializer {
  public user(data: User): TResponse<UserResponse> {
    if (!data) {
      return {
        code: StatusCode.exception,
        message: "data is null",
        responsedAt: moment().format()
      };
    }
    return {
      code: StatusCode.success,
      data: {
        id: data.id,
        name: data.name,
        age: data.age
      },
      responsedAt: moment().format()
    };
  }

  public users(data: User[]): TResponse<UserResponse[]> {
    if (!data) {
      return {
        code: StatusCode.exception,
        message: "data is null",
        responsedAt: moment().format()
      };
    }
    return {
      code: StatusCode.success,
      data: data.map(
        (d): UserResponse => {
          return {
            id: d.id,
            name: d.name,
            age: d.age
          };
        }
      ),
      responsedAt: moment().format()
    };
  }

  public delete(): TResponse<Record<string, null>> {
    return {
      code: StatusCode.success,
      data: {},
      responsedAt: moment().format()
    };
  }
}

ここではただデータを JSON で返すだけでなく、ステータスコードやレスポンスの時間など付加情報をつけたものを独自 Response 型として返すようにしています。その方がフロントエンド的には嬉しいので、そのような機能をつけてみました。

このクラスはよくみると、ApplicationSerializer というクラスを継承していたり、メソッドはTResponse<T> という型のものを返しています。これは異常系のレスポンスを作るための工夫です。

ApplicationSerializer は継承した Serializer クラスに、例外オブジェクトを渡すと異常系のレスポンスを返す、error いう関数を生やします。

class ApplicationSerializer {
  public error(error: Error): TResponse<{}> {
    try {
      const err: TException = JSON.parse(error.message);
      return {
        code: err.code,
        errorName: error.name,
        message: err.message,
        responsedAt: moment().format()
      };
    } catch {
      return {
        code: StatusCode.exception,
        errorName: error.name,
        message: "err obj parse error",
        responsedAt: moment().format()
      };
    }
  }
}

そして serializer は正常系か異常系のどちらかを返すので、それを型で表現します。

type TResponse<T> =
  | {
      code: typeof StatusCode.success;
      data: T;
      responsedAt: string;
    }
  | {
      code:
        | typeof StatusCode.invalid
        | typeof StatusCode.exception
        | typeof StatusCode.undefined;
      errorName?: string;
      message: string;
      responsedAt: string;
    };

アプリが取りうる StatusCode は定数ファイルとして管理しています。

const SUCCESS_CODE: "0000" = "0000";
const INVALID_CODE: "4000" = "4000";
const EXCEPTION_CODE: "5000" = "5000";
const UNDEFINED_CODE: "9000" = "9000";

const StatusCode = {
  success: SUCCESS_CODE,
  invalid: INVALID_CODE,
  exception: EXCEPTION_CODE,
  undefined: UNDEFINED_CODE
};

例外に応じて、レスポンスが成形されます。

コントローラーが例外オブジェクトを受け取ると catch 節に入り、そこで error 用の serializer が呼ぶようにしているので、不都合が起きた時に該当する例外を投げるだけで、自動的に error respose がクライアントへ返るようになりました。

database を作る

DB とやりとりするレイヤーです。いまは PC のメモリを DB としてやりとりすることを想定します。(あとでここを MySQL に差し替えます)

とはいえ、SQL に将来的に保存することも考えているので、どんな DB でも差し替えられるように、インターフェースだけ作ってしまいましょう。

これは UseCase が参照するため、application フォルダに置いても問題ないです。(私は database フォルダに入れています、ユースケースから database フォルダに言及したくない場合は application フォルダに避難させましょう。)

abstract class IUserRepository {
  abstract async findAll(): Promise<Array<User>>;
  abstract async find(id: number): Promise<User>;
  abstract async create(user: TCreateUserDTO): Promise<User>;
  abstract async update(updateUserDTO: TUpdateUserDTO): Promise<User>;
  abstract async delete(id: number): Promise<null>;
}

Repository インターフェイスに対して各 Repository クラス(memory 対応のものや MySQL 対応のものや Mongo 対応のものなど)が具体的なコードを実装し、UseCase はインターフェイスをコンポジションすることで、外側のコードのことを知らなくても動作させることができます。これを DI と組み合わせることで、usecase の中で Reposiotory を実体化し、そのインスタンスのメソッドを呼ぶ必要がなくなり、usecase は渡される repository のメソッドを interface にしたがって呼び出せるので、渡された Repository の詳細を知らずに repository を利用できるようになります。

そのため、外側から MySQL 用の repository が渡されてもいいし、memory 用の repository が渡されてもいいし、mongodb 用の repository が渡されてもよくなります。(*ただしその repository が interface にしたがって実装されている必要はあります)こうすることで、usecase に対して repository はプラグインとして見えます。

さっそく、メモリを DB としてやりとりするための repository を インターフェースにしたがって実装をします。

サンプルコードの master ブランチは MySQL 準拠です。Memory 準拠のものはこちらを参考ください。

const posts: Post[] = [];
const users: User[] = [];

const DB = {
  posts,
  users
};

export { DB };
import DB from "./db";

class UserRepositoryImpl extends IUserRepository {
  constructor() {
    super();
    // 初期データを突っ込む
    const user1 = new User(1, "samle", 3);
    const user2 = new User(2, "samle2", 3);
    DB.users = [user1, user2];
  }

  private convertModel(r: any) {
    let user = new User();
    user.id = r.id;
    user.name = r.name;
    user.age = r.age;
    return user;
  }

  async find(id: number): Promise<User> | Promise<null> {
    let queryResults = DB.users.filter(user => user.id === id);
    if (queryResults.length === 0) {
      return null;
    }
    return this.convertModel(queryResults[0]);
  }

  async create(user: User): Promise<User> {
    const userIds = DB.users.map(user => user.id);
    const maxId: number = Math.max.apply(null, userIds);
    const newId = maxId + 1;
    const newUser = new User(newId, user.name, user.age);
    DB.users.push(newUser);
    return newUser;
  }

  // 中略

  async delete(id: number): Promise<null> {
    DB.users = DB.users.filter(user => {
      return user.id !== id;
    });
    return null;
  }
}

ここで作った repository は usecase に DI されて呼び出されます。

infrastructure を作る

ここは フレームワーク やツールの詳細を記述していきます。
例えば、DB との接続などを行います。(いまは MySQL とは接続しませんが、のちに接続するのでそのときに例を紹介します)
ここでは、例としてルーティングについての設定を書きます。
ルーティングは Express の機能を用いて行うので、このレイヤーに書く必要があります。
(※円の内部が Express のことを知っていてはいけないので)

const router = express.Router();

// user
router.get("/users", async (req: express.Request, res: express.Response) => {
  let results = await userController.findAllUser(req, res);
  res.send(results);
});

router.get(
  "/users/:id",
  async (req: TFindUserRequest, res: express.Response) => {
    let result = await userController.findUser(req, res);
    res.send(result);
  }
);

router.delete(
  "/users/:id",
  async (req: TDeleteUserRequest, res: express.Response) => {
    let result = await userController.deleteUser(req, res);
    res.send(result);
  }
);

// 中略

これらのサンプルコードはこちらです。
https://github.com/sadnessOjisan/ts-clean

ちょっとこだわったところ

DI やインターフェイスを使って、レイヤー間の依存関係を制御する

「database を作る」の節で解説した部分です。そちらをご覧ください mm

ちなみにレイヤー間の依存関係をコントロールすることは、依存関係逆転の原則とも呼ばれています。

これは、DI と Interface を駆使して実現できます。

20160526 依存関係逆転の原則というスライドがとても分かり易かったです。

例外を投げる

レイヤーを分けた設計において、あるレイヤーでの処理になんらかの問題があったときに、そのことを呼び出し元のレイヤーに教えてあげたいです。そうすることで、どこで起きたか・どういうエラーか・どういう解決策がありそうかを呼び出し元に教えることができます。そこで、例外を投げてみましょう。例外は投げられると呼び出し元に上がっていきます。ここではユーザーとの境界であるコントローラー層に try-catch 節を加え、処理のなかで起きた例外をそこで補足し、例外発生用の serializer を呼び出しています。こうすることでクライアント側にどういうエラーが起きたかを伝え、エラーのハンドリングができるようになります。

テストを書いてみる

レイヤーを分けたので、コードの責務は狭まり、役割もはっきりしているので、テストを書きやすくなっています。
ただ、他のレイヤーのコードを呼ぶみたいな処理がほとんどになってしまっています。
なのでここで確かめることは、モックした関数が正しく呼ばれているかになります。

例えば UseCase に対するテストは次のように書きます。

jest.mock("../../../interface/database/MySQL/PostRepositoryImpl");

const con = new MysqlConnection();
const repository = new PostRepository(con);

const usecase = new PostUsecase.FindPostUseCase(repository);
it("constructorが動作する", (): void => {
  const usecase = new PostUsecase.FindPostUseCase(repository);
  expect(usecase).toBeTruthy(); // Ensure constructor created the object:
});
it("postRepository.findが呼ばれる", (): void => {
  usecase.getPost(1);
  expect(repository.find).toHaveBeenCalled();
});
it("postRepository.findAllが呼ばれる", (): void => {
  usecase.getAllPosts();
  expect(repository.findAll).toHaveBeenCalled();
});

// 中略

DB を MySQL に差し替える

それでは DB をメモリから MySQL に差し替えてみましょう。infrastructure と repository レイヤーを触るだけで綺麗に動くことが見れると思います。

まず、DB との接続や呼び出しを担当する機能を infrastructure レイヤーに書いていきます。

export class MysqlConnection extends IDBConnection {
  private pool: Pool;

  public constructor() {
    super();
    dotenv.config();
    this.pool = mysql.createPool({
      connectionLimit: 5,
      host: process.env.DB_HOST_DEV,
      user: process.env.MYSQL_USER,
      password: process.env.MYSQL_PASSWORD,
      database: process.env.MYSQL_DATABASE,
      port: Number(process.env.DB_PORT),
      timezone: process.env.TIMEZONE,
      insecureAuth: false
    });
    this.pool.getConnection(
      (error, connection): void => {
        if (error) {
          console.error(error);
          if (error.code === "PROTOCOL_CONNECTION_LOST") {
            console.error("Database connection was closed.");
          }
          if (error.code === "ER_CON_COUNT_ERROR") {
            console.error("Database has too many connections.");
          }
          if (error.code === "ECONNREFUSED") {
            console.error("Database connection was refused.");
          }
        }

        if (connection) connection.release();
        return;
      }
    );
    // @ts-ignore TODO: あとで治す.
    this.pool.query = util.promisify(this.pool.query);

    this.pool.on(
      "connection",
      (): void => {
        console.log("mysql connection create");
      }
    );

    this.pool.on(
      "release",
      (connection): void => {
        console.log("Connection %d released", connection.threadId);
      }
    );
  }

  public execute(
    query: string,
    params: number | string | null = null
  ): mysql.Query {
    if (params !== null) {
      return this.pool.query(query, params);
    } else {
      return this.pool.query(query);
    }
  }
}

これは IDBConnection というクラスを継承しています。これはレポジトリ層にある interface です。

export abstract class IDBConnection {
  abstract execute(query: string, params?: number | string | null): mysql.Query;
}

これもレイヤーを越えるための工夫です。execute は sql を実行する関数です。
しかし repository は内側にあるので 直接 infrastructure 層の関数を呼べません。そこで Interface を経由して関数を呼び出しました。

次に IPostRepository の MySQL 版の実装を書いていきましょう

class PostRepositoryImpl extends IPostRepository {
  private connection: IDBConnection;
  public constructor(connection: IDBConnection) {
    super();
    this.connection = connection;
  }

  public async find(id: number): Promise<TPostAndUserDTO> {
    const postResult = await this.connection.execute(
      "SELECT Posts.id, Posts.content, Users.name AS userName FROM Posts INNER JOIN Users ON Posts.user_id = Users.id;",
      id
    );
    if (postResult.length === 0) {
      return null;
    }
    const postAndUserDTO = postResult[0];
    return postAndUserDTO;
  }

  public async findAll(): Promise<TPostAndUserDTO[]> {
    let queryResults = await this.connection.execute(
      "select Posts.id, Posts.content, Users.name AS userName from Posts INNER JOIN Users ON Posts.user_id = Users.id;"
    );
    return queryResults;
  }

  // 中略

  public async delete(id: number): Promise<null> {
    await this.connection.execute("delete from Posts where id = ?", id);
    return null;
  }
}

そして、ここで作った PostRepositoryImpl をユースケースから使えるようにしましょう。

class CreatePostUseCase {
  private postRepository: IPostRepository;

  public constructor(postRepository: IPostRepository) {
    this.postRepository = postRepository;
  }

  public createPost(post: Post): Promise<TPostAndUserDTO> {
    const postDTO = toCreatePostDTO(post);
    return this.postRepository.create(postDTO);
  }
}

そして usecase に対して、コントローラーから repository を DI しましょう!

class PostController {
  private postSerializer: PostSerializer;
  private postRepository: PostRepository;

  public constructor(dbConnection: IDBConnection) {
    this.postSerializer = new PostSerializer();
    this.postRepository = new PostRepository(dbConnection);
  }

  public async findPost(
    req: TFindUserRequest
  ): Promise<TResponse<PostResponse> | TResponse<{}>> {
    try {
      const id = Number(req.params.id);
      const useCase = new PostUseCase.FindPostUseCase(this.postRepository);
      let result = await useCase.getPost(id);
      return this.postSerializer.post(result);
    } catch (error) {
      return this.postSerializer.error(error);
    }
  }

  public async findAllPost(): Promise<
    TResponse<PostResponse[]> | TResponse<{}>
  > {
    const useCase = new PostUseCase.FindPostUseCase(this.postRepository);
    let result = await useCase.getAllPosts();
    return this.postSerializer.posts(result);
  }
  // 中略
}

これで動くはずです。さっそく MySQL に接続しましょう。Docker に設定を用意しています。

$ docker-compose build

$ docker-compose up -d

DB が起動したら、サーバーを準備して、

$ yarn install

$ yarn run build:local

$ yarn run start:local

リクエストが通るか確認してみましょう。(初期データは突っ込んであります)

# user作成
$ curl -X POST -H "Content-Type: application/json" -d '{"name":"aho", "age":100}' localhost:3000/api/users

# user全件取得
$ curl localhost:3000/api/users

# user1件取得
$ curl localhost:3000/api/users/1

# user1件修正
$ curl -X PATCH -H "Content-Type: application/json" -d '{"age":100}' localhost:3000/api/users/2

# user削除
$ curl -X DELETE localhost:3000/api/users/2

# post作成
$ curl -X POST -H "Content-Type: application/json" -d '{"content":"ahoooo", "userId":1}' localhost:3000/api/posts

# post全件取得
$ curl localhost:3000/api/posts

# post1件取得
$ curl localhost:3000/api/posts/1

# post削除
$ curl -X DELETE localhost:3000/api/posts/2

きっと curl がうまくいったかと思います。
ちなみに Repository に対しては DB との Connector も DI しているので、外から MongoDB 用の Connector やら postgreSQL の Connector にも差し替えることができます。

なんでもかんでもクラスにしてみる

TypeScript でクリーンアーキテクチャー を実践する記事を調べると、ドメインクラスや DTO クラスを作らずに型だけ定義しているものを見かけます。
正直、この方が、毎回インスタンス化する手間が省ける分、楽そうです。

ただ、クラスとして扱うと、コンストラクターにバリデーションを挟んで、そのクラスのインスタンスが正しい値であることの確証を高められることができます。
その方が堅牢かなと思って、クラスをなるべく作るようにしました。

しかし、クラスを作る方式にも問題はあり、仕様変更によってフィールドの種類に増減があると、インスタンス化するときの引数の順番の修正が必要になり、もしインスタンス化している場所が多範囲に散らばると修正は大変になってしまいます。
もしクラスを多用するのでしたらビルダーパターン(GoF ではなく Effective Java の方)を使うと良いと思います。
Java をやっているときは Lombok という便利なツールが用意されていて、アノテーション書くだけでビルダーを作れたのですが、残念ながら TS にはなさそうです。そのようなツールを自分で実装することは次の宿題です。

関心の分離を意識してみてどうだったか

嬉しかったポイントは次の通りです。

取り外しがしやすい

各レイヤで責務が決まっていること、呼び出されるレイヤが決まっていること、(TS を使っているからという理由もあるが)呼び出しの IF が型で守られていることがとてもうれしかったです。
何かを追加しないといけないときは、どういう型をもったものを作らないといけないかが分かったり、修正をしくってもコンパイル時に怒ってくれたりするので、安心してコードを書くことができました。

テストしやすい

レイヤーごとにテストをかけるのがとてもやりやすかったです。大きい範囲のテストを書いてしまうと、一部が変わった時にテストも全部書き直しみたいなことになることもありますが、もともとのテスト範囲を小さくできるので、変更があってもテストの書き直しがあまり発生せず、本当に関心があるところにだけテストをかけるので、とても健康的な気分でした。

最後に

(もっとうまいやり方あるよなぁ・・・)

360
311
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
360
311

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?