はじめに
設計原則の「衝突」をどう解消するか
より良いソフトウェア設計について考える時、
複数の理論の間で板挟みになります。
例えば、ソフトウェア設計における深いモジュールと、
クリーンなコーディング技術における5行ルールの整合性をどのように考えましょうか。
前者は「インターフェースをシンプルに保ち、内部に複雑さを封じ込めること」を求めますが、
それを愚直に実践すれば、モジュールの内部ロジックは必然的に巨大化します。
一方で、後者は「メソッドを5行以内に収めること」を要求します。
この二つを同時に満たそうとすれば、
深いモジュールの内部は5行程度に分断された無数のプライベートメソッド(浅いモジュール)で埋め尽くされることになります。
さて、この状態は良いのでしょうか、矛盾はしていないのでしょうか。
同時に、この細分化された内部構造を振る舞いの検証という観点に立ってどう検証すべきでしょうか。
本稿では設計・実装・テストを一つの整合性あるシステムとして統合する戦略についてつらつらと考え、それを記事にしたいと思います。
用語定義
議論の解像度を揃えるため、本稿で使用する用語を定義します。
モジュール
特定の責務を果たすための論理的な境界であり、自己完結型のコンポーネントのこと。
クラスやパッケージ、名前空間、関数・メソッドなどコンパイル可能な多様なものが該当し、
設計・アーキテクチャといった大局的な観点に基づくものやその具体的なコードなどのような局所的な観点に基づくものまで幅広く存在する。
インターフェース
モジュールの外壁であり接合部分。
メソッド名やクラスの引数の型定義・数、クラスのプロパティにおけるそれらだけでなく、
呼び出し側が守るべきモジュールの呼び出し順序や、
それを利用する際に必要となる暗黙的な前提知識、
たとえば、クエリを実行するためのモジュールがあったとして、そのモジュールで自由度の高いクエリを表現できる場合、
それに紐づくDBやそのカラム、インデックスの知識が暗に求められています。
またはそのクエリの方言がDB種類に対応しているのかなどの知識も暗に求められています。
加えてそのモジュールの実行によって生じる副作用はどんなものなのか、
求められる例外処理がどんなものなのかも暗に求められています。
こう言った明示的ないしは暗黙的な知識の全てを含みます。
端的に言えば、そのモジュールを利用する際に必要となる明示的ないしは暗黙的な知識のこと。
情報の隠蔽
単にデータを隠すことではなく、「実装の詳細(How)」を隠し、外部に影響を与えずに中身を書き換えられる状態を作ること。
深いモジュール
インターフェースという「利用コスト」に対して、提供される機能という「価値」が圧倒的に大きい状態。
浅いモジュール
インターフェースの複雑さと提供する機能が同等、あるいは手順の制御を利用側に強いている状態。
結合
モジュール同士の依存の強さを示す指標です。
一方の変更が他方に波及しやすい状態を「密結合」、影響が限定的で切り離しやすい状態を「疎結合」と呼びます。
結合度を下げるには、外部に公開するインターフェースを最小化して、情報を隠蔽する必要があります。
境界線
AモジュールとBモジュールがあったとして、
そのモジュールごとの責務の割り当て範囲のこと。
これが不明瞭であったり領海侵犯させたり、
はたまた相手に依存しないと自身の振る舞いを機能提供出来ないような貧血ドメインモデルのコンポーネントにするのではなく、
重なりが生まれないように自身の振る舞いを明確に表現し、
それを自己完結できるように自己完結型の完全なオブジェクト(ドメインモデルの完全性が担保されたモジュール)を設計することが求められる。
5行ルール
関数やメソッドの行数(ステートメント)を5行程度に抑える制約。
ロジックを抽象化されたステップに分解し、
認知負荷を物理的に制限する戦術。
振る舞いのテスト
内部の実装構造ではなく、
外部から観測可能な「最終的な状態の変化」のみを検証する手法。
「入力 → 処理 → 出力」と言うプログラムの流れの内、
「入力」と「出力」の観点にたってあるべき結果が返ってくるか否かを検証する、
と言うブラックボックステストを行う。
戦略としての「深いモジュール」
設計の第一歩は、どこに境界線を引き、何を隠蔽するかを決定すること、
つまり、モジュールの責務や関心事、目的が単一責任になるよう抽象的に纏め上げ、
それらモジュールが密結合しないようなコミュニケーション体系を作ることです。
【Before】手順が漏洩した「浅いモジュール」
以下の設計は、注文処理に必要な手順が個別のメソッドとして露出しています。
// 利用側に手順の制御を強いる浅いモジュール
class OrderService {
Future<void> checkStock(Cart cart) async { ... }
int calculatePoints(User user, int amount) { ... }
Future<void> charge(User user, int amount) async { ... }
Future<void> saveOrder(Order order) async { ... }
}
この設計の問題は、利用側が「在庫確認 → 決済 → 保存」という正しい順序を把握していなければならない点にあります。
この状態だと誤った使い方に基づく想定外の振る舞いをしてしまいかねず、
利用者の認知負荷を高めてしまいます。
このようなソフトウェア設計上の複雑性のことを偶有的な複雑性と言い、
ドメインによる開発上の難しさと言った本質的な複雑性に対して、
適切な設計によりコントロールされるべきこととされています。
【After】複雑さを封じ込めた「深いモジュール」
利用側の関心は「注文を完了させる」という一点にあります。
そのため、インターフェースは必要最小限にとどめつつ、その効能のみを提供できるようにカプセル化を施します。
class OrderService {
final StockRepository _stockRepo;
final PaymentGateway _paymentGateway;
final OrderRepository _orderRepo;
OrderService(this._stockRepo, this._paymentGateway, this._orderRepo);
// 深いモジュール:利用者はこれ一つを呼ぶだけでビジネスプロセスが完結する
Future<void> placeOrder(User user, List<OrderItem> items) async {
final cart = Cart(items);
await _handleInventory(cart);
final total = _calculateTotal(cart, user);
await _executePayment(user, total);
await _persistOrder(user, cart, total);
}
}
上記のコードはplaceOrderというメソッドの利用に際して必要な引数を最低限に絞っている一方、
その中身のステートメントについては個々のプライベートメソッドの呼び出しによりビジネスロジックを構成しており、
利用上の前提知識をカプセル化しています。
これによりモジュールの責務を踏まえながらもコードをスッキリさせているのです。
戦術としての「整理用パーツ」と「5行ルール」
先述のようにモジュールを深くすることで利用者は内部実装を意識することなくその効能を享受することが出来るようになりました。
これはプログラムの変更容易性や拡張性、頑健性を高めることに繋がり、プロダクトの持続的な成長やその運用メンテナンスにおいても正の効果をもたらすものと思われます。
とは言え一方で、深いモジュールの内部は必然的に多くのステートメントのステップを抱えることになります。
これを一つの巨大なメソッドに記述すると可読性が著しく低下してしまい、
モジュール設計によるメリットを損なってしまいます。
そこで「5行ルール」を使い、モジュール内のロジックをプライベートなメソッドへ抽出します。
ここで抽出されたメソッドはそれ自体は単機能で「浅い」ものですが、
メインロジックを「斜め読み可能な抽象化された目次」として保つための重要な整理用パーツです。
extension on OrderService {
// 5行ルールに基づき、詳細(在庫ロック)を閉じ込める
Future<void> _handleInventory(Cart cart) async {
for (final item in cart.items) {
if (!await _stockRepo.isAvailable(item.id, item.quantity)) {
throw OutOfStockException(item.id);
}
await _stockRepo.lock(item.id, item.quantity);
}
}
// 割引計算の詳細。ここが複雑になるほど隠蔽の価値が高まる
int _calculateTotal(Cart cart, User user) {
final subtotal = cart.items.fold(0, (sum, i) => sum + (i.price * i.quantity));
final discount = _getMembershipDiscount(user, subtotal);
return subtotal - discount;
}
int _getMembershipDiscount(User user, int subtotal) {
if (!user.isPremium) return 0;
return (subtotal * 0.1).toInt(); // 10%割引
}
Future<void> _executePayment(User user, int amount) async {
final result = await _paymentGateway.charge(user.id, amount);
if (!result.isSuccess) throw PaymentFailedException(result.error);
}
Future<void> _persistOrder(User user, Cart cart, int total) async {
final order = Order(userId: user.id, items: cart.items, total: total);
await _orderRepo.save(order);
}
}
上記の拡張メソッドは先述のモジュール内部で参照しているプライベートメソッドの実装部分になります。
これら拡張メソッド内のステートメントをそのまま先述のモジュール内部に実装するのではなくプライベートメソッドとして抽出し、
それを参照するようにすることでモジュール内部のコード量を大きく減らすことが出来ると同時に、
その斜め読みも可能になります。
こう言った可読性とメンテナンス性も加味した斜め読みについては『定義指向プログラミング』や『関数内の抽象レベルを合わせる』という手法に基づいて設計することが有効かと思われますので、
こちらについては、僭越ながら、以下の記事をご参照いただければと思います。
自動テストはどうする?
境界線での「振る舞い」検証
先述のように「5行ルール」で細分化することにより可読性とメンテナンス性を高めつつモジュールの頑健性、拡張性、変更容易性を高めることが出来ました。
ただ一方でその内部には多くのプライベートメソッドがあります。
プライベートメソッド自体はカプセル化の効果がありますのでそれそのものが悪い訳ではありまけんが、気になる点もあります。
それは、
「プライベートメソッドを直接テストしたい場合はどうするか」
という不安に駆られることです。
しかし、プライベートメソッドの名前や引数をテストに固定してしまうと、将来そのパーツを再編した際にテストが足かせとなり、リファクタリングができなくなります。
プライベートメソッドの自動テストにおいてはアンチパターンとして語られることも多いので、以下に参考になりました記事を共有させていただければと思います。
このようなモジュールにおける自動テストにおいては、
「内部パーツの仕様(境界値など)を、すべて公開メソッド(placeOrder)経由でテストする」
ことで振る舞いを保証することが出来るかと思われます。
group('OrderService.placeOrder', () {
test('プレミアム会員の場合、割引が適用されて注文が保存されること', () async {
final user = User(id: 'u1', isPremium: true);
final items = [OrderItem(price: 1000)];
await orderService.placeOrder(user, items);
// 内部パーツ _calculateTotal の正確性を、保存結果で担保する
final savedOrder = await orderRepo.findLastByUserId('u1');
expect(savedOrder.total, 900);
});
});
上記の自動テストコードは公開モジュールであるplaceOrderメソッドの振る舞い全体における「割引金額の適用」に関する正常系の振る舞いを保証しています。
placeOrderメソッドの振る舞い保証には他にも必要なテストケース、テストスイートはありますが、
その内の一つでプライベートメソッドである_caluculateTotalメソッドの振る舞いも「ついでに」保証している形です。
そもそも自動テストにおいてはドメイン情報という内部実装(内部構造、詳細部分)をテストするべきではないというアンチパターンもあります。
こちらについては、僭越ながら、以下の記事をご参照いただければと思います。
プライベートメソッドと言う非公開インターフェースは常に公開インターフェースに使われる側のステートメントとして存在していて、
それすなわち公開モジュールの内部実装になるので、
テストで保証すべき外部からの振る舞いではありません。
加えて、そのプライベートメソッド「そのもの」の自動テストをすることはカプセル化を壊してしまい、
モジュールの境界を不明瞭にしてしまいます。
そう言う意味でも本来であればプライベートメソッドそのものの自動テストをすべきではないと思われますが、
上記のように公開メソッドの自動テストケースの一部として実装することで、
間接的に実質そのテストを行うことも出来ます。
こうすることで仕様変更以外ではテストがこけなくなるので、
偽陽性や偽陰性による品質保証上の弊害を回避することが出来るのです。
早い話、テストコードそのものが内部構造を知らないことで、
実装の自由と品質の保証が同じ境界線上で両立されるようになります。
設計が限界を迎える時の「昇格」プロセス
ここまでモジュール設計と5行ルール、自動テストとの間に戦略と戦術と言う観点で論理の整合性を見てきました。
ここからは少し話を変えて、プライベートメソッドそのもののリファクタリングのタイミングについて考えてみます。
開発が進むと、特定のプライベートメソッドにビジネスルール、
つまり、ドメインロジックやアプリケーションロジックと言った「現実世界の諸般のルールや手順、約束事」と言ったビジネスロジックが集中し、
公開メソッド経由のテストだけでは組み合わせの網羅が困難になる瞬間が訪れます。
今回はこれを「構造の限界」と捉えて考えてみます。
【Before】複雑化した内部パーツ(限界の状態)
例えば、先述の_calculateTotalメソッドを少し変形させつつ、
「キャンペーン」「地域別の税率」「クーポン」
といったロジックが新しく追加されたとします。
// placeOrderのテストを書くために、膨大なセットアップが必要になった状態
int _calculateTotal(Cart cart, User user, DateTime now) {
int total = _sum(cart);
total = _applyCampaign(total, cart, now);
total = _applyCoupon(total, user.coupon);
total = _applyTax(total, user.region);
return total;
}
この時、先述のplaceOrderメソッドのテストを書くために
「キャンペーン対象商品を含み、かつ有効なクーポンを持ち、かつ特定の住所に住んでいるユーザー」
これをセットアップしなければなりません。
これはテストコードを肥大化させ、本来検証したい計算ロジックを不透明にします。
【After】独立した「深いモジュール」への昇格
これを解消するために、上記_caluculateTotalメソッドと言う内部パーツを独立したクラス(新しい深いモジュール)へ昇格させます。
手順1:新クラス PricingEngine の抽出
計算ロジックそのものを独立したモジュールとして定義し、それ自体を「深い」構造にします。
class PricingEngine {
// このメソッド自体が新しい「深い窓口」になる
int calculate(Cart cart, User user, DateTime now) {
final base = _sum(cart);
final discounted = _applyDiscounts(base, user, now);
return _applyTax(discounted, user.region);
}
// 内部の整理は引き続き5行ルールのプライベートメソッドで行う
int _applyDiscounts(...) => ...;
int _applyTax(...) => ...;
}
手順2:OrderService からの委譲
OrderService は、複雑な計算の責任を新しいモジュールに任せるだけになります。
class OrderService {
final PricingEngine _pricingEngine;
Future<void> placeOrder(User user, List<OrderItem> items) async {
final cart = Cart(items);
await _handleInventory(cart);
// 複雑な詳細は PricingEngine へ委譲。OrderService の関心事から外れる。
final total = _pricingEngine.calculate(cart, user, DateTime.now());
await _executePayment(user, total);
await _persistOrder(user, cart, total);
}
}
テストに関する利点は後述いたしますが、
こうすることで、新規の機能追加の際に既存の振る舞いを変えずしてそれを追加することが出来るようにもなるので、
プロダクトコードの拡張性や変更容易性を高めることが出来るかと思われます。
昇格後のテストの変化
これによりテストに以下のような変化が生まれます。
-
PricingEngineの網羅テスト: 計算ロジックに特化し、境界値や複雑な組み合わせを直接、徹底的に検証できます -
OrderServiceの疎通テスト: 「計算機が正しく呼ばれているか」と「その結果が決済・保存に正しく回されているか」という、モジュール間の連携(インタラクション)の検証に集中できます
つまり、モジュールが膨らんできて見通しが悪くなってきたら、
それを単一責任に基づいて責務を割り当てたクラスを作り、
そのクラスに効能をカプセル化させることで、
それらモジュールごとの関心事に基づいて自動テストコードやテストケースを設計することが出来るかと思われます。
自動テストを開発者のための仕様書として使っていくためにも、
適切に責務分化されたモジュール、
今回の場合だと膨らんだプライベートメソッドをクラスに昇格させることで、
ここの振る舞いそのものを保証しつつ、
その可読性とメンテナンス性を確保することが出来るかと思われます。
まとめ
自浄作用を持つ設計システム
「深いモジュール」という戦略を立て、
その内部を「5行ルール」という戦術で整理し、
境界線での「振る舞いのテスト」で保護する。
この三位一体のアプローチは、コードの認知負荷を下げ、高いリファクタリング耐性をもたらしてくれるのではないかと思われます。
もし「テストが書きにくい」という苦痛を感じたなら、
それはプライベートメソッドを無理にテストする手法を探すべき時ではないかと思われます。
それは、モジュールの責任が限界に達したことを示すシグナルであり、
モジュール設計を改めて新たな境界線を引き直すべきタイミングなのだと思われます。
設計原則を静的なルールとしてではなく、テストのしやすさを指標に動的に構造を変化させる指針として捉えること。
これは、長期的な進化に耐えうるソフトウェアを構築するためにも、本質的に求められ得る姿勢なのかと感じます。
参考