はじめに
ソフトウェア設計におけるモジュール結合について学んでおり、
その中で表題のアンチパターンについて知ったので、
今回はこれを記事にしてみたいと思います。
内容結合とは
内容結合とは、上流モジュールと下流モジュールの関係性において、
下流モジュールが上流モジュールの公開インターフェースを通じてカプセル化された効能を享受するのではなく、
上流モジュール内にカプセル化された中身(非公開インターフェース)を直接参照して使用する、
と言うモジュール利用におけるアンチパターンのことを指します。
上流モジュールとは機能を提供するもの、
下流モジュールとはそれを使用して効能を享受するものとします。
言わば、適切なモジュール設計によってせっかくカプセル化が施されているにもかかわらず、
それを直接踏み越えてしまうことで頑健性を損なってしまうような状態、
ひいては変更容易性や拡張性にも悪影響を及ぼすような状態を引き起こしてしまうような
モジュール利用における約束事違反のことを指します。
不用意にモジュール利用に必要な知識を漏れ出させている状態、ということであり、
エンジニアにそれを暗黙的に求めている状態、ということです。
具体例:プライベートメソッドを外部参照
言葉だけだとイメージしづらいかと思われますので、以下に具体的なコードを示します。
// 内容結合させる対象コンポーネント(上流モジュール)
class BankAccount {
String owner;
int _balance = 1000; // プライベート変数
BankAccount(this.owner);
// 本来は外部から直接呼ばれてはいけないプライベートメソッド
void _secretWithdraw(int amount) {
_balance -= amount;
print('[Internal] $amount 円が隠れて引き出されました。残高: $_balance 円');
}
void showBalance() {
print('$owner さんの現在の公表残高: $_balance 円');
}
}
上記のような上流モジュールがあったとします。
private化された_secretWithdrawメソッドと_balanceプロパティについてはそのプレフィックスによりBankAccountクラス内部でのみ参照可能になっています。
ここで以下のコードのように無理やりプライベートメソッドを呼び出して_balanceの値を書き換えてみましょう。
// main.dart
import 'dart:mirrors';
/// 内容結合の例:リフレクションを使用してプライベートメソッドにアクセスする(下流モジュール)
/// アンチパターンコード
void main() {
// 1. 正常なインスタンス化
var myAccount = BankAccount('アリス');
myAccount.showBalance(); // 残高: 1000円
print('\n--- リフレクションによる内容結合(不正アクセス)を開始 ---');
// 2. インスタンスのミラー(鏡)を取得
InstanceMirror instanceMirror = reflect(myAccount);
// 3. プライベートメソッドの名前をシンボル化
// Dartのプライベートは「一意の識別子」として管理されるため、Symbol経由で指定可能
Symbol privateMethodName = #_secretWithdraw;
// 4. 外部からプライベートメソッドを強制実行(内容結合の発生)
// 引数に 800 を渡して呼び出す
instanceMirror.invoke(privateMethodName, [800]);
print('--- 不正アクセス終了 ---\n');
// 5. 結果の確認
myAccount.showBalance(); // 残高が 200円 に減っている
}
上記コードはリフレクションを用いてプライベートメソッドを無理やり呼び出しています。
このようなコードはカプセル化の効能を壊してしまい、内部実装が外に漏れ出してしまうことで頑健性を損ない、プロダクトコード全体の認知上の負荷を上げてしまいます。
どこで_balanceプロパティの値が不用意に書き換えられているのか、それをくまなく追わねばならなくなり、そんなコードがこれ以外にもわんさかあると考えると、否、例えこれだけだとしても、
それを読み解くエンジニアには常に「こういった存在とその影響範囲」といった暗黙的な知識を要してしまっているため、いずれにしても見通しは非常に悪くなってしまいます。。。
故に、内容結合を発生させるコードはアンチパターンであり、そういったコードは避けねばなりません。
具体例:Repositoryを介せずにDBとコミュニケーション
先述のようなコードは内容結合というアンチパターンを知らずとも「何となくやばそう」という匂いを何処となく感じさせるものでもあるため、実装の手を止める効果は持っているのではないかとも思われます。
しかし、このようなパターンはどうでしょうか。
- presentation, controller, service, repository, domainといったレイヤードアーキテクチャにて、
- repository経由でDBとコミュニケーションをしているものとそうでないものが混在している
クリーンなアーキテクチャをあえて壊すようなコードを以下に示してみます。
/// 擬似的なDBとDomain層
// database.dart (外部インターフェース)
class MockDatabase {
// どこからでもアクセスできてしまうデータ(行データ)を擬似的に表現
static final Map<int, Map<String, dynamic>> rawTable = {
1: {'id': 1, 'name': 'アリス', 'role': 'Admin'},
2: {'id': 2, 'name': 'ボブ', 'role': 'User'},
};
}
// user_domain.dart (Domain層)
class User {
final int id;
final String name;
final String role;
User({required this.id, required this.name, required this.role});
}
// user_repository.dart (Repository層)
/// Repository層とService層
class UserRepository {
// 【健全な処理】R: IDでユーザーを取得(DBからモデルへ変換)
User? findById(int id) {
print('[Repository] DBからデータを安全に取得中...');
final data = MockDatabase.rawTable[id];
if (data == null) return null;
return User(id: data['id'], name: data['name'], role: data['role']);
}
}
// user_service.dart (Service層)
class UserService {
final UserRepository _repository = UserRepository();
User? getUser(int id) {
return _repository.findById(id);
}
}
// user_controller.dart (Controller層)
/// ルールを破り始めたController層
class UserController {
final UserService _service = UserService();
// 【正常ルート】ReadはちゃんとService -> Repository経由で呼ぶ
void handleGetRequest(int id) {
final user = _service.getUser(id);
if (user != null) {
print('[Controller] ユーザーが見つかりました: ${user.name}');
}
}
// 【内容結合】Updateはあろうことか、DBのテーブルを直接書き換える!
void handleUpdateRequest(int id, String newName) {
print('[Controller] ⚠️規約違反: Service/RepositoryをバイパスしてDBを直接書き換えます');
if (MockDatabase.rawTable.containsKey(id)) {
// DBの「内部構造(Mapのキーや構造)」をControllerが完全に知ってしまっている(内容結合)
MockDatabase.rawTable[id]!['name'] = newName;
print('[Controller] DBの生データを直接更新しました。');
}
}
}
// user_view.dart (Presentation層 / 画面)
/// 画面からCRUDするPresentation層
class UserView {
final UserController _controller = UserController();
// 画面の初期表示
void renderPage() {
print('\n--- 画面描画 ---');
// ReadはController経由
_controller.handleGetRequest(1);
// UpdateもController経由(ただしControllerの内部はDB直叩き)
_controller.handleUpdateRequest(1, 'アリス・イン・ワンダーランド');
}
// 【最悪の内容結合】「削除ボタン」が押された時の処理
void onCryptoDeleteButtonClicked(int id) {
print('\n[Presentation] 🚨超・規約違反: 画面から直接DBの生データを削除します!');
// ControllerもServiceもRepositoryも完全に無視。
// 画面がDBの仕様(rawTableという変数名やremoveメソッド)に100%依存している
MockDatabase.rawTable.remove(id);
print('[Presentation] 画面から直接DBのレコードを抹消しました。');
}
}
このような状態になってしまうと、もはや何処で何をしていて、状態が今どのようになっていて、それがどう変遷しているのかを正確に追うことは難しいのではないでしょうか。。。
レイヤーの責務を超えたことをしていることでモックを差し込めずにコンポーネントテストの実装も難しくなり、DB修正時の影響範囲も閉じずに広がるばかりでしょう。。。
内容結合というと先述のコードのような「こんなコードなかなか書かないよなぁ」みたいなものを想像しがちですが、
実際は上記コードのようなアーキテクチャのアンチパターンをとることでも容易に表現できてしまい、
モジュール内の知識はどんどんと漏れ出してしまうのです。
こうならないためにも単一責任の原則に則って、レイヤーの役割・関心事・責務・目的をしっかりと踏まえて、お互いに超越しないようにコードを書き、設計しなければなりません。
Flutte, Dartにおけるアーキテクチャに関しては以下の記事が参考になります。
参考