serverside-ts
技術

サーバーサイドTypeScriptはクラスより関数で書くべき理由【実例あり】

by Leon

自分はバックエンドをTypeScriptで書くことが多いですが、最近はクラスをほとんど使わなくなりました。代わりに関数で書くようにしています。

TypeScriptはクラスも関数も書けますが、サーバーサイドで使う場合は関数の方が相性がいいと感じています。この記事ではその理由をコードを交えて説明します。

クラスベースのDIはなぜ辛いか

クラスで依存性注入(DI)を実現しようとすると、DIコンテナが必要になります。よく使われるのは tsyringe や inversify です。

@injectable()
class UserService {
  constructor(
    @inject("UserRepository") private userRepo: UserRepository,
    @inject("MailService") private mailService: MailService,
  ) {}

  async getUser(id: string) {
    return this.userRepo.findById(id);
  }
}

動くことは動きますが、いくつか問題があります。

まず、設定ミスが実行時エラーになります。デコレータの書き方を間違えても、TypeScriptのコンパイルは通ってしまいます。気づくのはサーバーを起動したときです。

次に、依存が増えるほどDIコンテナの設定ファイルが肥大化します。どこで何を注入しているか追いにくくなり、新しいメンバーが参加したときに把握するのが大変になります。

さらに、継承を使い始めると依存関係がさらに複雑になります。親クラスに何が注入されているか意識しないといけない場面が増えてきます。

高階関数で依存を解決する

関数を使えば、DIコンテナなしに依存を注入できます。パターンとしては、依存を引数に取る関数を返す「高階関数」を使います。

Repository層

type DB = {
  query: (sql: string, params?: unknown[]) => Promise<unknown>;
};

const createUserRepository = (db: DB) => ({
  findById: async (id: string) =>
    db.query(`SELECT * FROM users WHERE id = $1`, [id]),
  findAll: async () =>
    db.query(`SELECT * FROM users`),
});

type UserRepository = ReturnType<typeof createUserRepository>;

Service層

const createMailService = (config: MailConfig) => ({
  send: async (to: string, subject: string, body: string) => {
    // メール送信処理
  },
});

type MailService = ReturnType<typeof createMailService>;

UseCase層

const createGetUserUseCase = (deps: {
  userRepo: UserRepository;
  mailService: MailService;
}) => ({
  execute: async (id: string) => {
    const user = await deps.userRepo.findById(id);
    if (!user) throw new Error("User not found");
    return user;
  },
});

依存はすべて引数として渡します。TypeScriptの型チェックが通れば、実行時に「依存が見つからない」というエラーは起きません。IDEの補完も効くので、何が必要かが一目でわかります。

組み立て方

アプリの起動時にそれぞれのインスタンスを作って渡すだけです。

// index.ts
const db = createDB(process.env.DATABASE_URL);
const mailConfig = { host: process.env.MAIL_HOST };

const userRepo = createUserRepository(db);
const mailService = createMailService(mailConfig);

const getUserUseCase = createGetUserUseCase({ userRepo, mailService });

DIコンテナの設定ファイルは不要です。コードを上から読めば依存関係が全部わかります。

テストが書きやすくなる

関数ベースだとテストが非常にシンプルになります。モックはただのオブジェクトです。

const mockUserRepo: UserRepository = {
  findById: async (_id) => ({ id: "1", name: "Leon", email: "leon@example.com" }),
  findAll: async () => [],
};

const mockMailService: MailService = {
  send: async () => {},
};

const useCase = createGetUserUseCase({
  userRepo: mockUserRepo,
  mailService: mockMailService,
});

const user = await useCase.execute("1");
expect(user.name).toBe("Leon");

DIコンテナのセットアップが不要なので、テストファイルが短くなります。依存を差し替えるのも引数を変えるだけです。

まとめ

高階関数を使った依存注入のメリットをまとめます。

・外部ライブラリ不要
・型安全(コンパイルでミスを検出できる)
・依存関係がコードを読むだけで把握できる
・テストが書きやすい

クラスが悪いわけではありません。ただ、TypeScriptのサーバーサイドでは関数の方が言語機能だけでシンプルに書けます。特にチーム開発では、設定ファイルを読まなくても依存関係がわかる点が助かっています。

一度試してみると、クラスに戻りにくくなるかもしれません。