App Architecture & Design Patterns

Cross-Feature Communication & Core Module

16 min Lesson 11 of 12

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 auth module now depends on the entire home module
  • Unit-testing auth requires instantiating home widgets
  • Renaming or removing HomePage breaks the auth module
  • A circular dependency can form if home ever imports auth
Note: The goal is for each feature folder to depend only on the 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);
    }
  }
}
Tip: Using a 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,
);
Warning: Never define the concrete router or event-bus implementation inside a feature folder. That would re-introduce coupling. Keep all wiring in the 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.