go_routerで型安全性と疎結合を両立する設計パターン
はじめに
はじめまして、しもちです。レスキューナウテックブログへの初投稿となります。
普段はFlutterでのモバイルアプリ開発やマイクロサービスの設計に携わっています。
Flutterアプリケーションのルーティングでgo_routerを使用する際、多くのプロジェクトでは型安全性を確保するためにgo_router_builderを採用します。しかし、go_router_builderには構造的な課題があります。この記事では、go_router_builderを参考にしつつ、独自の型安全なルーティングシステムを実装する方法を紹介します。
go_router_builderの課題
go_router_builderの優れている点は「パラメータの型安全性」です。しかし、実際のプロジェクトで使用すると以下のような問題に直面することがあります。
- 密結合の問題: パスの構成とWidgetの呼び出しが密結合してしまう
- 循環参照のリスク: パス構成クラスから各機能への参照と、各機能からパス構成クラスへの参照が発生
例
// lib/routing/routes.dart
import 'package:myapp/features/home/home_page.dart'; // ← 各featureをimport
import 'package:myapp/features/product/product_detail_page.dart';
<HomeRoute>(
path: '/',
routes: [
TypedGoRoute<ProductDetailRoute>(path: 'product/:id'),
],
)
class HomeRoute extends GoRouteData {
Widget build(BuildContext context, GoRouterState state) {
return const HomePage(); // ← Widgetを直接返す(密結合)
}
}
class ProductDetailRoute extends GoRouteData {
final String id;
ProductDetailRoute({required this.id});
Widget build(BuildContext context, GoRouterState state) {
return ProductDetailPage(productId: id);
}
}
// lib/features/home/home_page.dart
import 'package:myapp/routing/routes.dart'; // ← routes.dartをimport
class HomePage extends StatelessWidget {
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () => ProductDetailRoute(id: '123').go(context), // ← ナビゲーション
child: Text('商品詳細へ'),
);
}
}
依存関係の図
┌─────────────────────────────────────────────────────┐
│ │
│ routes.dart ──────────────► home_page.dart │
│ ▲ │ │
│ │ │ │
│ └────────────────────────────┘ │
│ 循環参照! │
└─────────────────────────────────────────────────────┘
解決策:カスタムRouteクラスによる型安全性の実現
go_router_builderの型安全性という利点を参考に、独自のRouteクラスパターンを実装しました。これにより、疎結合を保ちながら型安全性を実現できます。
1. 階層的なルートパス定義(Enum)
まず、アプリケーション全体のルートパスを階層的に定義します。
// lib/routing/route_path.dart
enum RoutePath {
// Root
root('/'),
// Auth Routes
login('login', parent: root),
register('register', parent: root),
forgotPassword('forgot-password', parent: login),
// Main Routes
home('home', parent: root),
products('products', parent: root),
productDetail('detail', parent: products),
cart('cart', parent: root),
// Profile Routes
profile('profile', parent: root),
editProfile('edit', parent: profile),
orderHistory('orders', parent: profile);
final String segment;
final RoutePath? parent;
const RoutePath(this.segment, {this.parent});
/// 完全なパスを計算
String get path {
if (parent == null) return segment;
final parentPath = parent!.path;
if (parentPath == '/') {
return '/$segment';
}
return '$parentPath/$segment';
}
/// ネストされたルーティング用の相対パス
String get relativePath => segment;
}
このEnumにより、アプリケーション全体のパス構造を一元管理でき、パスの変更時も一箇所で修正できます。
2. 型安全なRouteクラス
次に、各ルートに対応する型安全なRouteクラスを定義します。
// lib/routing/app_route.dart
/// すべてのルートの基底クラス
sealed class AppRoute {
const AppRoute({required this.routePath});
final RoutePath routePath;
String get path => routePath.path;
Object? get extra => null;
void go(BuildContext context) => context.go(path, extra: extra);
Future<T?> push<T>(BuildContext context) =>
context.push<T>(path, extra: extra);
void replace(BuildContext context) => context.replace(path, extra: extra);
}
/// シンプルなルート(パラメータなし)
class HomeRoute extends AppRoute {
const HomeRoute() : super(routePath: RoutePath.home);
}
/// パラメータ付きルート(型安全)
class ProductDetailRoute extends AppRoute {
final ProductDetailParams params;
const ProductDetailRoute({required this.params})
: super(routePath: RoutePath.productDetail);
Object? get extra => params.toJson();
}
/// クエリパラメータ付きルート
class ProductListRoute extends AppRoute {
final String? category;
final String? searchQuery;
const ProductListRoute({this.category, this.searchQuery})
: super(routePath: RoutePath.products);
String get path {
final base = RoutePath.products.path;
final queryParams = <String, String>{};
if (category != null) queryParams['category'] = category!;
if (searchQuery != null) queryParams['q'] = searchQuery!;
if (queryParams.isNotEmpty) {
final query = Uri(queryParameters: queryParams).query;
return '$base?$query';
}
return base;
}
}
3. 型安全なパラメータ定義(Freezed)
パラメータは Freezed を使用して型安全に定義します。
// lib/routing/route_params.dart
class ProductDetailParams with _$ProductDetailParams {
const factory ProductDetailParams({
required String productId,
required String productName,
String? referrerPage,
}) = _ProductDetailParams;
factory ProductDetailParams.fromJson(Map<String, dynamic> json) =>
_$ProductDetailParamsFromJson(json);
}
class CheckoutParams with _$CheckoutParams {
const factory CheckoutParams({
required List<CartItem> items,
required double totalAmount,
String? couponCode,
}) = _CheckoutParams;
factory CheckoutParams.fromJson(Map<String, dynamic> json) =>
_$CheckoutParamsFromJson(json);
}
4. ルーター設定の分離
ルート定義クラス(AppRoute)とGoRouterの設定を分離します。app_router.dartはlib/直下に配置し、sharedディレクトリのルート定義を参照する構造にします。これにより、上位から下位への単方向の依存関係を保ちながら、ルート定義とページの紐付けを独立して管理できます。
// lib/app_router.dart
GoRouter appRouter(AppRouterRef ref) {
return GoRouter(
initialLocation: HomeRoute().path,
routes: [
GoRoute(
path: RoutePath.login.path,
pageBuilder: (context, state) => MaterialPage(
child: LoginPage(),
),
),
ShellRoute(
builder: (context, state, child) => AppShell(child: child),
routes: [
GoRoute(
path: RoutePath.products.path,
pageBuilder: (context, state) => MaterialPage(
child: ProductListPage(
category: state.uri.queryParameters['category'],
searchQuery: state.uri.queryParameters['q'],
),
),
routes: [
GoRoute(
path: RoutePath.productDetail.relativePath,
pageBuilder: (context, state) {
final params = state.extra as Map<String, dynamic>;
final productParams = ProductDetailParams.fromJson(params);
return MaterialPage(
child: ProductDetailPage(
productId: productParams.productId,
productName: productParams.productName,
),
);
},
),
],
),
],
),
],
);
}
実際の使用例
このパターンを使用すると、各機能モジュールから型安全にナビゲーションできます。
// 使用例
class ProductCard extends StatelessWidget {
final Product product;
const ProductCard({required this.product});
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
// 型安全なパラメータ
final route = ProductDetailRoute(
params: ProductDetailParams(
productId: product.id,
productName: product.name,
referrerPage: 'product_list',
),
);
route.push(context);
},
child: Card(
child: Text(product.name),
),
);
}
}
このアプローチの利点
-
型安全性
パラメータはFreezedで定義され、コンパイル時に型チェックされます。typoや型の不一致といった実行時エラーを未然に防げます。 -
疎結合と単方向の依存関係
ルート定義(shared/routing/)、パラメータ、ページ実装がそれぞれ独立しています。依存の方向は常に「上位(app_router.dart)→ 下位(各feature)」となり、循環参照のリスクを排除できます。 -
Feature-First Architectureとの親和性
各機能モジュールはshared/routing/のルート定義のみに依存し、他のfeatureを直接参照しません。これにより、featureごとの独立した開発・テストが可能になり、チーム分割やモジュールの差し替えも容易になります。
lib/
├── app_router.dart # 全体を束ねる(各featureをimport)
├── shared/
│ └── routing/
│ ├── route_path.dart # パス定義
│ ├── app_route.dart # ルートクラス
│ └── route_params.dart
└── features/
├── home/ # shared/routingのみ参照
├── product/ # shared/routingのみ参照
└── cart/ # shared/routingのみ参照
- テスタビリティ
ルート定義とページ実装が分離しているため、ナビゲーションロジックとUI表示を独立してテストできます。モックやスタブの差し込みも容易です。
まとめ
go_router_builderは型安全なルーティングを提供する便利なツールですが、パス構成とWidgetの密結合により循環参照が発生しやすいという構造的な課題があります。
本記事で紹介したカスタムRouteクラスパターンでは、ルート定義をshared/routing/に分離することで、go_router_builderの型安全性を維持しながら単方向の依存関係を実現しました。
このアプローチは以下のようなケースで特に有効です。
- Feature-First Architectureを採用しているプロジェクト
- 複数チームで機能ごとに分担して開発を進めたい場合
- featureの追加・削除が頻繁に発生するプロダクト
go_routerを使用していて、スケーラブルなルーティング設計を模索している方は参考にしてみてください。
Discussion