【実務・中級編】Dartのパターンマッチングで構築する「コマンドパターン」のクリーンな実装 – Dart コア文法・オブジェクト指向・Null安全解析バイブル

こんにちは。プロダカル・アーキテクトの私だ。
今日のコードレビューで、また「ただ動くだけのオブジェクト指向モジュール」を見かけた。無駄なインターフェースの乱立、細分化されすぎてコンテキストを見失ったサブクラス群、そして実行時まで型安全性が担保されないポリモーフィズムの亡霊たち――。

Webフロントエンド開発、あるいは複雑な非同期API連携を伴うアプリケーション設計において、状態の遷移やユーザーアクションのディスパッチをどう表現するかは、システムの寿命を決定づける死活問題だ。

オブジェクト指向の古典的な「コマンドパターン」は、拡張性に優れる一方で、ボイラープレート(定型コード)の山を生み出しがちだ。しかし、Dart 3で導入された `sealed class` と 網羅的な(Exhaustive)`switch`式 を組み合わせれば、この古典的パターンを「宣言的かつ完全に型安全」な現代のアーキテクチャへと昇華させることができる。

今日はその極限の知見を授けよう。

—

なぜ従来のオブジェクト指向コマンドパターンは破綻するのか?

古典的なコマンドパターンを思い出してほしい。`Command` という抽象インターフェースがあり、それを実装する `FetchUserCommand`、`UpdateSettingsCommand`、`DeleteAccountCommand` が何十個も別ファイルのクラスとして定義される。

// 昔ながらの冗長な実装(※これはアンチパターンだ)
abstract interface class Command {
Future execute();
}

class FetchUserCommand implements Command {
final String userId;
FetchUserCommand(this.userId);
@override
Future execute() async { / … / }
}

このアプローチには、Dart VMの実行モデルや保守性の観点からいくつかの致命的な欠陥がある。

1. コンテキストの分断: コマンドのデータ(ペイロード)と、それを処理するビジネスロジックが別々のクラスに分散し、コードベースの「見通し(Locality of Behavior)」が最悪になる。
2. 網羅性の欠如: 新しいコマンドを追加した際、それをハンドリングする側の `switch` や `if-else` に `default` 句を書いていると、コンパイラが未実装のケースを検知してくれない(非網羅的エラーの欠如)。結果、実行時エラーの温床になる。
3. 不要なヒープ割り当て: 細かいクラスインスタンスが乱立し、DartのジェネレーションGC(ガベージコレクション)に無駄な負荷をかける。

Dart 3の代数的データ型(ADT)的アプローチを使えば、これらを一刀両断できる。

—

Dart 3で構築する「モダン・コマンドパターン」

現代のDartにおいて、コマンドとは「データ(状態)」であり、それを解釈して実行する処理は「純粋な関数(あるいは網羅的なswitch)」であるべきだ。

以下のプロダクションコードを見てほしい。非同期API連携、UIの状態管理、エラーハンドリングのディスパッチを、`sealed class` と `switch` 式だけで美しく、かつ堅牢にカプセル化している。

import ‘dart:async’;

/// 1. コマンドの全バリエーションを定義する Sealed Hierarchy
/// 異なるペイロードを持つバリアントを安全に定義できる。
sealed class UICommand {
const UICommand();
}

class LoadUserProfile extends UICommand {
final String userId;
const LoadUserProfile(this.userId);
}

class UpdateUserEmail extends UICommand {
final String userId;
final String newEmail;
const UpdateUserEmail(this.userId, this.newEmail);
}

class LogoutSession extends UICommand {
const LogoutSession();
}

/// 2. アプリケーション層の依存関係(APIクライアントなど)
class ApiClient {
Future fetchProfile(String id) async {
// ネットワーク遅延のシミュレーション
await Future.delayed(const Duration(milliseconds: 300));
return ‘User(id: $id, email: “developer@dart.dev”)’;
}

Future updateEmail(String id, String email) async {
await Future.delayed(const Duration(milliseconds: 300));
print(‘[API] Updated $id email to $email’);
}

Future terminate() async {
print(‘[API] Session terminated.’);
}
}

/// 3. コマンドディスパッチャ(コマンドプロセッサ)
/// Dart 3の switch式 による網羅的(Exhaustive)なパターンマッチングを活用。
class CommandDispatcher {
final ApiClient _apiClient;

CommandDispatcher(this._apiClient);

/// コマンドを受け取り、コンパイル時保証された安全なルーティングを実行する
Future dispatch(UICommand command) async {
// switch『式』であることに注目。値を直接returnできる。
return switch (command) {
// パターンマッチングと同時にプロパティの destructuring(分解)を行う
LoadUserProfile(:final userId) => await _handleLoadUser(userId),

UpdateUserEmail(:final userId, :final newEmail) => await _handleUpdateEmail(userId, newEmail),

LogoutSession() => await _handleLogout(),
};
// 【重要】もしここで LogoutSession の処理を書き忘れた場合、
// Dartコンパイラは “The switch statement does not cover all cases” と
// コンパイルエラーを吐き出す。これが最大の強みだ。
}

Future _handleLoadUser(String userId) async {
print(‘[Command] Loading user: $userId’);
return await _apiClient.fetchProfile(userId);
}

Future _handleUpdateEmail(String userId, String email) async {
if (!email.contains(‘@’)) {
throw ArgumentError(‘Invalid email format: $email’);
}
await _apiClient.updateEmail(userId, email);
return ‘Email updated successfully.’;
}

Future _handleLogout() async {
await _apiClient.terminate();
return ‘Logged out.’;
}
}

/// 4. 実行エントリポイント
void main() async {
final apiClient = ApiClient();
final dispatcher = CommandDispatcher(apiClient);

// テストケースの実行
final commands = [
const LoadUserProfile(‘usr_001’),
const UpdateUserEmail(‘usr_001’, ‘new_architect@dart.dev’),
const LogoutSession(),
];

for (final cmd in commands) {
try {
// コンパイル時に型安全性が保証された状態で非同期処理が流れる
final result = await dispatcher.dispatch(cmd);
print(‘Result -> $result\n’);
} catch (e) {
print(‘Error executing command: $e\n’);
}
}
}

—

この設計がプロダクションで最強である理由(チーフアーキテクトの解説)

1. コンパイル時網羅性(Exhaustiveness)による「バグのコンパイルアウト」

これがこのパターンの最大の価値だ。将来、仕様変更で `DeleteAccount` という新しいコマンドを追加したとする。
従来の `if-else` や `default` 句に頼ったコードであれば、ハンドラー側の実装をうっかり忘れても実行時(しかもユーザーの手元)までバグが潜伏した。

しかし、Dart 3の `switch` 式は、`sealed class` のサブタイプをすべて網羅しているかをAOTコンパイラが静的解析する。ハンドラーへの実装漏れはコンパイルエラーとして即座に開発者のIDEに赤波線で通知される。人的ミスが入り込む余地を言語仕様レベルで断絶しているのだ。

2. オブジェクト分解(Destructuring)の美しさ

`LoadUserProfile(:final userId)` という構文を見てほしい。
これはただの型チェックではなく、パターンマッチングと同時にオブジェクト内部のプロパティを変数として抽出(Destructuring)している。余計なキャスト(`as LoadUserProfile`)やボイラープレートなgetterアクセスを完全に排除し、Cognitive Load(認負荷)を極限まで下げている。

3. 副作用の局所化とテスタビリティ

コマンド(データ)とディスパッチ(ロジック)が明確に分離されているため、ビジネスロジックの単体テスト(Unit Test)が極めて容易になる。
`CommandDispatcher` にモックの `ApiClient` を注入し、様々な `UICommand` を流し込むだけで、API連携やエラーハンドリングのパスを100%網羅したテストが書ける。

—

パフォーマンス上の注意点(Dart VMの裏側)

アーキテクチャを美しく保つ一方で、パフォーマンスへの配慮も忘れてはならない。

  • const コンストラクタの徹底:

コマンドクラスは原則として `const` コンストラクタを持つべきだ。UI層からディスパッチャへコマンドを渡す際に `const LoadUserProfile(‘123’)` と記述すれば、Dartの定数プールにキャッシュされ、ヒープアロケーション(メモリ割り当て)がゼロになる。GCのストップ・ザ・ワールドを懸念する高頻度なイベント駆動システム(Flutterのレンダリングパイプラインやリアルタイム通信など)において、この最適化は非常に効いてくる。

  • JIT/AOTコンパイルの最適化:

`sealed class` に対する `switch` 式は、Dart VM(JIT)および AOTコンパイラによって「ジャンプテーブル(Jump Table)」や効率的なインラインキャッシュに最適化される。深すぎる継承ツリーや `is` チェックの連続よりも圧倒的に高速にディスパッチされるため、パフォーマンスの懸念はない。

—

結びにかえて

設計の美しさは、コードの行数の少なさではなく、「変更容易性と堅牢性がどこまで高次元で両立しているか」で決まる。

明日からのコードレビューで、もしあなたが冗長なオブジェクト指向のコマンドクラスの山を見かけたら、こう問いかけてほしい。
「おい、それ、Dart 3の `sealed` と `switch` でもっとクリーンに書けるんじゃないか?」と。

言語の仕様を骨の髄まで理解し、道具に踊らされるのではなく、道具を支配するエンジニアであれ。健闘を祈る。

タイトルとURLをコピーしました