新規開発でClean Architectureやってみた 〜 FlutterとNuxt.jsでがんばる軽量DDD 〜
はじめに
先日アーキテクチャ選定から取り組んだプロジェクトにおいて、Clean Architectureをベースとしたアーキテクチャを選定・導入しました。
この経験を消化して自分の糧にするため、そして少しでも誰かのお役に立てるようにこの記事にまとめることにしました。
主な対象読者は、過去(そして現在)の私のようにClean ArchitectureやDDDの経験が浅めの方になります。
DDDやClean Architectureに親しまれているつよつよアーキテクトの方は生暖かい目でご笑覧いただき、もしよければコメントでアドバイスをくださると嬉しいです。
本エントリにおけるClean Architectureの定義
現在Clean Architectureには多様な理解があるように思います。そこで、まず本エントリにおけるClean Architectureの定義から書いていきます。
本エントリではClean Architectureを依存性逆転の原則(DIP) と ビジネスロジックの保護を示したリファレンスアーキテクチャであると定義します。
これを具体化したのがコアとなるドメイン層に対して各層が外側から順に依存する例の同心円の構造です。
下位(外側)となるUIやライブラリは変更や差し替えに柔軟に対応できる一方、上位(内側)のドメイン層やビジネスロジックは外部の変更に影響されず安定した状態に保つことができます。
リファレンスアーキテクチャとは?
リファレンスアーキテクチャという用語は以下の定義で使用しています。
リファレンスアーキテクチャとは、特定の原理・原則を指針として典型的な構成を示したアーキテクチャである
Clean Architectureの文脈で言えば、よく引用されるあの図はあくまでルールを具体化した場合の一例であり、層の分割方法や命名はプロジェクトごとに柔軟に適用できるものであるというニュアンスになります。
Clean Architectureを採用した場合の一般的な層構造は下記のようになります。
層 | 概要 |
---|---|
Entitiy | システムの最も核心的なビジネスルールを表現する層で、ドメインモデルを記述します。Usecaseやフレームワークなどの他の全ての要素から独立します。 |
Usecase | アプリケーションの具体的なビジネスロジックを扱う層です。ユーザーの操作や外部イベントに対して、どのような処理を行うかを定義します。 |
Interface Adapters | 内側(ドメイン層)と外側(データベースや外部からのリクエストなど)の間でデータ形式を変換します。内側の実装詳細が外部に漏れないようにする役割を持ちます。 |
Frameworks and Drivers | データベースやwebフレームワーク、外部APIなどの詳細な技術を扱う層です。他の層がこれらの技術に直接依存しないよう抽象化し、変更の影響を最小限に抑える役割を持ちます。 |
これはThe Clean Architectureで示された同心円の図を基にしたものです。
小規模プロジェクトや、フレームワーク自体が変換機能を提供している場合にはInterface Adaptersを省略し、3層構成にすることもできます。(実際に今回の事例ではFlutter、Nuxtともに3層構造でディレクトリを切っています。)
選定理由
続いて、今回のプロジェクトでなぜClean Architectureが適切であると考えたのかを、当時の状況と共にお話しします。
重視したこと
まずアーキテクチャの検討にあたって観点を3つ挙げました。
柔軟性
具体的な要件はもちろん、プロダクトが解決したい課題自体がまだ明確でなく、開発が進み機能が公開される度に変化することを期待される状況だったため柔軟性は最重要だと考えました。
拡張性
PMFを目指してMVPを作り始める段階でしたが、ロードマップを見る限り正式運用前にリプレイスする期間はなかったため、初期段階から拡張性を考慮した構成にする必要がありました。
キャッチアップの容易さ
社内事情でチームメンバーが流動的に増減する可能性があり、新規メンバーでもスムーズにキャッチアップできる設計が必要でした。
Clean Architectureの採用
ということで、表題で既に明かしていますが、上記を踏まえて今回の事例ではClean Architectureを採用しています。
メジャーなアーキテクチャであるため実装例が豊富であり、以下の要件をすべて満たすと判断したためです。
- 柔軟性: フレームワークや詳細部分の変更が、コアとなるビジネスロジックに影響しない。
- 拡張性: 層ごとに役割が明確なため、新機能を安全に追加可能。
- キャッチアップの容易さ: 層構造によりコードの意図が明確で、新規メンバーでもプロジェクト全体を把握しやすい。
...本当のところ
選定理由をそれっぽく書きましたが実際は勢いで決めた感もあります。
新規事業にエンジニア2人でアサインされて先行き不透明な状況の中、少しでも自分たちの興味の持てるものを、という思いで選びました。
社内のフロントエンドの負債解消のための技術検証としてFlutterに挑戦したいという打算的な理由も含めてFlutterを採用し、色々なリポジトリや記事からアーキテクチャを検討する中でClean Architectureを適用できるものを使いたくなったのです。
また、社内の基幹プロダクトはマイクロサービスでDDDライクな設計になっていたこともあり、メンバーの増減があった場合の学習コストも比較的少なく、リスクを許容できる環境であったことも選択の後押しになりました。
実装
続いて具体的な実装についてです。ディレクトリ構造、狙い、反省点を述べていきます。
ピボットを契機にフレームワークを変更しておりFlutterとNuxt.jsの2パターンがありますので、順にお話しします。
Flutter
ディレクトリ構造
Flutterで採用したデザインパターンはDIPを適用したレイヤードアーキテクチャの構造です。
Flutter大学様のこちらのBookを参考にさせていただきました。ディレクトリ構造を以下に引用させていただきます。
├── application アプリケーション層
│ ├── state アプリ全体共通状態
│ │ └── 関心事_provider.dart
│ └── usecase ユースケース
│ ├── <関心事>
│ │ ├── state 関心事の状態
│ │ │ └── 関心事_状態.dart
│ │ └── 関心事_usecase.dart
│ └── run_usecase_mixin.dart ユースケース処理共通化のための mixin
├── domain ドメイン層
│ ├── <関心事>
│ │ ├── entity 関心事の entity
│ │ │ ├── 関心事_entity.dart
│ │ │ └── 関心事_entity.freezed.dart
│ │ └── 関心事_repository_interface.dart 関心事のインターフェース
│ └── service 関心事以外のサービス
│ └── storage_service.dart 例えば、ストレージ関連を扱うサービス
├── infrastructure インフラストラクチャー層
│ └── <関心事>
│ ├── dto 関心事の dto
│ │ ├── 関心事_dto.dart
│ │ ├── 関心事_dto_freezed.dart
│ │ └── 関心事_dto_g.dart
│ └── repository 関心事のモックリポジトリ 及び ドメイン層のリポジトリインターフェースを実装したリポジトリ
│ ├── mock_関心事_repository_impl.dart
│ └── 関心事_repository.dart
└── presentation プレゼンテーション層
├── app.dart
├── component アプリデザイン全般に関する共通部品
│ ├── component_1.dart
│ ├── component_2.dart
│ └── component_3.dart
├── presentation_mixin.dart プレゼンテーション層処理共通化のための mixin
├── page 関心事に分かれたページ
│ └── <関心事>
│ ├── component 関心事で使用する部品
│ │ ├── component_1.dart
│ │ ├── component_2.dart
│ │ └── component_3.dart
│ ├── page_1.dart
│ └── page_2.dart
├── theme.dart
└── view_utils.dart 画面に関する Utility クラス
狙い
参考にさせていただいたBookではFeature-firstとLayer-firstの2つが選択肢として挙げられていましたが、以下の理由でLayer-firstを選択しています。
新規プロダクトであるためドメインの数が少ない
完全な0→1のフェーズで、まずはMVPを作成することが目標だったため、ドメインを絞ることが可能であり、Layer-firstのシンプルな構成でも管理しきれると判断しました。
学習コストの低減
Featuer-firstにした場合と比べディレクトリ構造がシンプルになるため、学習コストが抑えられると考えました。
結果と反省点
Clean ArchitectureとLayer-Firstの選定は自体はそこまで悪くはなかったものの、開発体制の問題で十分に活かしきれなかったように思います。
具体的には以下のような問題が起きました。
- ドメインモデリングに時間を時間を割かず機能追加を優先してしまったために、ドメインの役割が曖昧になってしまった
- 上記の問題をUsecase層で解決しようとした結果、一部のUsecaseが肥大化してしまった
手探りだったこともあり、特に基準を設けずドメインをバラバラの粒度で分割し、機能に変更があってもドメインを見直すことなく開発し続けたことが主な原因です。
単純にページや機能単位でドメインとして切り出すなど、実装時に感覚でドメインをコードに落としてレビューに回すような運用になってしまっていました。
ドメインの役割を曖昧にしたままUsecase層で帳尻を合わせ続けた結果、一部のUsecaseが肥大化し見通しの悪い構成になってしまったのだと思います。
Flutter webを採用したことによりFlutterの仕様の調査に気を取られ、今思うとドメインモデリングを軽視してしまっていました。
Nuxt.js
前回の反省を踏まえて、ピボットを機にNuxt.jsに乗り換えました。
メンバーの変化もあり、バックエンドにのみClean Architectureを適用することにしました。
DBはSupabaseを使用しています。
ディレクトリ構造
バックエンドのディレクトリ構造は以下の通りです。
packages/front/server
├── api
│ ├── <関心事>
│ │ └── (index or [id]).HTTP_METHOD.ts
├── domain
│ └── model
│ └── 関心事.ts
├── infrastracture
│ └── supabase
│ └── repository
│ └── 関心事Repository.ts
├── middleware
│ └── 関心事.ts
├── plugins
│ └── 関心事.ts
├── tsconfig.json
├── usecase
│ └── <関心事>
│ ├── I関心事Usecae.ts
│ └── <関心事>Usecae.ts
└── utils
├── shared.ts
├── constants.ts
├── date.ts
├── error.ts
├── jsdom.ts
├── logger.ts
└── usecase.ts
DIは以下のようにutils/usecase.tsで行い、api層はutils/usecase.tsを介してusecaseを利用します。
import { hogeUsecase } from '../usecase/hoge/hogeUsecase';
export const hogeUsecase = new HogeUsecase(
new SupabaseHogeRepository(),
);
狙い
Nuxt.jsではバックエンドにClean Architectureを適用した構造を取りました。
意識した点は以下の通りです。
バックエンドとフロントエンドの分離
前回の構成ではフロントエンドからRepository層を通じて直接DBにアクセスしていましたが、Nuxt.jsの導入を機に完全に分離しました。
フロントエンドのエンジニアが数名追加されたため、スキーマ駆動的に完全に分業する形としました。
構成をスリム化する
フロントエンドのApplicationにClean Architectureを持ち込む発想は悪くなかったものの、0→1のフェーズではどうしてもUIの変化にドメインが追いつけないきらいがありました。
そこでバックエンドのみClean Architectureを適用することで、より各層を単純化・軽量化し、ドメイン自体も柔軟に変更できる構造を意識しました。
結果と反省点
上記の構造によってバックエンドに限っては大きな破綻はなく、コードの見通しも良い比較的クリーンな状態を保つことができました。
一方で、以下の課題がありました。
DB構造への過度な依存
フロントエンドを完全に切り離した結果、バックエンドの設計がDBに引きずられる形になり、DDDの観点から見ると必ずしも理想的なドメインではありませんでした。
先にも触れていたドメインモデリングの不足に加え、Supabaseで生成したテーブルの型定義をドメイン層でも利用する設計にしてしまったことが問題だったと考えています。
テスト環境の未整備
Usecase層を含めてテストを一切書いていませんでした。
実装速度を言い訳にテストを完全に後回しにしていましたが、Clean Architectureを導入している以上、初期からテストを書いておくべきでした。
終わりに
学びと考察
プロジェクトを始めた時の私に言いたいのは、継続的にドメインモデリングをしろ、そしてテストを書けということです。
いわゆる軽量DDDとしてClean Architectureを単なるアーキテクチャとみなしてしまうと(しかもその上でテストを欠くと)、無用な複雑性を生むだけの結果になりかねません。
加えて、DDDを採用していれば以下のようなメリットも得られたでしょう。
ドメインモデリングにビジネスサイドを巻き込むことで、意図しない仕様変更や誤解を回避できる
ドメインモデリングを通じて、Bizサイドとエンジニアリングサイドでの認識を一致させることができたはずです。これにより、少なくとも出来上がった機能に対して「なんか違う」といったアバウトなフィードバックを受けることは無くせたでしょう。
ユビキタス言語の導入により、仕様の認識や変更に伴う議論がスムーズに進む
開発者とBizサイドで共通の言語を使うことで、言葉の定義や解釈の違いによる不毛な議論や空中戦を避けることができ、より建設的な議論が行えたはずです。
モデリングと実装の間に一貫性が生まれ、機能拡張が容易
上の2つを行っても仕様変更が発生することは当然避けられませんが、ドメインの責務が明確であれば機能拡張は容易だったでしょう。
せめて、コードを書き始める前に下記のようなUMLを作成すべきでした。
- システム関連図 (s)
- ユースケース図 (u)
- ドメインモデル図 (d)
- オブジェクト図 (o)
これらはlittle hands' lab様がDDDで最低限行うべきモデリングとして提唱されているもので、各手法の頭文字をとってsudoモデリングと命名されています。(参考)
このようなモデリングを行うことで、コードを書く前の段階でドメインの理解を深め、開発をよりスムーズに進められたでしょう。
モデリングを習慣化し、コードと仕様のギャップを最小限にすることが、Clean ArchitectureやDDDを最大限に活用するには不可欠でした。
今回のプロジェクトを通じて得た最大の教訓は、「目的を忘れずに設計を実践する」ことの重要性です。
アーキテクチャや設計手法を採用する際、形だけを模倣するのではなく、その背後にある意図や目標を理解し、それに基づいて開発を進める必要があります。
Clean ArchitectureやDDDは、複雑なプロジェクトでも柔軟性と保守性を確保するための強力なツールですが、適用には学びと試行錯誤が必要です。
プロジェクトを通じて得た知見や反省点は、今後の挑戦に確実に活かしていきたいと思います。
最後に、この記事がClean ArchitectureやDDDに挑戦しようとしている方々の参考になれば幸いです。
この記事が誰かの一歩を後押しするものになることを願っています。
参考文献
書籍
ブログ
Discussion