⚖️

社内ツールのリファクタリングで学んだレイヤー設計の意義

に公開

やったこと

Netlify Functions上でSlack APIを利用する機能のService層リファクタリングを担当しました。
既存の構成は UseCase層 → Service層(SlackService.ts) → Driver層(外部APIなどをラップする層) → Slack API という流れで、Service層がDriverをラップする形。

問題

  1. Driverの生成方法が統一されていなかった。constructorで生成するパターンと、関数内で遅延生成するパターンが混在していた。結果、チャンネルAにメッセージを送信するためにSlackServiceを生成すると、constructor内で関係のないチャンネルBやチャンネルCのDriverまで生成され、環境変数が未設定の環境など、一部環境でエラーを吐いていた。

  2. チャンネルごとに個別のDriverファイルが存在していた。各Driverの処理内容はほぼ同一(親となるslack.driver.tsの継承 + 各チャンネルの環境変数チェック)で、DRY原則に反した冗長な構造だった。

どう対処したか

最初のアプローチとして、役割の薄いService層を削除し、UseCaseからDriverを直接呼び出す構成を実装してPRを出しました。
Service層に存在するのはチャンネルごとの送信関数だけで、個別の処理もほとんどなかったため、レイヤーとしての存在意義を感じなかったのが大きな理由です。

しかし、後日PRを見返していたときにいくつか問題を発見しました。
Driverはチャンネルごとにファイルを作成する必要があり、DRY原則に反していて拡張性がない。
そして何より、Service層が存在しないことへの構造的な違和感を感じました。
違和感の正体は恐らく、Driverに書くようなロジックではないが、UseCaseに置くには具体的すぎるビジネスロジックを置く場所がない という感覚でした。
結果、その時点での規模ではService層が不要に見えても、将来的な拡張を考えると残しておくほうが合理的だと考えました。

最終的な設計としては、Service層にチャンネルKeyと環境変数のマッピング配列を持たせ、Keyを指定するとDriverを生成して返す静的なファクトリメソッド(static fromKey)を実装しました。
環境変数チェックも生成時に一元化することにしました。

// 一部コードを抽象化したもの

export class SlackService {
  constructor(private readonly driver: SlackDriver) {}

  static fromKey(channelKey: string): SlackService {
    const channelId = process.env[channelKey];
    if (!channelId) {
      throw new Error(`Config missing for: ${channelKey}`);
    }
    return new SlackService(new SlackDriver(channelId));
  }
}

この実装により各レイヤーの責務がより明確になり、当初の実装より拡張性・保守性を高めることが出来ました。

最終的な各層の責務:

  • Driver層: Slack APIのWrapper
  • Service層: 環境変数チェック、Driverの生成・返却、ビジネスロジックの拡張ポイント
  • UseCase層: Service経由で必要なチャンネルのインスタンスを取得し、業務フローを記述

学び

  • レイヤードアーキテクチャの各層にはちゃんと存在理由がある: 現時点で役割が薄く見えるレイヤーでも、責務の分離と将来の拡張性の担保として機能しうる。「今は不要だから消す」は長期的な視点で見ると悪手になってしまう可能性がある。
  • 「後の人が触ること」を前提に設計する: 自分だけが理解できる構造ではなく、"新しいチャンネルが増えたときにマッピング配列に1行追加するだけで済む"、という拡張のしやすさまで考慮して設計するべき。

補足

  • いくらレイヤー構造に存在理由があるとはいえ、オーバーエンジニアリングにならないように気をつけなければならないと思った。
  • 今回は一度PRを出したあとに気が付いたが、今後はPRを出す前に今回のような判断はできるようになりたい。

Discussion