3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Flutter】実践で学ぶClean Architecture + CQRS - 個人開発アプリで得た設計ノウハウ

Posted at

はじめに

「状態管理、結局どうすればいいの?」

この問い、Flutterを触り始めた頃からずっと頭にあった。Provider、Riverpod、BLoC...選択肢は山ほどある。ググれば「これがベスト」という記事がいくらでも出てくる。どれを選んでも、動くには動く。でも「これでいいの?」がずっと消えない。

機能を追加するたびにコードがこんがらがっていく。バグを直したと思ったら別の場所が壊れる。3ヶ月前の自分が書いたコードが読めない。あるあるだと思う。

フリーランス向け業務管理アプリ「Freelance One」を作る中で、ようやく自分なりの答えが見つかった。Clean Architecture + CQRS という構成だ。

「理論はわかったけど、実際どう書くの?」という疑問に答えたくて、この記事を書いた。動いているコードをベースに「なぜこう書くのか」を解説していく。

この記事でわかること

  • 4層Clean Architectureの実践的な構成方法
  • CQRSによるQuery(読み取り)/Command(書き込み)分離の意図
  • 各レイヤーの責務と、守るべきルール
  • 実際にハマった「よくあるミス」とその回避策

対象読者

  • Flutterの基礎(StatefulWidget、基本的なウィジェット)がわかる
  • 「このままじゃコードがスパゲッティになる...」と薄々感じている
  • Clean Architectureの図は見たことあるけど、実装イメージが湧かない

すでにクリーンアーキテクチャで大規模開発してます、という人には物足りないかも。その場合はコメントで「うちではこうしてる」を教えてもらえると嬉しい。

使用ライブラリ

先に主要なライブラリを紹介しておく。

ライブラリ 役割
Riverpod 3.0 状態管理。AsyncNotifierでデータ取得を宣言的に書ける
Freezed イミュータブルなデータクラスを自動生成。copyWith()==が使える
Drift SQLiteのラッパー。型安全なクエリが書ける

特にFreezedは必須。@freezedをつけるだけで、データクラスの比較やコピーが楽になる。

全体構成

いきなり詳細に入っても迷子になるので、先に全体像。「ふーん」くらいで流してOK。

依存の方向は常に内側へ。これがClean Architectureの基本ルール。

  • Presentation → Application → Domain ← Infrastructure
  • Domain層は他のどこにも依存しない(純粋なDart)
  • Infrastructure層はDomain層のインターフェースを実装する

ディレクトリ構成

実際のディレクトリはこんな感じ。

lib/src/
├── core/                   # 共通ユーティリティ
│   ├── exceptions/         # DomainException
│   └── result/             # Result<S, F>型
│
├── domain/                 # ビジネスロジック(純粋Dart)
│   ├── entities/           # Entity(Freezed)
│   ├── repositories/       # Repositoryインターフェース
│   └── services/           # ドメインサービス
│
├── application/            # ユースケース(CQRS)
│   └── usecases/{機能}/
│       ├── commands/       # 書き込み操作
│       ├── queries/        # 読み取り操作
│       └── dtos/           # データ転送オブジェクト
│
├── infrastructure/         # データアクセス
│   ├── datasources/        # Drift DB
│   ├── mappers/            # Entity ⇔ DB変換
│   └── repositories/       # Repository実装
│
└── presentation/           # UI(MVVM)
    └── features/{機能}/
        ├── pages/          # 画面
        ├── providers/      # ViewModel
        └── state/          # UI状態

各レイヤーの責務と実装例

Domain層:ビジネスルールを書く場所

Domain層には「ビジネスルール」だけを書く。Flutterにも、データベースにも依存しない、純粋なDartコード。

なんで純粋Dartにこだわるかというと、テストが楽になるから。Flutterのテスト環境を立ち上げなくても、dart test一発でロジックを検証できる。これ地味に大きい。

Entity設計:Freezed + create()パターン

@freezed
abstract class Contract with _$Contract {
  const factory Contract({
    required String id,
    required String projectId,
    required DateTime startDate,      // 契約開始日
    required DateTime endDate,        // 契約終了日
    required int unitPrice,
    required PriceType priceType,     // hourly or monthly
    int? lowerLimitHours,             // 精算幅下限(月額制のみ)
    int? upperLimitHours,             // 精算幅上限(月額制のみ)
    required DateTime createdAt,
    required DateTime updatedAt,
  }) = _Contract;
  const Contract._();

  /// 新規作成時はこのファクトリを使う
  static Contract create({
    required String projectId,
    required DateTime startDate,
    required DateTime endDate,
    required int unitPrice,
    required PriceType priceType,
    int? lowerLimitHours,
    int? upperLimitHours,
  }) {
    final now = DateTime.now();
    final contract = Contract(
      id: const Uuid().v4(),  // UUIDを自動生成
      projectId: projectId,
      startDate: startDate,
      endDate: endDate,
      unitPrice: unitPrice,
      priceType: priceType,
      lowerLimitHours: lowerLimitHours,
      upperLimitHours: upperLimitHours,
      createdAt: now,
      updatedAt: now,
    );

    contract.validate();  // 作成直後にバリデーション
    return contract;
  }

  /// ビジネスルールをここに集約
  void validate() {
    // 契約期間の整合性チェック
    if (endDate.isBefore(startDate)) {
      throw const DomainException.validation(
        '契約終了日は開始日より後でなければなりません'
      );
    }

    // 月額制の場合、精算幅の整合性をチェック
    if (priceType == PriceType.monthly) {
      if ((lowerLimitHours == null) != (upperLimitHours == null)) {
        throw const DomainException.validation(
          '精算幅の下限と上限は両方設定するか、両方未設定にしてください'
        );
      }
    }
  }

  /// 稼働時間から請求額を計算(ドメインロジック)
  Money calculateBillingAmount(Duration actualWorkDuration) {
    switch (priceType) {
      case PriceType.hourly:
        return _calculateHourlyBilling(actualWorkDuration);
      case PriceType.monthly:
        return _calculateMonthlyBilling(actualWorkDuration);
    }
  }
}

ポイント

  • create()ファクトリでUUIDと日時を自動設定
  • 作成直後にvalidate()を呼び、不正なエンティティの存在を許さない
  • 請求額計算などのビジネスロジックもEntityに持たせる

このパターンの良いところは、**「不正なEntityが存在しえない」**ことを型レベルで保証できる点だ。

要は、Domain層は「テストのためにFlutter環境が要らない」のがポイント。

Application層:CQRSで読み書きを分離

CQRSは「Command Query Responsibility Segregation」の略。読み取りと書き込みを分けるパターン。

正直、最初は「わざわざ分ける意味ある?」と思ってた。でもやってみると、読み取りは「データ取ってくるだけ」で済むのに対して、書き込みは「バリデーション → ビジネスルールチェック → 保存 → ...」と、やることが全然違う。1つのクラスに全部入れてたらContractServiceが500行超えて死んだ。分けてからは見通しがだいぶマシになった。

Commandの例:契約登録UseCase

class InsertContractUsecase {
  InsertContractUsecase({
    required ContractRepository contractRepository,
    required ContractValidationService contractValidationService,
  }) : _contractRepository = contractRepository,
       _contractValidationService = contractValidationService;

  final ContractRepository _contractRepository;  // Domain層のインターフェース
  final ContractValidationService _contractValidationService;

  Future<Result<String, DomainException>> execute(
    InsertContractCommand command,
  ) {
    return UsecaseErrorHandler.execute(
      errorMessage: '契約の登録に失敗しました',
      operation: () async {
        // 1. 日付を正規化
        final normalizedStartDate = command.startDate.startOfDay;
        final normalizedEndDate = command.endDate.startOfDay;

        // 2. 契約期間の重複チェック(ドメインサービス)
        await _contractValidationService.validateContractPeriodOverlap(
          projectId: command.projectId,
          startDate: normalizedStartDate,
          endDate: normalizedEndDate,
        );

        // 3. Entityを生成(ここでバリデーションも実行される)
        final newContract = Contract.create(
          projectId: command.projectId,
          startDate: normalizedStartDate,
          endDate: normalizedEndDate,
          unitPrice: command.unitPrice,
          priceType: command.priceType,
          // ...
        );

        // 4. 永続化
        final saveResult = await _contractRepository.save(newContract);
        return switch (saveResult) {
          Success() => Result.success(newContract.id),
          Failure(:final error) => Result.failure(error),
        };
      },
    );
  }
}

ポイント

  • 戻り値はResult<T, DomainException>
  • 成功/失敗を型で表現し、nullチェック地獄から解放
  • UseCaseは「Domain層のインターフェース」にのみ依存

Result型について: このResult<S, F>は、Dart 3.0のsealed classを使って独自定義している。switch文で網羅性チェックが効くので、「成功時の処理は書いたけど失敗時を忘れた」みたいなミスをコンパイル時に検出できる。

Infrastructure層:技術的な泥臭いところ

Infrastructure層はDomain層のインターフェースを実装する場所。Drift(SQLite)でDB操作したり、APIを叩いたり、ファイルを読み書きしたり。Flutterの外側とやりとりするコード全部がここ。

class ContractRepositoryImpl implements ContractRepository {
  ContractRepositoryImpl(this._database);

  final AppDatabase _database;

  @override
  Future<Result<void, DomainException>> save(Contract contract) async {
    try {
      final data = ContractMapper.toData(contract);
      await _database.into(_database.contracts).insertOnConflictUpdate(data);
      return const Result.success(null);
    } on SqliteException catch (e) {
      return Result.failure(
        DomainException.infrastructure('保存に失敗しました', originalError: e),
      );
    }
  }
}

Mapperの役割:EntityとDBテーブルの変換を担当。なぜInfrastructure層に置くかというと、「DBのカラム名」「テーブル構造」といった実装詳細を知っているのはこの層だけだから。将来DBをDriftからHiveに変えても、Mapperとリポジトリ実装を差し替えるだけで済む。

class ContractMapper {
  static ContractData toData(Contract entity) {
    return ContractData(
      id: entity.id,
      projectId: entity.projectId,
      unitPrice: entity.unitPrice,
      // ...
    );
  }

  static Contract toEntity(ContractData data) {
    return Contract(
      id: data.id,
      projectId: data.projectId,
      unitPrice: data.unitPrice,
      // ...
    );
  }
}

Presentation層:ViewModelとUI状態の分離

ここが一番ハマったところ。Riverpod 3.0のAsyncNotifierを使うんだけど、最初は「データ」と「UI状態」を同じNotifierに突っ込んでいた。

何が問題かというと、「保存中」フラグを更新するだけで、ビジネスデータまで再計算される。無駄。分けたほうがスッキリする。

// UI状態専用(保存中フラグ、変更有無など)
@Riverpod(keepAlive: false)
class DealEditUiState extends _$DealEditUiState {
  @override
  DealEditUiStateData build() => const DealEditUiStateData();

  void setSaving(bool isSaving, {String? error}) {
    state = state.copyWith(isSaving: isSaving, errorMessage: error);
  }
}

// データViewModel(ビジネスデータの管理)
@Riverpod(keepAlive: false)
class DealEditViewModel extends _$DealEditViewModel {
  late DealDto _initialDeal;  // 変更検知用に初期値を保持

  @override
  Future<DealEditData> build({String? dealId}) async {
    // Query を実行してデータを取得
    final agentsResult = await ref.read(getAgentsQueryProvider).execute();
    final agents = agentsResult.fold(
      onSuccess: (value) => value,
      onFailure: (error) => throw error,  // ★ここでthrowする
    );

    if (dealId != null) {
      final dealResult = await ref.read(getDealQueryProvider).execute(dealId);
      final deal = dealResult.fold(
        onSuccess: (value) => value ?? throw const DomainException.notFound('Deal', ''),
        onFailure: (error) => throw error,  // ★エラーは必ずthrow
      );
      _initialDeal = DealDto.fromDomain(deal);
      return DealEditData(deal: _initialDeal, agents: agents);
    }
    // ...
  }

  /// フィールド変更時
  void onTitleChanged(String value) {
    _updateDealField(
      (deal) => deal.copyWith(title: value),
      fieldName: 'title',
    );
  }

  void _updateDealField(DealDto Function(DealDto) mapper, {String? fieldName}) {
    state.whenData((data) {
      final updated = mapper(data.deal);
      final hasChanged = updated != _initialDeal;  // Freezedの==で比較
      state = AsyncValue.data(data.copyWith(deal: updated));

      // UI状態を更新
      ref.read(dealEditUiStateProvider.notifier).state = ref
          .read(dealEditUiStateProvider)
          .copyWith(hasChanges: hasChanged);
    });
  }
}

なぜ2つに分けるのか?

  • isSavingが変わるたびにビジネスデータを再取得する必要はない
  • データとUIの関心を分離することで、各クラスの責務が明確になる
  • テスト時にUIロジックとデータロジックを個別に検証できる

この分離、最初は面倒に感じたけど、慣れると「保存中フラグを変えただけでデータ再取得」みたいな事故が減る。

よくあるミスと回避策(3選)

ここからは実際にやらかしたミスを紹介する。検索しても意外と出てこなくて苦労したやつ。同じ罠にハマる人が減れば嬉しい。

ミス1:ViewModelのbuild()でexecute()の結果をwatch()する

これ、最初マジでわからなかった。画面が固まって、デバッグしてもエラーが出ない。

// ❌ ダメなパターン
@override
Future<Data> build() async {
  final result = ref.watch(someQueryProvider).execute();  // execute()をwatch!
  return result;
}

問題は「副作用のあるメソッド(execute()など)の結果をwatch」していること。watch()は購読を作成するので、状態が変わるたびにbuild()が再実行される。データ取得のような一回限りの非同期処理でwatch()を使うと、予期しない再ビルドが発生する可能性がある。

正解

基本的にRiverpodではbuild()内でref.watch()を使うことが推奨されている。ただし、execute()のような**「副作用を伴う非同期処理」や「毎回走ってほしくない処理」**をwatchしてしまうと、意図しないタイミングで再実行される事故が起きる。

ここでは意図を明確にするため、あえてread()を使って「一度だけ実行する」ことを明示している。

// ✅ 正解(一回限りの非同期操作の場合)
@override
Future<Data> build() async {
  final result = await ref.read(someQueryProvider).execute();  // read + await!
  return result.fold(
    onSuccess: (data) => data,
    onFailure: (error) => throw error,  // throwすること!
  );
}

補足: プロバイダー自体の変更を検知したい場合は、適切なハンドリングが必要。RepositoryなどのDIプロバイダーをwatch()するのは問題ない。

ミス2:build()でエラーを握りつぶす

「エラーが起きたら空のデータを返せばいいや」と思ってやったやつ。

// ❌ ダメ
@override
Future<Data> build() async {
  final result = await ref.read(queryProvider).execute();
  return result.fold(
    onSuccess: (data) => data,
    onFailure: (error) => Data.empty(),  // エラーを無視して空データを返す
  );
}

一見動くんだけど、エラーが起きても空データが表示されるだけ。ユーザーからしたら「なんかデータがない...バグ?」となる。原因究明も困難。

正解

// ✅ 正解
onFailure: (error) => throw error,  // エラーはthrowする!

throwすればAsyncValue.error状態になる。UI側ではref.watch(viewModelProvider).when(error: ...)で確実にエラー画面に遷移するので、ユーザーに「何かがおかしい」ことを伝えられる。握りつぶすと、この仕組みが全く機能しなくなる。

ミス3:hasChangesを追跡しない

これは本番リリース後に気づいた。ユーザーさんから「編集中に戻ったらデータ消えた」と報告が来た。

// ❌ ダメ
void onTitleChanged(String value) {
  state.whenData((data) {
    state = AsyncValue.data(data.copyWith(title: value));
    // hasChangesを更新していない!
  });
}

フォームを編集して戻るボタン → そのまま前の画面に戻る → データ消失。「保存しますか?」のダイアログを出すには、「変更があるか」を追跡しないといけない。

正解

// ✅ 正解
void onTitleChanged(String value) {
  state.whenData((data) {
    final updated = data.copyWith(title: value);
    final hasChanged = updated != _initialDeal;  // Freezedの==で比較
    state = AsyncValue.data(updated);

    ref.read(uiStateProvider.notifier).state =
        ref.read(uiStateProvider).copyWith(hasChanges: hasChanged);
  });
}

Freezedを使っていれば、==演算子が自動実装されるので、初期値との比較が簡単にできる。

この設計で得られたメリット

実際に使ってみて、良かったこと。

1. テストが書きやすい

Domain層は純粋Dartなので、dart test一発で実行できる。Flutterのテスト環境を立ち上げる必要がない。請求額計算とか、ロジックが複雑なところをガッツリテストできるのは安心感がある。

2. 変更の影響範囲がわかりやすい

「DBをDriftからHiveに変えたい」みたいな大改修でも、修正はInfrastructure層だけ。DomainもPresentationも触らなくていい。実際、DB周りの処理を書き直したとき、他のレイヤーはノータッチで済んで助かった。

3. 将来チーム開発に移行しやすい

今は個人開発だけど、いずれ人を巻き込むかもしれない。レイヤーごとに責務が決まっているので、「Domain層はAさん」「UI層はBさん」みたいに分業しやすい。コードレビューでも「これはDomain層に置くべきでは?」といった議論がしやすくなる。

まとめ

守るべきルール

  1. 依存は内側へ:Presentation → Application → Domain ← Infrastructure
  2. CQRSで読み書きを分離:Queryはシンプルに、Commandはバリデーション込み
  3. ViewModelではread()を使う:一回限りの非同期処理はwatchしない
  4. エラーはthrowする:握りつぶすとデバッグ地獄に落ちる
  5. hasChangesを追跡する:ユーザーのデータを守れ

正直、最初から完璧にできたわけじゃない。何度もリファクタして、今の形に落ち着いた。「なぜこう書くのか」がわかっていれば、自分のプロジェクトに合わせてアレンジもできるはず。

「うちではこうしてる」とか「ここ違くない?」があったらコメントかXで教えてほしい。


宣伝

この記事で紹介したパターンは、フリーランス向け業務管理アプリ「Freelance One」で実際に使っている。

稼働記録、契約管理、請求書作成など、フリーランスエンジニアに必要な機能を1つにまとめたアプリ。よかったら触ってみてほしい。

freelance_one.png

📱 App Store: https://link.nekoder.com/freelance-one-appstore

記事の感想や「うちではこうしてる」があれば、Xで教えてもらえると嬉しい。

𝕏 (旧Twitter): @HaruyaNekoder


参考文献

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?