Flutter · 10 min read

Flutter architecture interview questions and costs

Flutter architecture interview questions on layering, state ownership, packages and testing, with the decision each one tests and what it costs a team.

This page is for Flutter engineers facing an architecture round, or the architecture half of a senior technical round. You get what the round scores, a decision matrix by team size, ten Flutter architecture interview questions with the weak answer and the answer that scores, a worked example on where the cart lives, and the one follow-up that sits behind all of them. Flutter’s documentation has an architecture guide now, with a UI layer and a data layer, but real codebases are split across state management approaches, so the round is less about reciting a pattern and more about defending the one you chose.

What the round scores

Signal Strong Weak
Decision Chooses for this app, this team and this lifetime Names Clean Architecture and stops
Ownership Says who owns each piece of state and for how long “It is all in the provider”
Cost Names what the choice makes harder: boilerplate, generated code, onboarding “It scales”
Change Says when they would do it differently, and how they would migrate Defends every layer for every screen

The cost of a pattern in a team of three is not its cost in a team of thirty. An answer that ignores team size is not yet an architecture answer.

A decision matrix

Situation Reasonable default What it costs
Small app, one or two engineers Feature folders, a repository per data source, local state where it belongs, one simple state holder Logic can drift into widgets as the app grows
Team of five to ten Feature-first structure with presentation, domain and data inside each feature; one agreed state management approach; dependency injection More files per feature; the team has to agree on the approach and keep to it
Several teams Separate Dart packages per feature in a monorepo, a design system package, dependencies that only point one way, lint rules that enforce it Tooling, versioning and code generation across packages; slower changes to shared code
Large legacy app New features in the target structure, old screens migrated when touched, the “god” widgets split first Two styles side by side for a long time

The questions

1. “Walk me through the architecture of an app you built.”

Weak answer: the three layers of Clean Architecture, with no mention of the app.

The answer that scores: the layers as they exist, then the decisions: where state lives, which repositories own which data, where the rules shared between screens sit, and one thing you would change now. The change is the part that scores.

2. “When is an app complex enough for Clean Architecture?”

The answer that scores: when the business rules are complex, a team will maintain it for years, the logic needs heavy testing, or a data source is likely to be replaced. It is overkill for a prototype or a one-screen app, and saying so is part of the answer. A pass-through use case that only calls a repository is ceremony, not architecture.

3. “Feature-first or layer-first folders?”

The answer that scores: layer-first (models/, views/, controllers/) is fine for a small app and gets hard to navigate as it grows, because one change touches every folder. Feature-first keeps a change inside one feature, and makes the later split into packages easier. The layers still exist, inside each feature.

4. “Data model or domain entity?”

The answer that scores: the data model mirrors the API or the database, with its serialisation. The domain entity is what the rest of the app uses. Keeping them separate means an API rename changes one mapper instead of every screen. The cost is the mapping code, which is not worth it for a model used in one place.

5. “Which state management approach would you standardise on, and how?”

The answer that scores: compare on ownership, how transitions are represented, what rebuilds, how it is tested, and what migrating away would cost. Prove it on one hard flow first, then write the decision down with examples and lint guidance. Do not rewrite stable features for uniformity. The deeper version is question 6 of Senior Flutter interview questions.

6. “Singleton, service locator or dependency injection?”

The answer that scores: a singleton hides the dependency and keeps state between tests. A service locator like GetIt makes dependencies replaceable but still hidden at the call site. Injection through constructors or providers makes them visible and easy to fake. For a small app a locator can be the pragmatic choice; the cost appears in testing.

7. “How do you split a large app into packages?”

The answer that scores: a core package for networking and shared infrastructure, a design system package, and a package per feature. Features depend on core and the design system, never on each other; they talk through interfaces or the routing layer. A package cannot import what its pubspec.yaml does not declare, so most violations fail the build instead of waiting for a reviewer. lib/src is a convention, not a wall, so add a lint or a dependency check for what the structure cannot express.

The follow-up: “Monorepo or several repositories?” A monorepo gives atomic changes across packages and one CI, at the cost of tooling and CI time. Separate repositories give clear ownership and access control, at the cost of version drift and cross-repository refactoring.

8. “How do you keep the architecture from eroding?”

The answer that scores: mechanisms, not documents. Packages that make forbidden imports impossible, custom lint rules for what packages cannot express, a short written record of each decision and why, and review that checks the rule instead of arguing it again. A rule in a wiki erodes. A rule in the build holds.

9. “How do you handle feature flags without slowing the app or growing the binary?”

The answer that scores: typed flags with an owner, a default and an expiry date, a safe value when the remote config is unavailable, and completed flags removed. Code behind a disabled flag still ships in the binary unless it is excluded at build time or loaded later through a supported mechanism. A remote flag must never be the only thing protecting an action the server should authorise.

10. “How do you test this architecture?”

The answer that scores: most tests on the logic, without Flutter: repositories with fake data sources, state holders driven by events with time controlled. Widget tests for screens with the state holder faked. A few integration tests for the flows that make money. If the architecture makes one of those hard, that is the architecture telling you something. Include the late result: a read that finishes after the user switched accounts must change nothing.

Worked example: the cart

Prompt: “In an e-commerce app, the cart must be reachable from any screen and survive an app restart. Where does it live?”

Strong answer: “One owner: a CartRepository that holds the cart, writes every change to local storage, and exposes it as a stream or a listenable. It is created once at the app root and provided to the widget tree, so any screen can read it and nobody keeps a copy. Screens read it and send commands like add and remove. They never change a list they were handed. Restoring after a restart is loading from storage, not replaying UI state.”

Push: “The user signs out and another user signs in on the same phone.” Now the cart belongs to an account, not to the app. Storage is partitioned by user, sign-out clears or detaches the old cart according to a product decision, and the repository is recreated for the new session.

Push: “A response from the old account arrives after the switch.” This is the bug the first sentence is really guarding against. Give every async result a ticket and drop the ones that are stale:

abstract interface class CartStore {
  Future<Map<String, int>> read(String accountID);
  Future<void> write(String accountID, Map<String, int> items);
}

enum CartPhase { signedOut, loading, ready, saving, failed }

class CartSession {
  CartSession(this._store);
  final CartStore _store;
  String? _accountID;
  int _generation = 0;
  Map<String, int> _items = const {};
  CartPhase _phase = CartPhase.signedOut;

  CartPhase get phase => _phase;
  Map<String, int> get items => _items;

  Future<void> selectAccount(String? id) async {
    final ticket = ++_generation;
    _accountID = id;
    _items = const {};
    _phase = id == null ? CartPhase.signedOut : CartPhase.loading;
    if (id == null) return;
    try {
      final restored = await _store.read(id);
      if (ticket != _generation) return;
      _items = Map.unmodifiable(restored);
      _phase = CartPhase.ready;
    } on Exception {
      if (ticket == _generation) _phase = CartPhase.failed;
    }
  }

  Future<void> retry() async {
    final id = _accountID;
    if (id == null || _phase != CartPhase.failed) return;
    await selectAccount(id);
  }

  Future<void> add(String productID, int quantity) async {
    final owner = _accountID;
    if (owner == null || _phase != CartPhase.ready) {
      throw StateError('Cart is not ready for another mutation');
    }
    final ticket = _generation;
    final next = Map<String, int>.from(_items);
    next[productID] = (next[productID] ?? 0) + quantity;
    _phase = CartPhase.saving;
    try {
      await _store.write(owner, Map.unmodifiable(next));
      if (ticket != _generation) return;
      _items = Map.unmodifiable(next);
      _phase = CartPhase.ready;
    } on Exception {
      if (ticket == _generation) _phase = CartPhase.failed;
    }
  }
}

Two decisions in there are worth saying out loud. A write that started under the old account finishes in the old account’s partition and never touches the new account’s cart. And add is rejected while a save is in flight, so rapid taps need a command queue, which costs more code. The store is injected, so a test can complete the old account’s read after the switch and check that nothing changes. failed is not a dead end: retry reloads the account’s stored cart through selectAccount, so a failed save or read recovers from what storage holds. The app root must still call selectAccount when the session changes, because nothing else will.

Push: “The server also has a cart, for the website.” Now there are two copies, and the question is which wins on conflict. Local first for responsiveness, the server as the authority on price and stock, and a merge rule stated before anyone writes the sync code.

Push: “Which local store?” A cart with line items, quantities and an owner is not a setting, so a key-value store is the wrong home for it. The storage choice has to answer a few questions: whether you need transactions and schema migrations, what the size bound is, and what happens when the stored data is corrupt. A cart that cannot be read back should fall back to empty and a server fetch, not crash the launch.

The answer is scored on the first sentence, “one owner”, and on whether the pushes change the design rather than the vocabulary.

The follow-up behind every question

Whatever the question, the next one is a version of: “What would make you change this?” A team that doubles, a second app sharing the code, a move to the web, a state management package that stops being maintained. Have one concrete answer for your own app: the condition, what you would change, and how you would migrate without stopping feature work. The same follow-up closes the Android and iOS architecture rounds, and the platform-neutral version is on Mobile architecture interview questions.

Sources

What to do next

Whichever you pick, answer out loud and time yourself. Saying the answer is the part a page cannot do for you, and it is the part the round scores.

Free guide: the Phoenix Method