☸️

中規模以上のWeb開発に耐える3層アーキテクチャとDIP設計

2023/06/12に公開

この記事について

私がWebアプリケーションの設計をするときは、プレゼンテーション層、ドメイン層(ビジネスロジック層)、データアクセス層の3つを基本的なレイヤーとして構えることが多いです。
そしてリポジトリパターンを使用して依存の向きを逆転させ、ドメイン層をアプリケーションの中心に構えます。

ドメイン部分を中心に据え、変更の多いレイヤーを外側に持っていく考え方は、オニオンアーキテクチャやクリーンアーキテクチャでも採用されています。

ですが、これらのアーキテクチャを忠実に再現せずとも、最低限DIPにより処理の中心をドメイン層に据え、ビジネスロジックを疎結合で高凝集なクラス設計にすることで、中規模以上の開発にも十分耐えることができると思っています。

Source

今回使用したコードはこちらに置いています。

https://github.com/ishiyama0530/so-so-app

workspace

npmのworkspaceの機能を使用し、上述した設計を作っていきます。

https://daveiscoding.com/nodejs-typescript-monorepo-via-npm-workspaces

言語に依存した内容になってしまうので、以降workspaceについては触れません。
読み進める上でこちらを理解しておく必要もないです。

抑えておく単語

3層アーキテクチャ

プレゼンテーション層、ビジネスロジック層、データアクセス層と、それぞれの関心毎でレイヤーを分けるWeb開発の定番設計です。
よく聞くMVCやMVVMなどはプレゼンテーション層の話です。

Dependency Injection(DI)

モジュール間の参照はインターフェースを通して行い、そのインターフェースを通して呼ばれる具体的な処理は、外部から指定(注入)する依存性管理の手法です。

Dependency Inversion Principle(DIP)

SOLID原則の1つで、和訳では「依存性逆転の原則」と言われています。
上位モジュールをインターフェースに依存させ、DIにより下位モジュールの切り替えを行います。
これにより、モジュール間の依存関係が最小化され、コードの再利用性とメンテナンス性が向上します。

リポジトリパターン

DIPを使った設計パターンです。
データ操作に関る処理を抽象化し、永続化に関する処理をビジネスロジックから切り離します。

ディレクトリ構成

  root
  ├─ apps
  │   ├─ cli(プレゼンテーション層)
  │   └─ api(プレゼンテーション層)
  │
  └─ packages
  │   ├─ domain(ドメイン層)
  │   ├─ infra(データアクセス層)
  │   └─ shared(横断的関心事)
  │ 
  └─ package.json など (詳細はソースコードを確認してください。)

ドメイン層

ドメイン層にはユースケースを取り扱う「applications」ディレクトリとアプリケーションの中核の定義である「domains」ディレクトリを作成しています。
ユースケース層を別に作るのも良いですが、1つパッケージを増やすのはそれなりにパワーがいります。
今回は最初から「頑張りすぎない」ということで、ドメイン層に同梱しています。
サービスがスケールしてから必要に応じて分離できるように実装しておくと良いかもしれません。

domain/applications

高凝集なクラス設計にしたいので1ユースケース1クラスで作成します。なるべく具体的なクラス名をつけることで、単一責任を維持します。

依存関係はDIにより解決することを前提に作っているので、コンストラクタを確認することで依存関係が明確になります。

パブリックなメソッドも1つだけなので、コンストラクタで受け取ったインスタンスは、全てこのパブリックメソッドで使用されます。
(この高凝集設計が改修時にすごく助かります)

DIコンテナを使用すると、各クラスでの依存性管理とインスタンス化が不要になり、コードの冗長性が減少します。

https://github.com/ishiyama0530/so-so-app/blob/main/packages/domain/src/applications/users/UserCreateInteractor.ts

domain/domains

今回はエンティティとリポジトリのみ作成しています。バリューオブジェクトなど後から作ると変更が手間であるものに関しては、最初から用意しておくと良いかもしれません。

脱線しますが、TypeScriptの場合、自分は楽をしたいのでZodを使用してしまいます。
(なるべくライブラリに依存しないのが良いとは思いますが…)

https://github.com/ishiyama0530/so-so-app/blob/main/packages/domain/src/domains/users/UserEntity.ts

データアクセス層

ドメイン層で定義しているリポジトリの実装を行います。
今回はモックにしていますが、これがMySQLやPostgres、またはORマッパーを使用したとしても、永続化に関する都合と関心毎はこのレイヤーで完結させます。

これにより、ドメイン層からみたデータアクセス層はプラガブル(交換可能)なパーツとして扱うことができます。

また、テスト時はDIでモックに差し替えることで、副作用のないテストが可能になります。
本番のコードにテスト用のコードが流入しないというメリットもあります。

https://github.com/ishiyama0530/so-so-app/blob/main/packages/infra/src/Repositories/UserRepositoryMock.ts

ただ、トランザクション管理に関しては少し工夫が必要です。
トランザクションのスコープはユースケース単位になることが多いですが、ユースケースにはデータアクセス固有の情報は持たせたくありません。

※ C#のTransactionScopeやjava Spring の@Transactionalのようにデータベースに依存しないトランザクション管理の方法がある言語はそれを使用すると良いでしょう。

プレゼンテーション層

アプリケーションの中核(ドメイン層)が独立したパッケージになっているので、プレゼンテーション層はスマホアプリでも、Webサイトでもなんでも構いません。プレゼンテーション層もプラガブルです。

今回は簡単なCLIとAPIを用意しました。ポイントとしては、ここでDIコンテナの依存性を管理しています。

エンドユーザーに近いプレゼンテーション層は、市場のニーズに影響を受けやすいです。ドメインロジックと切り離しておくことで、良い意味で「捨てやすい」プログラムになります。

apps/cli

https://github.com/ishiyama0530/so-so-app/blob/main/apps/cli/src/main.ts

apps/api

https://github.com/ishiyama0530/so-so-app/blob/main/apps/api/src/main.ts

このような感じで依存関係を注入しています。

https://github.com/ishiyama0530/so-so-app/blob/main/apps/api/src/inversify.config.ts

共通処理

横断的な関心毎を処理する場として、全てのレイヤーから参照されることを前提としたSharedパッケージを定義しています。

プレゼンテーション層でWebの技術が使われることが多く、また表現力も豊なのでアプリケーション内のエラーは、以下のHttpExceptionを使用することにします。

プレゼンテーション層では包括的なエラーハンドリングをすることが多いと思うので、そこでステータスコードをみてレスポンスを制御することで一貫性を持ってエラーを対処することができます。

https://github.com/ishiyama0530/so-so-app/blob/main/packages/shared/src/errors/HttpError.ts

https://github.com/ishiyama0530/so-so-app/blob/main/apps/api/src/middlewares/GlobalErrorHandler.ts

サービス分割

プロジェクトがスケールし、参画者が増え、取り扱う要件が増加し、ソースコードが巨大なモノリスになってしまうこともあるでしょう。

そのときは無理やり既存の設計に当て込むのではなく、サービス分割を考えてみると良いかもしれません。
幸い高凝集で疎結合なプログラムは、処理を切り離すことが容易です。

まとめ

以上が私が基本にしている設計となります。

初めにも記述しましたが、依存の方向が内側で高凝集で疎結合な設計にすると、プロジェクトがスケールしていってもそこまで保守性は低下しません。

とはいっても、世の中にはたくさんの設計があります。「こうあるべき」というものはなく、アプリケーションを通して設計者の意図を尊重することが大切だと思っています。
レガシーコードでも一貫性のあるコードの保守性はそこまで低くないです。

※ 最近、世間のレガシーコードへの風当たりが強い気がします…(心に留めておくくらいが◎です。)

また、ユーザーやステークスホルダーの要望に素早く対応できる弾力性は必須です。ここに関しては事業の規模や方針によって変わってくるところでしょう。

今後も保守性と事業のスケール、両方を加味してそれにあった設計を考えていこうと思います。

最後まで見ていただきありがとうございました!

Discussion