はじめに
今回参照した本はこちらです。
ソフトウェアアーキテクチャの基礎――エンジニアリングに基づく体系的アプローチ
- 著者: Mark Richards, Neal Ford 他
ソフトウェアアーキテクチャとは?
ソフトウェアアーキテクチャを一言で表すと、**「システムの骨組みと、その作り方のルール」**です。
家を建てる時、まず設計図を描き、柱や壁の配置、配管などを決めますよね?ソフトウェアも同様に、どのような部品(モジュールやコンポーネント)で構成し、それらがどのように連携するのか、どのようなルール(設計原則や制約)に従って作るのか、といった全体的な構造を定めるのがアーキテクチャです。
良いアーキテクチャは、開発効率、保守性、拡張性、信頼性など、ソフトウェアの品質全体に大きな影響を与えます。
モジュール性: ソフトウェアを部品に分ける考え方
複雑なシステムを理解しやすく、管理しやすくするためには、システムをより小さなモジュール(部品)に分割することが重要です。これをモジュール性と言います。
適切にモジュール化されたシステムは、変更の影響範囲を限定でき、再利用性も高まります。モジュール性を評価する上で重要な指標が**「凝集度」と「結合度」**です。
凝集度: モジュールのまとまり具合
凝集度 (Cohesion) は、モジュールがどれだけ単一の目的に集中しているかを示す指標です。
凝集度が高いモジュールは、特定の機能や責務に特化しており、内部の要素が互いに強く関連しています。逆に、凝集度が低いモジュールは、関連性の低い機能が雑多に含まれており、理解しにくく、変更の影響も広がりやすくなります。
凝集度は高い方が良いとされています。
凝集度の分類(低い順)
-
偶発的凝集 (Coincidental Cohesion): 最も低い
- まったく関係のない処理が、ただファイルが同じという理由だけで1つのモジュールに詰め込まれている状態。
- 例: ログ記録、ファイル読み込み、メール送信など、互いに関連のない関数が
utils.py
にまとめられている。
-
論理的凝集 (Logical Cohesion)
- 似たようなカテゴリの処理が1つのモジュールにまとめられているが、実行する処理は引数(フラグなど)によって決まる。内部の処理は関連性が低いことが多い。
- 例:
process(data, mode)
という関数があり、mode
の値によって画像処理、音声処理、テキスト処理など全く異なるロジックを実行する。
-
時間的凝集 (Temporal Cohesion)
- プログラム実行のある特定のタイミングで行われる処理がまとめられているが、機能的な関連性は低い。
- 例: システム起動時に実行される初期化処理(設定読み込み、DB接続、ロガー設定など)をまとめたモジュール。
-
手順的凝集 (Procedural Cohesion)
- 特定の処理手順に従って実行される処理がまとめられている。処理間にデータの受け渡しはあるが、モジュール全体として1つの明確な機能を表してはいない。
- 例: ファイルを開く → 読み込む → パースするという一連の手順を実行する関数。
-
通信的凝集 (Communicational Cohesion)
- 同じデータ構造(入力や出力)を扱う処理がまとめられている。
- 例: あるデータを受け取り、そのデータを使って複数の属性を計算し、結果をまとめて返すモジュール(例: ユーザー情報を受け取り、年齢や住所から推奨情報を生成する)。
-
逐次的凝集 (Sequential Cohesion)
- ある処理の出力が、次の処理の入力となるような一連の処理がまとめられている。手順的凝集より関連性が強い。
- 例: 生データを読み込み、それを加工し、さらに別の形式に変換するモジュール。
-
機能的凝集 (Functional Cohesion): 最も高い
- モジュールが単一の明確な目的(機能)を持ち、その達成に必要な要素だけが含まれている状態。
- 例:
calculateSalesTax(price)
のように、与えられた価格に対して消費税を計算するという単一の機能だけを持つ関数。
凝集度を高めるメリット
- 可読性向上: モジュールの目的が明確になり、コードが理解しやすくなる。
- 再利用性向上: 特定の機能に特化しているため、他の箇所でも使いやすくなる。
- 保守性向上: 変更が必要な場合、影響範囲がそのモジュール内に限定されやすくなる。テストもしやすくなる。
凝集度を高める方法
- 単一責務の原則 (SRP: Single Responsibility Principle) を意識する。「一つのモジュールは、一つの、そしてただ一つのアクターに対して責務を負うべきである」という原則に従い、モジュールに複数の異なる目的を持たせないようにする。
- データの流れや処理の関係性を考慮し、関連性の高いものをまとめ、低いものを分離するようにモジュールを分割する。
結合度: モジュール間の依存関係
結合度 (Coupling) は、モジュール間の依存関係の強さを示す指標です。
結合度が高いと、あるモジュールを変更した際に、依存している他のモジュールにも影響が及ぶ可能性が高くなります。これにより、修正コストが増大し、保守性や再利用性が低下します。
結合度は低い方が良いとされています。理想は、各モジュールが独立して動作できることです。
結合度の種類(低い順)
-
データ結合 (Data Coupling): 最も低い
- モジュール間で、単純なデータ(プリミティブ型や単純なデータ構造)を引数や戻り値としてやり取りする。
- 例:
calculateArea(width, height)
のように、数値データを渡す。
-
スタンプ結合 (Stamp Coupling)
- モジュール間で、構造体やオブジェクト全体を渡すが、受け取る側はその一部のデータしか利用しない。
- 例:
User
オブジェクト(名前、住所、電話番号などを持つ)を関数に渡すが、関数内では名前 (user.name
) しか使わない。必要なデータだけを渡す方が結合度は低くなる。
-
制御結合 (Control Coupling)
- あるモジュールが別のモジュールに対して、処理の流れを制御するための情報(フラグなど)を渡す。呼び出し側が呼び出し先の内部ロジックを知っている必要がある。
- 例:
processData(data, is_detailed_mode)
のように、処理モードを指示するフラグを渡す。
-
外部結合 (External Coupling)
- 複数のモジュールが、共通の外部リソース(設定ファイル、データベース、外部ライブラリの特定の仕様など)に依存している。
- 例: 複数のモジュールが同じ環境変数や特定のファイル形式を直接参照している。
-
共通結合 (Common Coupling)
- 複数のモジュールが、共通のグローバル変数やグローバルなデータ領域を共有・変更している。
- 例: 複数のモジュールが
globalConfig
というグローバル変数を参照・更新している。どこで変更されたか追跡が困難になる。
-
内容結合 (Content Coupling) / 内部結合 (Internal Coupling): 最も高い
- あるモジュールが、別のモジュールの内部データや実装の詳細に直接アクセスしたり、変更したりする。
- 例: あるクラスが、別のクラスの
private
なメンバ変数やメソッドを直接操作する(言語によっては可能)。これはカプセル化を破壊する最悪の結合。
結合度を低く保つには
- モジュール間のインターフェースを明確に定義し、内部実装を隠蔽する(カプセル化)。
- 必要なデータのみを渡すようにする。
- グローバル変数の使用を避ける。
- 依存関係逆転の原則(DIP)などを活用し、具体的な実装ではなく抽象(インターフェースや抽象クラス)に依存する。
主系列からの距離: パッケージ配置の理想と現実
ロバート・C・マーチン氏が提唱した概念で、パッケージ(モジュールの集まり)の抽象度と安定度のバランスを見るための指標です。
-
抽象度 (A: Abstractness)
- パッケージ内のクラスのうち、抽象クラスやインターフェースが占める割合。
- $A = \frac{\text{抽象クラス・インターフェースの数}}{\text{総クラス数}}$
- $A=0$ なら、パッケージ内は具象クラスのみ。
- $A=1$ なら、パッケージ内は抽象クラス・インターフェースのみ。
-
安定度 (I: Instability)
- パッケージが外部のパッケージにどれだけ依存しているか(不安定さ)を示す指標。依存される側(変更しにくい)ほど安定している。
- $I = \frac{\text{出力依存数 (Fan-out)}}{\text{入力依存数 (Fan-in) + 出力依存数 (Fan-out)}}$
- Fan-out: パッケージが依存している外部パッケージの数。
- Fan-in: パッケージに依存している外部パッケージの数。
- $I=0$ なら、他のパッケージに依存していない(最も安定)。変更の影響を受けにくい。
- $I=1$ なら、他のパッケージに依存しているだけで、どのパッケージからも依存されていない(最も不安定)。変更の影響を受けやすい。
主系列 (Main Sequence) とは、これらの指標が $A + I = 1$ という関係を満たす理想的な直線を指します。
- $A=1, I=0$ (苦痛ゾーン Zone of Pain): 非常に抽象的だが、誰からも依存されていないパッケージ。役に立たない可能性がある。
- $A=0, I=1$ (無駄ゾーン Zone of Uselessness): 非常に具体的で不安定(多くのパッケージに依存している)が、誰からも依存されていないパッケージ。変更が困難で再利用性も低い。
主系列からの距離 (D: Distance from the Main Sequence) は、パッケージがこの理想的な状態からどれだけ離れているかを示します。
$D = |A + I - 1|$
- $D=0$ が理想的な状態。パッケージは抽象度と安定度のバランスが取れている。
- $D>0$ であるほど、主系列から離れており、「苦痛ゾーン」または「無駄ゾーン」に近づいていることを示唆する。
この指標を使うことで、パッケージ構成が健全かどうかを評価し、リファクタリングの指針を得ることができます。
コナーセンス: コード間の依存関係の強さ
コナーセンス (Connascence) は、Bertrand Meyerによって提唱され、後にJim Weirichらによって拡張された概念で、**コード間の依存関係の強さ(変更の連動性)**を測るための考え方です。
あるコンポーネントを変更したとき、別のコンポーネントも変更しなければならなくなる場合、それらの間にはコナーセンスが存在すると言います。コナーセンスが強い(レベルが高い)ほど、変更の影響範囲が広がり、保守性が低下します。
コナーセンスは弱い方が良いとされています。
コナーセンスの種類(弱い順)
静的なコナーセンス (Static Connascence) - コンパイル時に検出可能
-
名前のコナーセンス (Connascence of Name): 最も弱い
- あるエンティティ(変数、メソッド、クラスなど)の名前が、複数の場所で参照されている。名前を変更すると、参照箇所すべてを変更する必要がある。
- 例: クラスのメソッド名を変更すると、そのメソッドを呼び出している箇所もすべて修正が必要。
-
型のコナーセンス (Connascence of Type)
- 複数のコンポーネントが、特定のエンティティの型に依存している。型を変更すると影響を受ける。
- 例: 関数の引数を
int
型と定義していたが、float
型に変更すると、その関数を呼び出している箇所で型エラーが発生する可能性がある。
-
意味のコナーセンス (Connascence of Meaning / Convention)
- 特定の定数値の意味について、複数の箇所で共通の認識(規約)を持っている。
- 例:
True
を1
、False
を0
として扱うという暗黙のルールがある場合、このルールを知らないとコードを誤解する可能性がある。マジックナンバーもこれに該当。
-
位置のコナーセンス (Connascence of Position)
- 引数などの値の意味が、その順番や位置によって決まっている。
- 例:
draw_line(x1, y1, x2, y2)
のように、引数の順番が固定されている関数。引数の順番を変えたり、間に追加したりすると、呼び出し側もすべて修正が必要。
-
アルゴリズムのコナーセンス (Connascence of Algorithm)
- 複数のコンポーネントが、特定のアルゴリズムが正しく動作するために、互いに協調する必要がある。
- 例: あるモジュールでデータを特定のアルゴリズム(例: HmacSHA256)でエンコードし、別のモジュールでデコードする場合、両方が同じアルゴリズムと設定(例: 秘密鍵)を使用しなければならない。
動的なコナーセンス (Dynamic Connascence) - 実行時にのみ検出可能
-
実行順序のコナーセンス (Connascence of Execution Order)
- 複数の処理が、特定の順序で実行されないと正しく動作しない。
- 例:
initialize()
を呼び出した後にprocess()
を呼び出さないとエラーになる。
-
タイミングのコナーセンス (Connascence of Timing)
- 処理の実行タイミングが重要となる依存関係。特に並行処理や非同期処理で問題になる。
- 例: ある処理が終わるのを待たずに(コールバックやPromiseを使わずに)次の処理を開始すると、競合状態やデッドロックが発生する。
-
値のコナーセンス (Connascence of Value)
- 複数の値が、特定の関係性(制約)を満たしている必要があり、それが複数の箇所で仮定されている。
- 例: ある範囲を表す2つの変数
start
とend
があり、常にstart <= end
である必要がある。この制約がコードの複数箇所でチェック・依存されている場合、一方の値を変更する際は他方も考慮する必要がある。
-
アイデンティティのコナーセンス (Connascence of Identity): 最も強い
- 複数のコンポーネントが、メモリ上の同じエンティティ(オブジェクトインスタンス)を参照する必要がある。
- 例: イベントリスナーを登録した後、登録時と同じインスタンスを使って解除しないと、正しく解除できない場合。
コナーセンスのレベルを意識し、できるだけ弱いレベルのコナーセンス(名前、型など)に留めることで、変更に強く、保守しやすいコードを目指すことができます。リファクタリングは、強いコナーセンスを弱いコナーセンスに置き換える活動とも言えます。
アーキテクチャ特性: システムの非機能要件
ソフトウェアアーキテクチャを設計する上で、機能要件(システムが何をすべきか)だけでなく、アーキテクチャ特性 (Architecture Characteristics)、いわゆる非機能要件 (Non-functional Requirements) を考慮することが極めて重要です。
アーキテクチャ特性とは、**「そのシステムがどのように振る舞うか・維持されるか・進化するか」**を定義するものです。これらはシステムの成功に不可欠であり、設計の初期段階で特定し、優先順位をつける必要があります。
主なアーキテクチャ特性には、以下のようなものがあります。
運用特性 (Operational Characteristics)
- 可用性 (Availability): システムが障害なく稼働し続けられる時間の割合。高可用性が求められるシステムでは、冗長化(サーバーの複数台構成、レプリケーションなど)やフェイルオーバーの仕組みが必要。
- 耐障害性 (Fault Tolerance): システムの一部に障害が発生した場合でも、システム全体が停止することなく、処理を継続または適切に復旧できる能力。エラーハンドリング、リトライ機構、レプリケーションなどが関連。
- パフォーマンス (Performance): レスポンスタイム(応答時間)、スループット(単位時間あたりの処理能力)、リソース使用率(CPU、メモリなど)といったシステムの効率性。効率的なアルゴリズム、キャッシング、非同期処理などが影響。
- 監視性 (Monitorability) / 可観測性 (Observability): システムが現在どのような状態にあるか、問題が発生した場合に原因を特定できるか。ログ収集、メトリクス監視、トレースなどが重要。
- 回復性 (Recoverability): 障害発生後、システムを正常な状態に迅速に復旧できる能力。バックアップとリストア戦略、ロールバック手順などが含まれる。
- セキュリティ (Security): 不正アクセス、データ漏洩、改ざんなどからシステムとデータを保護する能力。認証、認可、暗号化、脆弱性対策などが重要。
構造特性 (Structural Characteristics)
- 保守性 (Maintainability): システムの修正や改善がどれだけ容易か。コードの可読性、モジュール性(低結合・高凝集)、テスト容易性などが影響。
- テスト容易性 (Testability): システムやその各部分をどれだけ容易にテストできるか。依存関係の注入(DI)、モック化しやすい設計などが重要。
-
拡張性 (Extensibility) / スケーラビリティ (Scalability):
- 拡張性: 新しい機能を追加する際の容易さ。モジュール性が高く、疎結合な設計が有利。
- スケーラビリティ: システムへの負荷(ユーザー数、データ量など)が増加した際に、性能を維持・向上させるためにリソース(サーバー、DBなど)を追加・拡張できる能力。水平スケール(サーバー数を増やす)や垂直スケール(サーバーのスペックを上げる)などがある。オートスケーリングも関連。
- デプロイ容易性 (Deployability): 開発したソフトウェアを本番環境へリリースする際の容易さや頻度。CI/CDパイプラインの構築、コンテナ化などが関連。
横断的特性 (Cross-cutting Characteristics)
- アクセシビリティ (Accessibility): 高齢者や障がいのある方を含め、誰もがシステムを利用できるか。
- ユーザビリティ (Usability): ユーザーがシステムをどれだけ容易に、効率的に、満足して利用できるか。
- コスト効率 (Cost Efficiency): 開発コスト、運用コスト(サーバー費用、ライセンス料など)を最適化できるか。クラウドサービスの活用、サーバーレスアーキテクチャなどが関連。
アーキテクチャ特性間のトレードオフ
重要なのは、すべてのアーキテクチャ特性を同時に最高レベルで満たすことはできないということです。多くの場合、特性間にはトレードオフの関係が存在します。
- スケーラビリティ vs コスト効率: サーバー台数を増やしてスケーラビリティを高めると、運用コストが増大する。
- セキュリティ vs パフォーマンス: 強力な暗号化処理や厳密なアクセス制御は、システムのレスポンスタイムを低下させる可能性がある。
- 開発速度 vs 保守性: 短期的な開発速度を優先して技術的負債を許容すると、将来的な保守性が低下する。
- パフォーマンス vs 可用性: キャッシュを多用してパフォーマンスを上げると、キャッシュサーバーが単一障害点になり可用性が低下するリスクがある(対策が必要)。
したがって、プロジェクトの目的、ビジネス要件、制約条件などを考慮し、どのアーキテクチャ特性を優先するのかを設計の初期段階で明確に定義し、合意形成することが非常に重要です。
コンポーネントの分類: システムの部品を整理する
システムが大きくなるにつれて、その構成要素(コンポーネント)をどのように整理・分類するかが重要になります。コンポーネント分類は、システムを構成する部品を、その役割や責務に応じてタイプ別に整理する考え方です。
コンポーネントは、クラスや関数、あるいはそれらの集まり(モジュール、パッケージ)として実装され、その設計はテックリードや開発者が担当します。明確な分類ルールを持つことで、コードの配置場所が予測しやすくなり、一貫性のある設計を促進します。
コンポーネントの分類方法には、いくつかの一般的なアプローチがあります。
1. レイヤー(層)別の分類
システムを水平方向のレイヤーに分割し、各レイヤーに特定の責務を割り当てる古典的な方法です。
- プレゼンテーション層 (Presentation Layer): UI(ユーザーインターフェース)、APIエンドポイントなど、ユーザーや外部システムとのやり取りを担当。
- アプリケーション層 (Application Layer): ユースケース(システムの操作シナリオ)を実装。ドメイン層のオブジェクトを操作し、ビジネスプロセスを実行する。
- ドメイン層 (Domain Layer): ビジネスルールやドメイン知識(システムの中心的な関心事)を表現するエンティティやロジックを含む。
- インフラストラクチャ層 (Infrastructure Layer): データベースアクセス、外部API連携、ファイルシステム操作など、技術的な詳細を担当。
メリット:
- 関心事の分離が明確になる。
- 各層の役割が理解しやすい。
デメリット:
- 機能追加時に複数のレイヤーを修正する必要がある場合が多い。
- ドメイン知識がアプリケーション層などに漏れ出すことがある。
2. 機能別の分類
システムを主要な機能(例: ユーザー管理、商品管理、注文管理)ごとに分割し、それぞれの機能に関連するコンポーネントをまとめる方法です。
/src
/user
user.controller.ts
user.service.ts
user.repository.ts
/product
product.controller.ts
product.service.ts
product.repository.ts
/order
order.controller.ts
order.service.ts
order.repository.ts
メリット:
- 特定の機能に関するコードが見つけやすい。
- 機能単位での開発やテストがしやすい。
デメリット:
- 機能間で共通する処理の置き場に悩むことがある。
- 機能が複雑化すると、ディレクトリ内が肥大化する可能性がある。
3. 責務ベースの分類
コンポーネントを**「何を担当するか」**という具体的な責務に基づいて分類する方法です。フレームワークのパターン(MVC、MVP、MVVMなど)でよく見られます。
例 (NestJSなど):
- Controller: HTTPリクエストを受け取り、適切な Service を呼び出し、レスポンスを返す。
- Service: 主要なビジネスロジックやユースケースを実装する。複数の Repository を利用することもある。
- Repository: データ永続化層(DB、外部APIなど)へのアクセスを抽象化する。
- Entity/Model: ドメインオブジェクトやデータベースのスキーマを表す。
- DTO (Data Transfer Object) / Presenter: レイヤー間や外部とのデータ転送、表示・出力形式への変換を担当。
メリット:
- 各コンポーネントの役割が明確になる。
- フレームワークの規約に沿っている場合、開発者が理解しやすい。
デメリット:
- ビジネスドメインの知識が複数の責務タイプに分散しやすい。
4. ドメインによる分割 (ドメイン駆動設計 - DDD)
システムの関心事をビジネスドメイン(例: 商品、注文、顧客、配送)に基づいて分割し、それぞれのドメインを中心にコンポーネントを設計・配置する方法です。マイクロサービスアーキテクチャは、この考え方を物理的なサービス分割に応用したものです。
例 (ECサイト):
- 商品ドメイン (Product Domain): 商品の登録、在庫管理、価格設定などの責務を持つコンポーネント群。
- 注文ドメイン (Order Domain): カート処理、注文確定、決済などの責務を持つコンポーネント群。
- 顧客ドメイン (Customer Domain): 顧客情報の登録、認証、ログインなどの責務を持つコンポーネント群。
- 配送ドメイン (Shipping Domain): 配送手配、配送ステータス管理などの責務を持つコンポーネント群。
メリット:
- ビジネスドメインとコードの構造が一致するため、理解しやすく、変更に強い。
- ドメインごとに独立して開発・デプロイしやすいため、チーム間の連携が疎結合になる(マイクロサービスの場合)。
- 各ドメインに最適な技術を選択できる可能性がある。
デメリット:
- ドメイン境界の特定が難しい場合がある。
- ドメイン間の連携方法(イベント駆動、API呼び出しなど)の設計が必要。
- 全体を俯瞰するのが難しくなる可能性がある。
5. 技術による分割 (例: MVCモデル)
伝統的な MVC (Model-View-Controller) パターンのように、技術的な役割(データ管理、表示、制御)に基づいてコンポーネントを分割する方法です。
- Model: アプリケーションのデータとビジネスロジックを担当。
- View: ユーザーインターフェース(表示)を担当。Model のデータを表示する。
- Controller: ユーザーからの入力を受け取り、Model と View を制御する。
メリット:
- 広く知られており、理解しやすい。
- UIとビジネスロジックの分離を促す。
デメリット:
- アプリケーションが複雑化すると、Controller や Model が肥大化しやすい(Fat Controller/Fat Model)。
- ビジネスロジックが複数のコンポーネント(特に Model と Controller)に分散し、ドメインの凝集性が失われやすい。
- レイヤー別分類と組み合わせられることが多いが、変更や拡張に弱い傾向がある場合がある。
これらの分類方法は排他的ではなく、組み合わせて使われることもよくあります(例: ドメインごとに分割し、その内部をレイヤー別や責務ベースで分類する)。プロジェクトの規模、複雑さ、チーム構成、重視するアーキテクチャ特性などに応じて、最適なコンポーネント分類戦略を選択することが重要です。
まとめ
この記事では、ソフトウェアアーキテクチャの基礎として、以下の重要な概念を解説しました。
- アーキテクチャの定義: システムの骨組みとルール。
-
モジュール性: システムを部品に分ける考え方。
- 凝集度: モジュール内のまとまり具合(高い方が良い)。
- 結合度: モジュール間の依存関係(低い方が良い)。
- 主系列からの距離: パッケージの抽象度と安定度のバランス指標。
- コナーセンス: コード間の変更連動性の強さ(弱い方が良い)。
- アーキテクチャ特性: システムの非機能要件(トレードオフを意識することが重要)。
- コンポーネント分類: システムの部品を整理する方法(レイヤー、機能、責務、ドメイン、技術など)。