層ごとにprovider置いたら参照違反になるの、なんでなん?💔

層ごとにprovider置いたら参照違反になるの、なんでなん?💔

これ絶対あるじゃん??これ絶対あるじゃん??

  • Clean Architecture + MVVMでフォルダ分けたのに、UseCaseProviderRepositoryProviderをimportしたら「application層からinfrastructure層への参照違反」って言われた
  • 仕方なくprovider定義をdomain層に移したら、今度は「domain層がRiverpodに依存してる」って言われた
  • 「じゃあどこに置けばいいの??」ってなって全部lib/直下にぶち込んだ
  • アーキテクチャ図はキレイなのに、importの矢印がぐちゃぐちゃ

これがCross-layer Provider Definition(provider定義の層またぎ)、以降「配線バラまき」って呼ぶね、が原因なんだよね。


てかシンプルに言うとねてかシンプルに言うとね

provider(配線コード)を各層のフォルダの中に直接置いちゃったから、層の壁をぶち破るimportができちゃう。

「それの何がまずいの? 動いてるし」ってなる気持ち、わかる!
でもそのimport1本のせいで「層に分けた意味が全部消えてる」話、ちゃんとするね。


ちょっと待って、先に全然関係ない話していい?🏠ちょっと待って、先に全然関係ない話していい?🏠

会社の話するね。

「みらい商事」っていう中堅の商社があったんだけどさ。ここ、部署間のルールがめっちゃ厳しかったの。

「部署同士は直接やり取りしない。必ず受付を通す。」

営業部は倉庫部の中身を知らないし、倉庫部は経理部のやり方を知らない。それぞれバラバラに動けるようにするためのルールなんだよね。

ただ、連絡先の管理でやらかしたの。

各部署が、他の部署の担当者の内線番号とか席の場所を、自分の部署のホワイトボードに直接書いてたんだよね。

営業部のホワイトボード:

  • 「在庫確認 → 倉庫部の山田さん、2F奥の席、内線2345」
  • 「商品スペック → 企画部の佐藤さん、3F会議室横、内線3456」

ある日、総務部長がこう言ったの。

「来月から倉庫部は4Fに移動します。内線番号も変わります。」

営業部の田中さんが真っ先に気づいた。

「え、うちのホワイトボードに『倉庫部は2F、内線2345』って書いてあるんだけど……」

田中さんは内線2345にかけた。繋がらない。席が変わって番号も変わったらしい。

「じゃあ新しい番号、どこで確認すんの?」

倉庫部に直接行って、向こうのホワイトボードを見るしかなかった。

ちょっと待って? 「部署同士は直接やり取りしない」ってルールだったよね? なのに新しい番号を知るために、倉庫部の中に入り込んでるじゃん。

ルール、ぶっ壊れてるんだよね。

しかもこれ、営業部だけの問題じゃなくて。経理部も、カスタマーサポートも、全部自分とこのホワイトボードに「倉庫部は2F」って書いてたの。移動のたびに全部署のホワイトボードを書き直すハメになったんだよね。

3日後、サポート部がお客さんに「在庫あります」って古い情報で回答しちゃってクレームが来た。経理は1週間後にやっと気づいた。

「連絡先を各部署のホワイトボードに書いてたから、こうなったんだ……」

これが本番で起きてるやつが、配線バラまきってこと。


じゃあコードで見てみよっか👀じゃあコードで見てみよっか👀

Flutter + Riverpodで、Clean Architecture + MVVMをやってるプロジェクトを想像してみて。

// ❌ 各層にprovider定義を置いちゃったやつ

// ===== domain層 =====
// lib/domain/repositories/user_repository.dart
abstract class UserRepository {
  Future<User> findById(String id);
}

// ===== infrastructure層 =====
// lib/infrastructure/repositories/user_repository_impl.dart
class UserRepositoryImpl implements UserRepository {
  @override
  Future<User> findById(String id) async {
    final doc = await FirebaseFirestore.instance
        .collection('users').doc(id).get();
    return User.fromJson(doc.data()!);
  }
}

// lib/infrastructure/providers/user_repository_provider.dart
// ↓ infrastructure層にprovider定義を置いた
final userRepositoryProvider = Provider<UserRepository>((ref) {
  return UserRepositoryImpl();
});

// ===== application層 =====
// lib/application/usecases/get_user_usecase.dart
class GetUserUseCase {
  final UserRepository repository;
  GetUserUseCase(this.repository);
  Future<User> execute(String id) => repository.findById(id);
}

// lib/application/providers/get_user_usecase_provider.dart
import '../../infrastructure/providers/user_repository_provider.dart';
// ↑ ここ!application層からinfrastructure層をimportしてる!

final getUserUseCaseProvider = Provider<GetUserUseCase>((ref) {
  final repo = ref.watch(userRepositoryProvider);
  return GetUserUseCase(repo);
});

見えた? application/providers/からinfrastructure/providers/へのimportが発生してるじゃん。

application層(ビジネスの判断をする場所)が、infrastructure層(FirestoreとかDBの実装がある場所)の中身を知っちゃってるの。

さっきのみらい商事でいうと、営業部のホワイトボードに「倉庫部の山田さん、2F、内線2345」って書いてある状態。連絡先を知るために、相手の部署に入り込んでるんだよね。

「いやproviderを層のフォルダに置くのが自然じゃない?」って思うよね。わたしも最初そう思った。でもこの「自然」が罠なんだよね。


やばいのまだあんだけど、バグに気づけない問題😭やばいのまだあんだけど、バグに気づけない問題😭

配線バラまきの怖さって「参照違反になる」だけじゃなくて、**「正しいか確認する方法まで壊れてる」**のがほんとにやばいの。

さっきのみらい商事で考えてみて? 各部署のホワイトボードが正しいか、どうやって確認する?

  • 営業部のボード → 倉庫部に行って照合
  • 経理部のボード → 倉庫部に行って照合
  • サポート部のボード → 倉庫部に行って照合

確認するためにルールを破らなきゃいけないじゃん。ちゃんとなってるか調べたいだけなのに、調べるにはルール破るしかないの。もう詰んでるんだよね。

コードでも同じなんだよね。

// テストでrepositoryを差し替えたい
// でもprovider定義がinfrastructure層にあるから...

// test/application/get_user_usecase_test.dart
import '../../infrastructure/providers/user_repository_provider.dart';
// ↑ テストコードまでinfrastructure層に依存してる

void main() {
  test('ユーザーが見つかる', () {
    final container = ProviderContainer(
      overrides: [
        // infrastructure層のproviderをoverrideしてるから
        // infrastructureの中身が変わるとテストのimportまで壊れる
        userRepositoryProvider.overrideWithValue(MockUserRepository()),
      ],
    );
  });
}

FirestoreからSupabaseに切り替えたとする。infrastructure/providers/のファイル名もクラス名も変わるよね。そしたらテストファイルのimportも全部書き直しじゃん。

配線バラまきは壊れやすいだけじゃなく、テストでも層のルールを破る設計ってこと。


分けてた会社の話もするね〜✨分けてた会社の話もするね〜✨

同じ業界に「あおぞら商事」ってとこがあったんだけどさ。部署間のルールは同じく厳しいんだけど、連絡先の管理がまるで違ったの。

1Fの受付に「社内連絡先ノート」が1冊だけある。 各部署のホワイトボードには、連絡先は一切書いてない。

営業部が在庫を確認したいときは、受付に「在庫確認の担当者につないで」って言うだけ。受付がノート見て「倉庫部の山田さんですね、おつなぎします」ってなる。

営業部は山田さんが2Fにいるか4Fにいるかを知らなくていいの。

倉庫部が4Fに移動した日。受付がディレクトリの1行を書き換えた。それだけ。

営業部のホワイトボード? 何も変わってない。
経理部? 何も変わってない。
サポート部? 何も変わってない。

誰もルールを破らずに、全部署が正しい相手に繋がり続けたんだって。


コードに戻すとこういうことなんだよね。ちょっと見てみて?

あおぞら商事の「受付の連絡先ノート」= Composition Root。providerの定義をどの層にも属さない1箇所にまとめるの。

// ✅ Composition Rootパターン

// ===== domain層(変わらない) =====
// lib/domain/repositories/user_repository.dart
abstract class UserRepository {
  Future<User> findById(String id);
}

// ===== application層(変わらない) =====
// lib/application/usecases/get_user_usecase.dart
class GetUserUseCase {
  final UserRepository repository;
  GetUserUseCase(this.repository);
  Future<User> execute(String id) => repository.findById(id);
}

// ===== infrastructure層(変わらない) =====
// lib/infrastructure/repositories/user_repository_impl.dart
class UserRepositoryImpl implements UserRepository {
  @override
  Future<User> findById(String id) async {
    final doc = await FirebaseFirestore.instance
        .collection('users').doc(id).get();
    return User.fromJson(doc.data()!);
  }
}

// ===== Composition Root(どの層にも属さない) =====
// lib/providers/injection.dart

import '../domain/repositories/user_repository.dart';
import '../infrastructure/repositories/user_repository_impl.dart';
import '../application/usecases/get_user_usecase.dart';

final userRepositoryProvider = Provider<UserRepository>((ref) {
  return UserRepositoryImpl();
});

final getUserUseCaseProvider = Provider<GetUserUseCase>((ref) {
  final repo = ref.watch(userRepositoryProvider);
  return GetUserUseCase(repo);
});

各層のフォルダにprovider定義は1個もないじゃん。lib/providers/injection.dartが「受付の連絡先ノート」として、全部の配線を1箇所で管理してるの。

どの層もComposition Rootをimportしてこないし、全ての層をimportしていいのはここだけなんだよね。だから参照違反にならない。

// テストもスッキリ

// test/application/get_user_usecase_test.dart
import '../../lib/providers/injection.dart';
// ↑ infrastructure層のimportは一切ない

void main() {
  test('ユーザーが見つかる', () {
    final container = ProviderContainer(
      overrides: [
        userRepositoryProvider.overrideWithValue(MockUserRepository()),
      ],
    );
    // FirestoreがSupabaseに変わっても、このテストは1行も変わらない
  });
}

で、実務だとどういうパターンがあるの?🌸

「Composition Rootはわかった。でも実際のプロジェクトだとどうすんの?」ってなるよね。よくある3パターンを紹介するね。


パターン1:小〜中規模(1パッケージ)

一番シンプルなやつ。lib/providers/に全provider定義をまとめるだけ。

lib/
  domain/          # インターフェース
  application/     # ユースケース
  infrastructure/  # 実装
  presentation/    # UI(ViewModelとView)
  providers/       # ← ここがComposition Root
    injection.dart # 全providerを定義

ファイルが大きくなってきたらinjection.dartを分割してもOK。providers/auth_providers.dartproviders/user_providers.dartみたいにね。場所はproviders/の中。層のフォルダには絶対入れない。


パターン2:大規模(マルチパッケージ)

パッケージを分けてる場合、各パッケージはクラスだけ外から使えるようにして、配線はアプリ本体でやるの。

packages/
  domain/          # UserRepositoryのインターフェースだけ公開
  application/     # GetUserUseCaseだけ公開
  infrastructure/  # UserRepositoryImplだけ公開
app/
  lib/
    providers/     # ← アプリ本体のComposition Root
      injection.dart

各パッケージは他のパッケージのproviderを知らない。繋ぎ込みはアプリ本体の仕事ってこと。


パターン3:feature-firstフォルダ構成

「層じゃなくて機能で分けてるんだけど?」ってケースもあるよね。

lib/
  features/
    auth/
      domain/
      application/
      infrastructure/
      presentation/
    user/
      domain/
      application/
      infrastructure/
      presentation/
  providers/       # ← feature横断のComposition Root
    auth_providers.dart
    user_providers.dart

feature内にproviderフォルダを置きたくなるんだけど、そうするとfeature間の配線で同じ参照違反が起きる。だからproviderはfeatureの外に出すのが正解なんだよね。


ちゃんとした名前もあるから一応言うね📚ちゃんとした名前もあるから一応言うね📚

Composition Root(コンポジションルート)とは、「誰が誰を使うか」の配線を全部1箇所にまとめた場所っつーこと。

Mark Seemannって人が2011年の『Dependency Injection in .NET』でちゃんとまとめたやつ。2019年の第2版で.NET以外にも使える話に広げてる。

難しそうに聞こえるけど、要は「providerの定義、どの層のフォルダにも入れないで1箇所にまとめろ」を問い続けるだけなんだよね。


これだけ覚えて帰って🥺これだけ覚えて帰って🥺

合言葉はこれ。hbng(配線バラまきNG)

provider定義を書くとき、これだけ問いかけてみて。

「このprovider、別の層のproviderをimportしてない?」

たとえばgetUserUseCaseProviderを書くとき——

このファイル、application/フォルダにあるよね? で、userRepositoryProviderをimportしてる。そのprovider、infrastructure/フォルダにあるよね?

application → infrastructureのimportしちゃってるじゃん。 これ、営業部のホワイトボードに倉庫部の内線番号を直接書いてるのと同じじゃん。

「このprovider定義、今いるフォルダの層より外側のproviderをimportしてる?」——答えがYESなら、そのprovider定義はComposition Rootに移動するべきっつーこと。


まとめるとこういうことねまとめるとこういうことね

観点配線バラまき(各層にprovider)Composition Root(1箇所にまとめる)
provider定義の場所application層やinfrastructure層のフォルダ内どの層にも属さない専用フォルダ
importの方向application → infrastructure(参照違反)Composition Root → 全層(唯一許可された方向)
層の実装を差し替えたとき複数ファイルのimportを書き直すComposition Rootの1ファイルだけ変える
テストテストコードまでinfrastructure層をimportするComposition Rootだけimportすれば済む
新メンバーの理解配線があちこちに散らばって追えない1ファイル見れば全体の繋がりがわかる

「このprovider定義、層をまたぐimportしてない?」——それを問い続けるだけで、参照違反は消えるっつーこと。


沼りたい人はこっちも読んで📖沼りたい人はこっちも読んで📖

権威ある書籍

  • Mark Seemann『Dependency Injection in .NET』(2011) — Composition Rootの考え方をちゃんとまとめた最初の本
  • Mark Seemann, Steven van Deursen『Dependency Injection: Principles, Practices, and Patterns』(2019, 第2版) — .NET以外にも使える形に広げた改訂版
  • Robert C. Martin『Clean Architecture』(2017) — 層の向きとComposition Rootを含む設計全体の話

一次資料

無料で読める入門記事

  • Zenn・Qiitaで「Composition Root Flutter」「Riverpod Clean Architecture」で検索すると日本語の実装例が見つかる

Dev-Here「ギャルでも分かる設計の勘ドコロ!」シリーズ 第13回