Cross-Feature Communication & Core Module
Cross-Feature Communication & Core Module
As a Flutter application grows, it is natural to split it into feature modules — auth, profile, notifications, payments, and so on. The critical architectural challenge is: how can Feature A trigger behaviour in Feature B without importing Feature B directly? Direct imports create tight coupling, make unit testing painful, and turn refactors into ripple-effect disasters. The solution is a core module that acts as the shared contract layer between features.
Why Direct Imports Between Features Are Dangerous
Consider a naive implementation where the auth feature imports the home feature to navigate after login:
// BAD: auth/login_page.dart imports home directly
import 'package:my_app/features/home/home_page.dart'; // tight coupling!
void _onLoginSuccess() {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const HomePage()),
);
}
This single import means:
- The
authmodule now depends on the entirehomemodule - Unit-testing
authrequires instantiatinghomewidgets - Renaming or removing
HomePagebreaks the auth module - A circular dependency can form if
homeever importsauth
core module, never on sibling features. The dependency graph must be a tree, not a web.The Core Module as a Shared Contract Layer
The core module (sometimes called shared or common) is a first-class Dart package or folder that every feature is allowed to import. It exposes:
- Abstract interfaces (abstract classes) — contracts that features must implement
- Domain models / entities — pure data classes shared across features
- Navigation contracts — abstract router or route-name constants
- Event bus / stream channels — typed cross-feature event definitions
- Utility helpers — formatting, extensions, theming tokens
// core/navigation/app_router.dart
abstract class AppRouter {
void goToHome();
void goToProfile(String userId);
void goToLogin();
void pop();
}
// core/events/app_event.dart
abstract class AppEvent {
const AppEvent();
}
class UserLoggedIn extends AppEvent {
final String userId;
const UserLoggedIn(this.userId);
}
class UserLoggedOut extends AppEvent {
const UserLoggedOut();
}
With this contract in place, the auth feature only ever imports from core. The concrete router implementation lives in main.dart or an app layer, which wires everything together via dependency injection.
Cross-Feature Navigation via an Abstract Router
Inject the abstract AppRouter into each feature. The feature calls methods on the interface; it never knows which concrete implementation runs underneath:
// features/auth/presentation/login_notifier.dart
import 'package:my_app/core/navigation/app_router.dart';
import 'package:my_app/core/events/app_event.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class LoginNotifier extends StateNotifier<AsyncValue<void>> {
LoginNotifier(this._router, this._eventBus)
: super(const AsyncValue.data(null));
final AppRouter _router;
final StreamController<AppEvent> _eventBus;
Future<void> login(String email, String password) async {
state = const AsyncValue.loading();
try {
// ... call auth repository ...
const userId = 'user-123';
_eventBus.add(const UserLoggedIn(userId)); // broadcast event
_router.goToHome(); // navigate without importing home
state = const AsyncValue.data(null);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
}
StreamController<AppEvent> (or a package like event_bus) as the event bus means any feature can listen to UserLoggedIn without knowing that the auth module was the sender. The only shared knowledge is the event class itself — which lives in core.Event Broadcasting Without Direct Dependencies
The event bus pattern decouples the sender from all receivers. A notifications feature can react to UserLoggedIn to load the user's notification count; a profile feature can react to fetch the profile — neither imports auth:
// features/notifications/application/notifications_service.dart
import 'package:my_app/core/events/app_event.dart';
class NotificationsService {
NotificationsService(Stream<AppEvent> events) {
events.whereType<UserLoggedIn>().listen((event) {
_loadNotifications(event.userId);
});
events.whereType<UserLoggedOut>().listen((_) {
_clearNotifications();
});
}
void _loadNotifications(String userId) {
// fetch from API — no auth import needed
}
void _clearNotifications() {
// reset local state
}
}
Wiring Features Together in the App Layer
The main.dart or a dedicated AppModule is the only place that knows about every feature. It instantiates concrete implementations and injects them:
// app/app_module.dart (the composition root)
import 'package:my_app/core/navigation/app_router.dart';
import 'package:my_app/core/events/app_event.dart';
import 'package:my_app/app/go_router_impl.dart'; // concrete router
import 'package:my_app/features/notifications/...'; // concrete service
final _eventBus = StreamController<AppEvent>.broadcast();
final appRouterProvider = Provider<AppRouter>(
(ref) => GoRouterImpl(ref),
);
final eventBusProvider = Provider<StreamController<AppEvent>>(
(ref) => _eventBus,
);
app layer — the one layer that is allowed to know about every feature.Summary
Cross-feature communication stays clean when every feature depends only on the core module's contracts, never on sibling features. The core module exposes abstract interfaces for navigation and typed events for broadcasting state changes. The app layer (composition root) provides concrete implementations and wires features together through dependency injection. This produces a dependency graph shaped like a tree — core at the root, features as branches, the app layer at the top — which is easy to test, refactor, and scale.