🫠

アーキテクチャを自分なりに整理してみる

に公開

プロジェクトの構造どうしてる?

アプリやサービスを開発する際、プロジェクト構造をどのように設計するかはずっと抱えてきた悩みです。
効果的な構造がないと、コードの保守が困難になったり、機能追加や修正が複雑になったりする問題が生じがちです。

特に大規模なアプリケーションでは、コードベースが肥大化するにつれて、各モジュール間の依存関係が絡み合い、全体像を把握するのが難しくなります。
そのため、開発の初期段階で明確なアーキテクチャ方針を定めることは、長期的なプロジェクトの成功において極めて重要な要素となります。

Domain-Driven Design (DDD) や Onion Architecture、Clean Architectureなど、ソフトウェアアーキテクチャのパターンは多岐にわたります。
それぞれが異なる設計思想に基づき、特定の課題解決を目指して提唱されています。
私自身もこれらの様々なアプローチを実際のプロジェクトで試行錯誤し、それぞれの利点や課題を試してみました。

その中で、複数のパターンから得た知見を組み合わせ、自身の開発スタイルに合った形で整理がついたのが、今回説明するものです。
このアプローチでは、特に状態管理や依存関係のシンプルさに焦点を当て、保守性と拡張性のバランスを取りながら、開発効率を高めることを目指しています。

私のアーキテクチャ

全体像

まずは全体像のイメージです

まずUIを担当するView層があり、ViewはAppStateを参照して画面を表示、クリックなどのイベントをもとにActionを呼び出します。
AppStateはアプリの状態を記憶しておく機能を担います。
Action層ではインフラ層の各機能を呼び出し、AppStateを変更します。

それぞれの要素

View

View層は、ユーザーインターフェース(UI)の描画と表示に関する責任を担います。
具体的には、画面上に配置されるボタン、テキストフィールド、画像といった各種コンポーネントを構築し、ユーザーが視覚的に認識し操作できる形を提供します。この層の主な役割は、AppStateから受け取ったデータを適切な形式で画面に反映させることです。

また、ユーザーがUIとインタラクションした際のイベント(例:ボタンクリック、テキスト入力)を捕捉し、それをAction層へと適切に伝達するゲートウェイとしての機能も果たします。
View層はユーザー体験に直結するため、デザインシステムの統一性やアクセシビリティへの配慮が重要であり、その内部にビジネスロジックを持たず純粋な表示の責任に集中することが原則です。

Action

Action層は、アプリケーションの具体的な機能実行をオーケストレーションする中心的な役割を担います。

この層自体はビジネスロジックの詳細な実装やデータ永続化の実装を直接持つことはありません。
その代わり、Domain層、Repository層、Helper層といったインフラ層の各機能を適切に組み合わせて、一つのまとまったアプリケーション機能として実行させます。

例えば、「ユーザー登録」というアクションでは、Domain層の定義クラスをもとにユーザーエンティティを生成し、Repository層を介してデータベースに保存し、必要であればHelper層のユーティリティ関数を使ってデータを加工する、といった一連のプロセスを調整します。

Action層はViewからのユーザーイベントを受け取り、これらの処理を通じてAppStateの状態を更新するなどの調整役を果たすことで、各インフラ層が単一責任の原則を保ちつつ、協調して動作するハブとして機能します。

AppState

AppStateは、アプリケーション全体の現在の状態を一元的に管理する責任を負います。
これには、現在開いているタブのインデックス、フィルタリングされたリストの表示条件、フォームの入力値、ユーザーの認証状態など、UIの表示や機能の挙動に影響を与える全ての情報が含まれます。

AppStateの変更はAction層からの指示によってのみ行われ、View層が直接AppStateを変更することは基本的に許されません。View層はAppStateを参照することで、常に最新かつ一貫性のある状態を反映したUIを表示します。

この一貫した状態管理メカニズムにより、アプリケーションの振る舞いが予測可能となり、デバッグ作業や新たな機能の追加が容易になります。
複雑なアプリケーションにおいて、AppStateの明確な定義と管理はシステムの安定性と保守性を高める上で非常に重要です。

Domain(インフラ層)

Domain層は、アプリケーションの中核をなすビジネスロジックとドメイン知識をカプセル化する層です。
この層には、システムが扱う主要な概念、すなわちエンティティ(例:顧客、製品、注文など)のデータ構造と、それらが持つ固有の振る舞いやロジックが定義されます。

例えば、注文エンティティであれば「合計金額を計算する」「注文ステータスを更新する」といった、ビジネスルールに密接に関連するメソッドが含ます。
Domain層は、特定の技術実装(データベース、UIフレームワーク、外部サービスなど)に依存せず、純粋なビジネスルールとドメインの整合性を保証することに集中します。
これにより、ドメインモデルは変化に強く、再利用性やテストの容易性が向上し、Action層がビジネス処理を実行する際の基盤を提供します。

Repository(インフラ層)

Repository層は、データの永続化と取得に関する責任を負う層です。具体的には、アプリケーションが利用する様々なデータソース(リレーショナルデータベース、NoSQLデータベース、ファイルシステム、外部APIなど)との間でデータをやり取りするための抽象化されたインターフェースを提供します。

この層は、データのCRUD(作成、読み取り、更新、削除)操作をカプセル化し、永続化に関する具体的な実装の詳細(SQLクエリの発行、APIリクエストの送信など)を上位層から隠蔽します。

例えば、UserRepositoryは、内部でデータベースにアクセスしてユーザーデータを保存したり取得したりする処理を実行しますが、その実装方法は上位層からは見えません。Repository層を導入することで、Domain層やAction層が特定の永続化メカニズムに依存することなく、データソースの変更がアプリケーション全体に与える影響を最小限に抑えることができます。

Helper(インフラ層)

Helper層は、特定のドメイン知識には直接属さないものの、アプリケーション全体で汎用的に利用されるユーティリティ機能や共通ロジックを提供する層です。

ここには、日付のフォーマット変換、文字列の操作、共通の入力値バリデーション関数、ログ出力処理、あるいは外部サービスとの連携における定型的なデータ変換など、多岐にわたる機能が含まれます。
これらの機能は、Action層で複数の異なるユースケースにおいて繰り返し利用される可能性が高く、
個々のActionに同じコードが散らばるのを防ぎます。

Helper層に集約することで、コードの重複を削減し、システムの保守性を向上させることが可能です。
これにより、Action層は純粋なビジネスフローのオーケストレーションに集中でき、コードの見通しが良くなり、開発効率の向上にも寄与します。

考察

レイヤードアーキテクチャと比較

レイヤードアーキテクチャは、ソフトウェアシステムを論理的に分離された複数の層(例: プレゼンテーション層、アプリケーション層、ドメイン層、インフラストラクチャ層)に分割する、広く普及している設計パターンです。

このアプローチでは、各層が特定の役割を持ち、上位の層は下位の層にのみ依存するという厳格なルールが一般的です。
これにより、関心の分離が促進され、システムのモジュール性が高まり、個々の層の変更が他の層に与える影響を限定できるとされています。

通常、ユーザーインターフェースからの入力はプレゼンテーション層が受け取り、アプリケーション層で処理が調整され、ドメイン層でビジネスロジックが実行され、最終的にインフラストラクチャ層でデータが永続化される、という流れで処理が進みます。

しかし、ここで提案するアーキテクチャは、必ずしも伝統的なレイヤードアーキテクチャが目指すような完璧な層構造を厳格に追求するものではありません。
むしろ、ここでは依存関係のシンプルさと管理のしやすさを最優先しています。

具体的には、Action層がDomain、Repository、Helperといったインフラ層の各機能に依存することを許容し、それ以外の不必要な依存関係(例: Viewが直接Domainに依存する、RepositoryがAppStateを参照するなど)を徹底的に排除することに焦点を当てます。

このアプローチは、従来の「レイヤーが積み重なる」というよりも、Action層が中央の「ハブ」として機能し、他の層はそのハブに対してサービスを提供する、という形式に近いと言えます。これにより、複雑な依存関係の網の目を避け、システムの変更や拡張がより容易になる構造を目指します。

View層は、このアーキテクチャにおいて他の層とは異なる特別な扱いを受けます。
その主な理由は、実際のアプリケーション開発において、View層の実装がReact、Vue、SwiftUIといった特定のUIフレームワークやライブラリに強く依存せざるを得ないからです。

これらのライブラリは、それぞれが独自のコンポーネントモデル、ライフサイクル、状態管理のメカニズムを持っているため、View層を完全にフレームワーク非依存で設計することは非現実的です。

したがって、各UIライブラリの特性や推奨されるパターンに合わせて、Actionの呼び出しとAppStateの参照を効果的に行えるよう、設計上の工夫が必要となります。

例えば、データバインディングやリアクティブな機能(useEffectなど)を活用してAppStateの変更をViewに監視させ、ユーザー操作をイベントとしてActionにディスパッチする仕組みを構築することが考えられます。

ほかのアーキテクチャで足りなかったもの

クリーンアーキテクチャやオニオンアーキテクチャなど、多くの既存アーキテクチャで実装を試みた際、アプリケーションの「状態」を管理する専用の要素が不足していると感じることがありました。

これらのアーキテクチャは、ビジネスロジックとインフラストラクチャの分離に重点を置いていますが、UI表示に直接関係する「今開いているタブのインデックス」や「リストのフィルタリング状態」、「フォームの入力中データ」といった、アプリケーションの揮発的な状態をどこで、どのように管理すべきかについての明確な指針が少なく、実装に迷いました。

この曖昧さから、結果としてView層に状態が混在したり、ドメイン層に不適切なUI関連の状態が入り込むリスクがありました。

AppStateという概念を独立した要素として導入することで、このようなアプリケーション固有の状態を適切に分離し、一元的に管理することが可能にしました。
これにより、状態の変更がより追跡しやすくなり、アプリケーション全体の振る舞いの予測性が向上します。

アプリケーションの状態を保存し、適切に管理する要素は、ユーザー体験の質とコードの保守性にとって非常に重要だと考えています。
明確な状態管理の仕組みがなければ、UIが期待通りに更新されなかったり、異なる画面間でデータの一貫
性が失われたりといった問題が発生しやすくなります。

AppStateを独立した要素として定義し、その変更経路を厳密に管理することで、これらの問題を未然に防ぎ、アプリケーション全体の整合性を保つことが可能になります。これにより、開発者は状態の変化がシステムに与える影響範囲を限定しやすくなり、デバッグ作業や新機能の追加、さらにはリファクタリングの効率が大きく向上するはずです。

クソコードもこの要素で読むと見やすい。

一般に「クソコード」と呼ばれる、読みづらく理解しにくいコードは、多くの場合、役割が異なる要素が不適切に混在していることが根本的な原因です。

例えば、ユーザーインターフェースが直接データベースへのアクセスロジックを持つ、Action層でビジネスロジックとUIの表示ロジックが絡み合う、あるいはDomainオブジェクトが特定のUIの状態を持つなど、各層の責任が曖昧になっているケースが頻繁に見られます。

このような既存の複雑なコードベースを分析する際でも、今回提案したView、Action、AppState、Domain、Repository、Helperといった明確な要素に分解して読むことで、それぞれの本来の意図や問題点を特定しやすくなります。

この役割分担の視点を持つことで、たとえコードが混在していても、どの部分がどの要素に相当するのかを切り分け、理解を段階的に深めることが可能です。結果として、既存の複雑なコードの改善点を見つけたり、リファクタリングの方向性を定めたりする上で、非常に有効なアプローチと考えています。

補足

保守の仕方

ソフトウェアの長期的な保守性において、モジュール間の依存関係の管理は常に重要な課題の一つです。
本アーキテクチャでは、依存関係をAction層に集中させることで、そのシンプルさを目指しています。

つまり、Action層がDomain、Repository、Helperといったインフラ層の具体的な実装に直接依存する形です。このアプローチは、比較的小規模から中規模のプロジェクトにおいては十分なシンプルさと開発速度を提供しますが、より大規模で堅牢なシステムや、長期にわたる保守を考慮する場合、依存性注入(Dependency Injection: DI)の導入がさらに有効な手段となり得ます。
DIを適用することで、Action層は具体的なインフラ層の実装ではなく、その抽象化されたインターフェースにのみ依存するようになります。

これにより、特定のデータベースや外部サービスの実装が変更されてもAction層への影響が限定され、テストの容易性やモジュール間の疎結合性が飛躍的に向上します。DIコンテナのようなツールを利用することで、依存関係の解決を自動化し、システムの保守性を一段と高めることが可能になります。

DIについてはこの記事がわかりやすかったです
https://zenn.dev/tokium_dev/articles/dependency-injection-watanabe

DTOの扱い

DTO(Data Transfer Object)は、プロセスや層の間でデータを転送するために特化されたシンプルなデータ構造のオブジェクトです。

通常、DTOはデータフィールドとそれに対応するゲッター・セッターのみで構成され、ビジネスロジックは含まれません。
その主な目的は、異なるモジュール間やネットワーク経由でのデータ受け渡しを効率化し、データの形状を明確にすることです。

例えば、データベースから取得した多量のデータをそのままView層に渡すのではなく、UIの表示に必要な最小限のデータのみをDTOに変換して受け渡すことで、通信量を最適化し、不必要な情報漏洩を防ぐ役割も果たします。

アプリケーション内のすべてのデータ受け渡しを、Domain層で定義されたエンティティや値オブジェクトのみで行うことは、現実的に困難な場合があります。Domainオブジェクトはビジネスロジックに特化しており、その構造はしばしば特定のユースケースやUI表示の要件には適さないためです。
特に、異なる層や外部サービス間でデータの粒度や形式が異なる場合、Domainオブジェクトだけでは柔軟に対応しきれない状況が発生します。

データベース(DB)付近で永続化のために扱うデータ構造と、View付近でユーザーに表示するために必要なデータ構造は、たとえ同じ種類の情報を扱っていたとしても、しばしば異なる要求を持っています。

例えば、DBは正規化された形式でデータを保持することが一般的ですが、Viewは表示のために結合されたり、集計されたりした非正規化されたデータを必要とすることがあります。
また、セキュリティやプライバシーの観点から、一部のDBフィールドはViewには公開せず、表示データからは除外したい場合もあります。

これらの理由から、各層が自身の責務とデータ利用の要件に合わせたDTOを持つことは、非常に有効なアプローチだと考えられます。例えば、Repository層がDBから取得したデータをDomain層に渡すためのDomainDTO、Action層がDomain層から受け取ったデータをView層に渡すためのViewDTOなどが考えられます。
これにより、各層が受け渡しデータの形式に柔軟に対応できるようになり、層間の結合度を低減させ、個々の層の変更が他の層に与える影響を最小限に抑えることが可能になります。

感想

アーキテクチャの構造を考えるとき、自分はレイヤーで考えるより、一か所ハブを作って、そこで全体を処理・管理するのが一番現実的で簡単だと感じてます。
アーキテクチャのルールに縛られて実装しようとして、かえってコードが複雑になってしまうことがよくありました。ある程度ルールが緩く、かつ、構造化された状態を維持しようとしてただりついたのがこのアーキテクチャでした。
みなさんは普段どんなアーキテクチャで実装していますか?

Discussion