Flutter Interview Series · Architecture & Scalability

Modular architecture is the question senior Flutter interviewers use to filter candidates. Real questions, model answers, folder structures, dependency rules, and the build-time numbers that make the case for modularization.

Here's the thing about Flutter modular architecture questions: they're not really about Flutter. They're about how you think about software at scale — how you manage dependencies, enforce boundaries, keep teams unblocked, and prevent the slow entropy that turns every growing app into a spaghetti codebase that nobody wants to touch. Interviewers ask these questions because the answers reveal engineering maturity that no algorithmic problem ever could.

This guide goes through the real questions asked in Flutter senior and staff interviews, with answers precise enough to survive a follow-up and code grounded in actual production patterns. No hand-waving. No vague references to "separation of concerns."

"A monolith doesn't fail all at once. It fails one tight coupling at a time — until the day changing a button color requires rebuilding the entire app."

1. What Is a Monolith in Flutter — and Why Does It Matter?

A Flutter monolith is a single Dart package where all features, data layers, and UI components live together. It's how every Flutter app starts, and it works fine until it doesn't. The inflection point typically comes around 50,000–100,000 lines of code or when a team grows past 4–5 developers working on the same codebase simultaneously.

Q : "What are the concrete symptoms that tell you a Flutter app needs to be modularized?"

MODEL ANSWER

The first symptom is build time. In a monolith, any change triggers a full incremental rebuild across the entire app. When hot restart takes 8–15 seconds and CI full builds exceed 10 minutes, productivity is measurably impaired. Modular apps allow flutter build to skip unchanged packages entirely.

The second is merge conflicts. When 5 developers all edit files in the same lib/ directory, conflicts are daily. Module boundaries create natural ownership — teams own packages, not folders.

Third is test isolation. In a monolith, unit testing a repository often requires initializing the full app widget tree because dependencies are entangled. Modules with explicit interfaces make unit tests fast and focused.

Finally, accidental coupling: a developer imports a widget from a completely unrelated feature because it's visible in the same package. Separate packages with controlled exports make this a compile error, not a code review comment.

Module Structure

2. The Layer Model — What Gets Its Own Package?

The most common modular Flutter architecture splits the codebase into three tiers of packages: app packages (the shell), feature packages (vertical slices of functionality), and core/shared packages (horizontal utilities used across features). The dependency rule flows strictly downward.

None

The packages/app is the only Dart package that becomes the final Flutter app. It imports feature packages and wires them together — providing GoRouter configuration, dependency injection registration, and the root MaterialApp. Feature packages don't know about each other; they communicate through contracts defined in core packages.

Q : "How do you handle cross-feature communication in a modular Flutter app? Feature A needs to navigate to Feature B."

MODEL ANSWER

Features should never import each other directly — that recreates the coupling modularization was meant to prevent. There are two clean solutions:

1. Navigation contracts via the app layer. Define an abstract AuthNavigator interface in a core package. Feature A calls authNavigator.goToLogin() without knowing the implementation. The app layer injects the concrete GoRouter implementation. Neither feature imports the other.

2. Shared event bus / stream via a core package. For non-navigation cross-feature events (e.g. "user logged out"), define a AppEventBus in a core package. Both features depend on the core package — one publishes, one subscribes. No direct dependency between features.

The rule of thumb: if two features need to communicate directly, ask whether the shared concept belongs in a third, shared core package — or whether one feature is actually a sub-feature of the other.

// packages/core_navigation/lib/src/auth_navigator.dart
abstract class AuthNavigator {
  void goToLogin();
  void goToRegister({String? referralCode});
}

// packages/feature_home/lib/src/home_screen.dart
// ✅ Depends on core_navigation — NOT on feature_auth
class HomeScreen extends StatelessWidget {
  const HomeScreen({required this.authNavigator, super.key});
  final AuthNavigator authNavigator;

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: authNavigator.goToLogin,
      child: const Text('Sign In'),
    );
  }
}

// packages/app/lib/src/navigation/go_router_auth_navigator.dart
// App layer provides the concrete implementation
class GoRouterAuthNavigator implements AuthNavigator {
  GoRouterAuthNavigator(this._router);
  final GoRouter _router;

  @override
  void goToLogin() => _router.go('/auth/login');

  @override
  void goToRegister({String? referralCode}) =>
      _router.go('/auth/register', extra: referralCode);
}

Dependency Inversion

3. Dependency Inversion — The Non-Negotiable Rule

Q : "Explain the Dependency Inversion Principle and how it applies to Flutter modular architecture."

MODEL ANSWER

The Dependency Inversion Principle states that high-level modules should not depend on low-level modules — both should depend on abstractions. In Flutter modular terms: a feature package (high-level) should not import a concrete repository class from a data package (low-level). Instead, both should depend on an abstract interface defined in a contract package that neither owns the implementation of.

The practical effect: you can swap the Hive-based storage implementation for a SQLite one without touching a single feature package. You can mock the UserRepository interface in tests without instantiating real network clients. The feature package doesn't care how data is fetched — only that the contract is fulfilled.

In Flutter, this is typically realized through abstract classes (or Dart 3 interfaces using the interface keyword) combined with a DI framework like get_it or riverpod to wire up implementations at the app layer.

// packages/core_contracts/lib/src/user_repository.dart
abstract interface class UserRepository {
  Future<User> getUser(String id);
  Future<void> updateUser(User user);
  Stream<User> watchUser(String id);
}

// packages/data_user/lib/src/remote_user_repository.dart
// Implements the contract — feature packages never import this directly
class RemoteUserRepository implements UserRepository {
  RemoteUserRepository(this._api, this._cache);
  final UserApi _api;
  final UserCache _cache;

  @override
  Future<User> getUser(String id) async {
    final cached = await _cache.get(id);
    if (cached != null) return cached;
    final user = await _api.fetchUser(id);
    await _cache.put(user);
    return user;
  }
  // ...
}

// packages/app — wires it up with get_it
GetIt.I.registerLazySingleton<UserRepository>(
  () => RemoteUserRepository(GetIt.I(), GetIt.I()),
);

⚑ Interview TipDart 3.0 (released May 2023) introduced the interface keyword, which prevents external packages from extending (but not implementing) a class. Use abstract interface class for repository contracts — it signals intent more precisely than a plain abstract class and shows you're current with the language.

pubspec.yaml Structure

4. The Melos Monorepo — Practical Setup

Managing 10–20 interdependent Dart packages manually is not viable. The standard solution is Melos — a tool by Invertase that manages multi-package Dart/Flutter repositories. It provides workspace-level commands, versioning, changelog generation, and script running across packages.

Q : "How do you manage local package dependencies in a Flutter monorepo? Walk me through the setup."

MODEL ANSWER

I'd use Melos with a workspace melos.yaml at the repository root. Melos handles bootstrapping (running pub get across all packages and symlinking local dependencies), unified script execution, and — if needed — automated versioning using Conventional Commits. Local packages reference each other with path: dependencies, which Melos overrides automatically in its bootstrap step.

# melos.yaml — repository root
name: my_app_workspace

packages:
  - packages/**
  - apps/**

scripts:
  analyze:
    run: melos exec -- dart analyze .
    description: Analyze all packages

  test:all:
    run: melos exec --fail-fast -- flutter test
    description: Run tests in all packages

  gen:
    run: melos exec --depends-on="build_runner" -- \
      dart run build_runner build --delete-conflicting-outputs
    description: Run code generation in packages that need it
# packages/feature_profile/pubspec.yaml
name: feature_profile
version: 1.0.0

dependencies:
  flutter:
    sdk: flutter
  core_contracts:
    path: ../../packages/core_contracts   # local path dep
  core_ui:
    path: ../../packages/core_ui
  flutter_riverpod: ^2.5.1
  go_router: ^14.0.0

📦 Workspace pubspec (Dart 3.5+ / Flutter 3.24+)Dart 3.5 introducednative pub workspacesas an alternative to Melos for managing multi-package repositories, using a workspace: key in the root pubspec.yaml. As of 2024 it's still maturing — Melos remains the production standard for complex monorepos, but know that the native solution exists and is under active development.

Comparison

5. Architecture Comparison: Monolith vs Modular

None

State Management in Modules

6. State Management Across Module Boundaries

Q : "How do you share state between feature modules without creating tight coupling? For example, the current authenticated user needs to be accessible in every feature."

MODEL ANSWER

The correct approach depends on the state management solution, but the principle is the same: define the state interface and data model in a core package, and inject the concrete provider at the app layer.

With Riverpod: define a currentUserProvider in core_auth_state. Every feature package depends on core_auth_state and reads the provider — no feature depends on another feature. The concrete implementation (which reads from SharedPreferences, Firebase Auth, etc.) is registered in the app package's ProviderScope overrides.

With Bloc: put the AuthBloc abstract class and its states in a core package. The app layer creates and provides the concrete AuthBloc via MultiBlocProvider at the root. Features read from it via context.read<AuthBloc>() — they know the contract, not the source.

// packages/core_auth_state/lib/src/auth_state_provider.dart
// Riverpod — defines the shape, app layer provides the value

final currentUserProvider = StreamProvider<User?>((ref) {
  // Override in ProviderScope at the app layer
  throw UnimplementedError('currentUserProvider must be overridden');
});

// packages/app/lib/main.dart
void main() {
  runApp(
    ProviderScope(
      overrides: [
        currentUserProvider.overrideWith(
          (ref) => ref.watch(firebaseAuthProvider).authStateChanges()
              .map((u) => u != null ? User.fromFirebase(u) : null),
        ),
      ],
      child: const MyApp(),
    ),
  );
}

Testing in Modular Apps

7. Testing Strategy in a Modular Codebase

Q : "How does modularization change your testing strategy? What do you test at the package level vs the app level?"

MODEL ANSWER

Modularization enables a much cleaner test pyramid. At the package level, each feature and core package has its own test/ directory. Unit tests mock the abstract interfaces the package depends on — no real network, no real storage, no Flutter widget tree unless you're explicitly testing widgets. These tests run in milliseconds and should be the majority of your test suite.

Widget tests also live in the feature package, using mock implementations of repository interfaces. They test that the UI renders correctly given specific state, without any app-level plumbing.

Integration and e2e tests live in the app package, because they test real workflows that cross module boundaries — they're expensive and should be fewer in number. The key insight: because module contracts are well-defined, you can confidently substitute fake implementations for integration tests without fear of the fakes drifting from production behavior.

// packages/feature_profile/test/profile_bloc_test.dart
// Tests the feature in complete isolation — no real repository
class MockUserRepository extends Mock implements UserRepository {}

void main() {
  late MockUserRepository repo;
  late ProfileBloc bloc;

  setUp(() {
    repo = MockUserRepository();
    bloc = ProfileBloc(userRepository: repo);
  });

  blocTest<ProfileBloc, ProfileState>(
    'emits loaded state when user fetch succeeds',
    build: () {
      when(() => repo.getUser('u1'))
          .thenAnswer((_) async => User(id: 'u1', name: 'Alex'));
      return bloc;
    },
    act: (b) => b.add(LoadProfile(userId: 'u1')),
    expect: () => [
      ProfileLoading(),
      ProfileLoaded(User(id: 'u1', name: 'Alex')),
    ],
  );
}

Quick-Fire Round

8. Quick-Fire Questions — Crisp Answers

Q : "What's the difference between a Dart package and a Flutter plugin? When would a module be each?"

ANSWER

A Dart package is pure Dart — no native platform code. A Flutter plugin contains Dart code plus native implementations for Android/iOS (and potentially web, macOS, etc.). For internal feature modules, use plain Dart packages unless the module must interface with platform APIs (camera, Bluetooth, NFC). Platform coupling in a feature module is a design smell — if platform access is needed, abstract it behind an interface in a core package and implement it in a dedicated plugin package.

Q : "How do you prevent a developer from importing a private class from another package?"

ANSWER

Dart's package visibility model is file-based, not class-based. Anything in a package's lib/src/ directory is conventionally private — importing package:other/src/internal.dart works but violates the package's API contract. To enforce this: use linter: rules: - implementation_imports in your analysis_options.yaml. This turns any src/ import from an external package into an analyzer warning, catchable in CI. Only export what belongs in the public API via the package's barrel file.

Q : "When would you NOT modularize? Is there a cost to getting it wrong too early?"

ANSWER

Absolutely. Premature modularization is a real anti-pattern. If you split a 10-screen app into 12 packages before the domain is understood, every refactor requires touching 5 packages and updating 12 pubspec.yaml files. The boundaries you draw early often turn out to be wrong — and wrong module boundaries are more expensive to fix than a well-organized monolith.

My heuristic: start modular for core_ui and core_network from day one (these rarely change shape), but keep features in the monolith until you have at least 3 developers and a stable domain model. Then extract feature packages along team ownership boundaries, not feature count.

"The best module boundary is the one that maps to a team. Code ownership and package ownership should be the same line drawn in the same place."

§ Interview Checklist

9. What to Have Ready Before Your Interview

  • Be able to draw the 3-layer dependency diagram (app → feature → core) from memory and explain the direction rule.
  • Know what Melos does and how to bootstrap a monorepo workspace — including the melos.yaml structure.
  • Be able to explain Dependency Inversion with a concrete Flutter example: abstract interface in core, implementation in data package, wired at app layer.
  • Know the difference between Dart packages and Flutter plugins — and when a feature module should be each.
  • Understand the implementation_imports lint rule and why it matters for enforcing module boundaries.
  • Have an opinion on Riverpod vs Bloc for cross-module state — and know how each handles provider scoping.
  • Know Dart 3.0's interface keyword and when to use abstract interface class.
  • Be prepared to argue against modularization for early-stage projects — this shows engineering judgment, not just pattern knowledge.

Modular architecture questions in Flutter interviews are ultimately questions about engineering judgment: when to extract, where to draw boundaries, how to enforce them without over-engineering, and how to communicate those decisions to a team. The code patterns here are learnable in a weekend. The judgment behind them comes from understanding the problems they solve — and the new problems they create. That's what separates a developer who has heard of modular architecture from one who has lived with its trade-offs.

#Flutter #FlutterDev #ModularArchitecture #CleanArchitecture #DartLang #FlutterInterview #SoftwareArchitecture #Melos #Monorepo #DependencyInversion #MobileDevelopment #TechInterview #Riverpod #FlutterBloc #SoftwareEngineering