チュートリアルを超えてスケールするFlutterアプリのアーキテクチャ
本番環境向けFlutterアーキテクチャパターン — 状態管理からナビゲーション、依存性注入、機能優先のプロジェクト構造まで。

チュートリアルで作成するFlutterアプリは、画面数が20程度になるまではうまく機能します。しかし、それ以降は、すべてのルートが記述された単一のmain.dartファイルが管理不能になり、setStateの呼び出しが競合状態を引き起こし、「コンストラクタを通じて渡すだけ」のアプローチでは10個ものパラメータを持つウィジェットツリーが生成され、新しい機能を追加するたびに既存の5つのファイルに手を加えることになります。私自身のプロジェクトを含め、複数のプロジェクトでこの状況を目の当たりにしてきましたが、パターンは常に同じです。チームは3ヶ月目あたりで壁にぶつかり、再構築するか、開発速度の低下を受け入れるかのどちらかを迫られます。
ここで説明するアーキテクチャは、私が本番環境のアプリで使用しているものです。これは最も単純なアプローチではありませんが、それがポイントです。最も単純なアプローチはチュートリアルで教えられるものであり、アプリに実際の要件が生じた瞬間に機能しなくなります。
チュートリアルアーキテクチャが失敗する理由
ほとんどのFlutterチュートリアルやコースでは、フラットなプロジェクト構造を教えています。
lib/
├── main.dart
├── home_screen.dart
├── detail_screen.dart
├── user_model.dart
├── api_service.dart
└── widgets/
├── custom_button.dart
└── loading_indicator.dart
これはToDoアプリには機能します。しかし、実際のアプリでは予測可能な理由で失敗します。
ファイルが自然な境界なく肥大化する。 home_screen.dartが画面レイアウト、状態管理、API呼び出し、ビジネスロジックをすべて持っている場合、すぐに500行を超えてしまいます。何をどこに抽出するべきかについての構造的な指針がありません。
依存関係が暗黙的になる。 home_screen.dartが直接ApiService()をインスタンス化すると、テストでモックに置き換える方法がなく、環境ごとに異なる設定をする方法もなく、同じデータを必要とする複数の画面で単一のインスタンスを共有する方法もありません。
ナビゲーションがハードコードされたルートの網になる。 ディープリンク、認証ガード、条件付きフローがすべてフラットなルートリストに組み込まれます。ガードとリダイレクトを含む30個のルートがある頃には、ナビゲーションコードはアプリの中で最も壊れやすい部分になります。
テストが非現実的になる。 依存性注入がないと、ウィジェットテストはサービスレベルではなくHTTPレベルでのモックが必要になります。ビジネスロジックとUIが明確に分離されていないと、振る舞いではなく実装の詳細をテストすることになってしまいます。
機能優先のプロジェクト構造
最も影響力のあるアーキテクチャ上の決定は、フォルダ構造です。私は機能優先のアプローチを採用しており、各機能が自身の画面、ウィジェット、状態、モデルを所有します。
lib/
├── app/
│ ├── app.dart # MaterialApp configuration
│ ├── router.dart # GoRouter configuration
│ └── theme.dart # Theme data
├── core/
│ ├── constants/
│ │ ├── api_constants.dart
│ │ └── app_constants.dart
│ ├── errors/
│ │ ├── app_exception.dart
│ │ └── error_handler.dart
│ ├── network/
│ │ ├── api_client.dart
│ │ ├── interceptors/
│ │ └── network_info.dart
│ └── utils/
│ ├── date_utils.dart
│ └── string_utils.dart
├── features/
│ ├── auth/
│ │ ├── data/
│ │ │ ├── auth_repository.dart
│ │ │ └── auth_remote_source.dart
│ │ ├── domain/
│ │ │ ├── user_model.dart
│ │ │ └── auth_state.dart
│ │ ├── presentation/
│ │ │ ├── screens/
│ │ │ │ ├── login_screen.dart
│ │ │ │ └── register_screen.dart
│ │ │ ├── widgets/
│ │ │ │ ├── login_form.dart
│ │ │ │ └── social_login_buttons.dart
│ │ │ └── providers/
│ │ │ └── auth_provider.dart
│ │ └── auth.dart # Barrel file for public API
│ ├── home/
│ │ ├── data/
│ │ ├── domain/
│ │ ├── presentation/
│ │ └── home.dart
│ └── settings/
│ ├── data/
│ ├── domain/
│ ├── presentation/
│ └── settings.dart
└── shared/
├── widgets/
│ ├── app_button.dart
│ ├── app_text_field.dart
│ ├── loading_overlay.dart
│ └── error_view.dart
├── models/
│ └── paginated_response.dart
└── providers/
└── connectivity_provider.dart
ルール
機能は他の機能からインポートしない。 機能Aが機能Bからのデータを必要とする場合、そのデータは共有モデルまたは共有サービスに存在し、機能Bのフォルダにはありません。これにより循環依存が防止され、機能が独立してデプロイ可能になります(モジュラーモノレポに移行する場合に役立ちます)。
core/ディレクトリにはインフラストラクチャが含まれる。 ネットワーキング、エラーハンドリング、定数、ユーティリティなど、すべての機能が必要とする可能性はあるが、どの単一の機能にも属さないものです。
shared/ディレクトリには再利用可能なUIと横断的関心事が含まれる。 複数の機能が依存する共有ウィジェット、共有モデル、プロバイダーなどです。
バレルファイルは各機能の公開APIを公開する。 アプリの他の部分はfeatures/auth/auth.dartからインポートし、内部ファイルからは決してインポートしません。これにより、外部のコンシューマを壊すことなく、機能の内部構造をリファクタリングできます。
// features/auth/auth.dart
export 'domain/user_model.dart';
export 'domain/auth_state.dart';
export 'presentation/providers/auth_provider.dart';
// Don't export internal implementation details
状態管理: Riverpod
私は本番プロジェクトでProvider、Bloc、Riverpodを使用してきました。それぞれにトレードオフがあり、選択は技術的な優位性よりもチームの慣れに依存します。とはいえ、私は特定の理由から新しいプロジェクトではRiverpodを採用しています。
BlocよりもRiverpodを選ぶ理由
Blocは、エンタープライズのバックグラウンドを持つチームにとって優れています。イベント/状態パターンは、ReduxやMVIを扱ったことのある開発者には馴染み深いものです。厳格な単方向データフローを強制し、予測可能でテスト可能なステートマシンを生成します。欠点は冗長性です。シンプルな機能でも、イベントクラス、状態クラス、Blocクラスが必要となり、しばしば3つのファイルにまたがります。複雑なビジネスロジックと複数の開発者が関わるアプリでは、この儀式は報われます。しかし、迅速に開発を進める小規模チームにとっては、オーバーヘッドに感じられるかもしれません。
Provider(オリジナルのパッケージ)は、単純な依存性注入にはうまく機能しますが、複雑な状態管理には苦労します。スコープのためにウィジェットツリーに依存するため、状態のライフサイクルがUI構造に結びついてしまいます。ウィジェットツリーをリファクタリングすると、状態管理が微妙な形で壊れる可能性があります。
Riverpodは、Providerの根本的な制限を解決します。プロバイダーはグローバル(ウィジェットツリーに依存しない)で、コンパイル時安全であり、自動破棄、ファミリープロバイダー、非同期状態などの高度なパターンをサポートします。また、テスト可能性を維持しながら、Blocよりも少ないボイラープレートコードを生成します。
Riverpodの実践
典型的な機能の状態管理をどのように構築するかを以下に示します。
// features/auth/domain/auth_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'user_model.dart';
part 'auth_state.freezed.dart';
@freezed
class AuthState with _$AuthState {
const factory AuthState.initial() = _Initial;
const factory AuthState.loading() = _Loading;
const factory AuthState.authenticated(User user) = _Authenticated;
const factory AuthState.unauthenticated() = _Unauthenticated;
const factory AuthState.error(String message) = _Error;
}
// features/auth/presentation/providers/auth_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../data/auth_repository.dart';
import '../../domain/auth_state.dart';
part 'auth_provider.g.dart';
@riverpod
class AuthNotifier extends _$AuthNotifier {
@override
AuthState build() {
_checkAuthStatus();
return const AuthState.initial();
}
Future<void> _checkAuthStatus() async {
final repository = ref.read(authRepositoryProvider);
final user = await repository.getCurrentUser();
state = user != null
? AuthState.authenticated(user)
: const AuthState.unauthenticated();
}
Future<void> signIn(String email, String password) async {
state = const AuthState.loading();
final repository = ref.read(authRepositoryProvider);
try {
final user = await repository.signIn(email, password);
state = AuthState.authenticated(user);
} on AuthException catch (e) {
state = AuthState.error(e.message);
}
}
Future<void> signOut() async {
final repository = ref.read(authRepositoryProvider);
await repository.signOut();
state = const AuthState.unauthenticated();
}
}
コード生成を伴う@riverpodアノテーションを使用することで、手動でのプロバイダー宣言のボイラープレートが不要になります。プロバイダーコードを生成するには、dart run build_runner buildを実行します。
Blocがより優れている場合
Riverpodを好むにもかかわらず、Blocがより強力な選択肢となるシナリオもあります。
- 強制されたパターンが不整合を防ぐ大規模チーム。
- 多くの遷移を伴う複雑なステートマシン — Blocのイベントシステムはこれらを明示的かつテスト可能にします。
- リプレイ/アンドゥ機能が必要な場合 — Blocのイベントログはこれを簡単にします。
- チームがすでにBlocを知っている場合。 コードベース全体の一貫性は、両者の技術的な違いよりも重要です。
GoRouterによるナビゲーション
GoRouterはFlutterナビゲーションの標準となり、それには正当な理由があります。宣言的ルーティング、ディープリンク、リダイレクト、ネストされたナビゲーションをすぐにサポートします。
// app/router.dart
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'router.g.dart';
@riverpod
GoRouter router(RouterRef ref) {
final authState = ref.watch(authNotifierProvider);
return GoRouter(
initialLocation: '/',
redirect: (context, state) {
final isAuthenticated = authState is Authenticated;
final isAuthRoute = state.matchedLocation.startsWith('/auth');
if (!isAuthenticated && !isAuthRoute) return '/auth/login';
if (isAuthenticated && isAuthRoute) return '/';
return null;
},
routes: [
GoRoute(
path: '/auth/login',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/auth/register',
builder: (context, state) => const RegisterScreen(),
),
ShellRoute(
builder: (context, state, child) => AppShell(child: child),
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsScreen(),
),
GoRoute(
path: '/item/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return ItemDetailScreen(itemId: id);
},
),
],
),
],
);
}
ナビゲーションガード
redirectコールバックは認証ガードをグローバルに処理します。しかし、ロールベースのアクセス、オンボーディングフロー、機能フラグなど、より複雑なロジックが必要になる場合があります。私はこれらをリダイレクトチェーンで処理します。
redirect: (context, state) {
// Authentication check
final isAuthenticated = authState is Authenticated;
if (!isAuthenticated && !state.matchedLocation.startsWith('/auth')) {
return '/auth/login';
}
// Onboarding check
if (isAuthenticated && !hasCompletedOnboarding && state.matchedLocation != '/onboarding') {
return '/onboarding';
}
// Already authenticated, redirect away from auth screens
if (isAuthenticated && state.matchedLocation.startsWith('/auth')) {
return '/';
}
return null; // No redirect
},
リポジトリパターン
リポジトリはビジネスロジックとデータソースの間に位置します。データがREST API、ローカルデータベース、キャッシュ、またはこれら3つの組み合わせのどこから来るのかを抽象化します。
// features/auth/data/auth_repository.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../domain/user_model.dart';
part 'auth_repository.g.dart';
@riverpod
AuthRepository authRepository(AuthRepositoryRef ref) {
return AuthRepository(
remoteSource: ref.read(authRemoteSourceProvider),
localSource: ref.read(authLocalSourceProvider),
);
}
class AuthRepository {
final AuthRemoteSource _remoteSource;
final AuthLocalSource _localSource;
AuthRepository({
required AuthRemoteSource remoteSource,
required AuthLocalSource localSource,
}) : _remoteSource = remoteSource,
_localSource = localSource;
Future<User?> getCurrentUser() async {
// Try local cache first
final cachedUser = await _localSource.getCachedUser();
if (cachedUser != null) return cachedUser;
// Fall back to remote
try {
final user = await _remoteSource.fetchCurrentUser();
if (user != null) await _localSource.cacheUser(user);
return user;
} catch (_) {
return null;
}
}
Future<User> signIn(String email, String password) async {
final user = await _remoteSource.signIn(email, password);
await _localSource.cacheUser(user);
await _localSource.saveToken(user.token);
return user;
}
Future<void> signOut() async {
await _remoteSource.signOut();
await _localSource.clearCache();
}
}
なぜAPIを直接呼び出してはいけないのか
状態管理コードからの直接的なAPI呼び出しは、いくつかの問題を引き起こします。
- キャッシュロジックがビジネスロジックに漏れ出す。 API呼び出しの前にキャッシュをチェックしますか?キャッシュはどのくらい有効ですか?これはデータ層の関心事であり、状態管理の関心事ではありません。
- オフラインサポートが書き直しになる。 Blocが直接
http.getを呼び出す場合、オフラインサポートを追加するにはすべてのBlocを変更する必要があります。リポジトリを使用すれば、キャッシュとフォールバックロジックを1箇所に追加できます。 - テストにはHTTPモックが必要になる。 リポジトリのモックはテストごとに1つです。HTTPのモックは、すべてのテストケースに対してレスポンスボディ、ステータスコード、ヘッダーを設定する必要があります。
サービス層
複数のリポジトリにまたがる操作や複雑なビジネスロジックを伴う操作には、サービス層を使用します。
class OrderService {
final OrderRepository _orderRepo;
final PaymentRepository _paymentRepo;
final NotificationRepository _notificationRepo;
OrderService({
required OrderRepository orderRepo,
required PaymentRepository paymentRepo,
required NotificationRepository notificationRepo,
}) : _orderRepo = orderRepo,
_paymentRepo = paymentRepo,
_notificationRepo = notificationRepo;
Future<Order> placeOrder(Cart cart, PaymentMethod method) async {
// Validate stock
await _orderRepo.validateStock(cart.items);
// Process payment
final payment = await _paymentRepo.charge(method, cart.total);
// Create order
final order = await _orderRepo.create(
items: cart.items,
paymentId: payment.id,
);
// Send confirmation (fire and forget)
_notificationRepo.sendOrderConfirmation(order).ignore();
return order;
}
}
サービスには、UIや単一のリポジトリに属さないビジネスロジックが含まれます。状態管理層(Riverpod notifier、Bloc)がサービスを呼び出し、サービスがリポジトリ間で調整を行います。
エラーハンドリング
一貫したエラーハンドリングは、Flutterアーキテクチャにおいて最も軽視されがちな側面の1つです。私は型付き例外と結果型を組み合わせて使用しています。
// core/errors/app_exception.dart
sealed class AppException implements Exception {
final String message;
final String? code;
const AppException(this.message, {this.code});
}
class NetworkException extends AppException {
const NetworkException([String message = 'Network error']) : super(message);
}
class ServerException extends AppException {
final int statusCode;
const ServerException(this.statusCode, String message) : super(message);
}
class AuthException extends AppException {
const AuthException([String message = 'Authentication failed']) : super(message);
}
class ValidationException extends AppException {
final Map<String, String> fieldErrors;
const ValidationException(this.fieldErrors) : super('Validation failed');
}
APIクライアントはHTTPエラーを捕捉し、型付き例外に変換します。
// core/network/api_client.dart
class ApiClient {
final Dio _dio;
ApiClient(this._dio);
Future<T> get<T>(String path, {T Function(dynamic)? fromJson}) async {
try {
final response = await _dio.get(path);
return fromJson != null ? fromJson(response.data) : response.data as T;
} on DioException catch (e) {
throw _mapError(e);
}
}
AppException _mapError(DioException error) {
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout) {
return const NetworkException('Connection timed out');
}
final statusCode = error.response?.statusCode;
if (statusCode == null) return const NetworkException();
return switch (statusCode) {
401 => const AuthException('Session expired'),
403 => const AuthException('Access denied'),
422 => ValidationException(
_parseValidationErrors(error.response?.data),
),
_ => ServerException(statusCode, 'Server error'),
};
}
}
テストアーキテクチャ
優れたアーキテクチャはテストを簡単にします。各層には独自のテスト戦略があります。
リポジトリテスト
void main() {
late AuthRepository repository;
late MockAuthRemoteSource mockRemote;
late MockAuthLocalSource mockLocal;
setUp(() {
mockRemote = MockAuthRemoteSource();
mockLocal = MockAuthLocalSource();
repository = AuthRepository(
remoteSource: mockRemote,
localSource: mockLocal,
);
});
group('getCurrentUser', () {
test('returns cached user when available', () async {
final user = User(id: '1', name: 'Test');
when(() => mockLocal.getCachedUser()).thenAnswer((_) async => user);
final result = await repository.getCurrentUser();
expect(result, equals(user));
verifyNever(() => mockRemote.fetchCurrentUser());
});
test('fetches from remote when cache is empty', () async {
final user = User(id: '1', name: 'Test');
when(() => mockLocal.getCachedUser()).thenAnswer((_) async => null);
when(() => mockRemote.fetchCurrentUser()).thenAnswer((_) async => user);
when(() => mockLocal.cacheUser(any())).thenAnswer((_) async {});
final result = await repository.getCurrentUser();
expect(result, equals(user));
verify(() => mockLocal.cacheUser(user)).called(1);
});
});
}
Riverpodによるプロバイダーテスト
void main() {
test('signIn updates state to authenticated', () async {
final user = User(id: '1', name: 'Test');
final mockRepository = MockAuthRepository();
when(() => mockRepository.signIn(any(), any()))
.thenAnswer((_) async => user);
final container = ProviderContainer(
overrides: [
authRepositoryProvider.overrideWithValue(mockRepository),
],
);
final notifier = container.read(authNotifierProvider.notifier);
await notifier.signIn('test@test.com', 'password');
final state = container.read(authNotifierProvider);
expect(state, AuthState.authenticated(user));
});
}
RiverpodのProviderContainerとオーバーライドメカニズムはテストをクリーンにします。依存関係をウィジェットツリーレベルではなく、コンテナレベルでオーバーライドするからです。
実用的なトレードオフ
このアーキテクチャは無料ではありません。実際のコストがかかります。
ファイル数の増加。 チュートリアルアプリでは1つのファイルで済む機能が、この構造では5〜8個のファイルになる可能性があります。小規模なアプリ(10画面未満)の場合、これはやりすぎに感じられるかもしれません。
学習曲線。 プロジェクトに新しく参加する開発者は、層の境界と何をどこに配置すべきかを理解する必要があります。ドキュメントは役立ちますが、それでも立ち上げには時間がかかります。
コード生成。 Riverpodのアノテーションシステム、不変モデルのためのFreezed、JSONシリアライゼーションはすべてbuild_runnerを必要とします。ビルド生成は開発に一手間加え、大規模なコードベースでは遅くなる可能性があります。
過剰な抽象化の誘惑。 クリーンなアーキテクチャを持っていると、「念のため」抽象化を追加しがちです。常に1つの実装しか持たないデータソースに対するリポジトリインターフェースは、価値のない複雑さを追加します。私は具体的な2番目の実装がある場合にのみ抽象化を作成し、それ以前には作成しません。