Architecture Patterns
MVC, MVP, MVVM, Clean Architecture, Repository pattern
- 01
Explain Clean Architecture in Flutter with layers and responsibilities.
MediumClean Architecture organizes code in concentric layers governed by one rule: source-code dependencies point inward only, never outward.
// ── Domain Layer (pure Dart, zero imports) ───────── class Product { final int id; final String name; final double price; const Product({required this.id, required this.name, required this.price}); } … - 02
How do you structure a large Flutter app by features (feature-first)?
HardFeature-first (vertical slicing) groups files by FEATURE rather than by layer.
// ── Feature-first project structure ─────────────── /* lib/ ├── main.dart ├── app.dart # MaterialApp, router setup ├── core/ … - 03
Compare MVC, MVP, MVVM, and MVI patterns and how they map to Flutter.
MediumAll four organize the relationship between UI and business logic but differ in who calls whom and how data flows.
// ── MVP-style — explicit View interface ── abstract class LoginView { void showLoading(); void showError(String message); void navigateHome(); } … - 04
What does it mean to apply SOLID principles in Flutter?
MediumIn Flutter, Single Responsibility and Dependency Inversion do most of the work; the other three mostly bite at package and interface boundaries.
// ── Single Responsibility ── // ❌ Mixed concerns class UserService { Future<User> fetchUser(int id) async { /* HTTP */ } String formatUserName(User u) => u.name.toUpperCase(); // formatting Future<void> saveToFile(User u) async { /* file IO */ } … - 05
How do you split a large Flutter app into modular packages or workspaces?
HardOnce a Flutter codebase passes ~50k lines or 3+ teams, packing everything into one
lib/becomes painful: long compile cycles, accidental cross-feature imports, no enforced boundaries.# ── Pub workspaces, Dart 3.6+ (recommended for new monorepos) ── # Top-level pubspec.yaml name: my_app_workspace environment: { sdk: ^3.6.0 } workspace: - app … - 06
How do you map between domain entities and data DTOs cleanly?
MediumThe data layer talks JSON; the domain layer talks Dart.
// ── Domain — pure ── class User { final int id; final String fullName; final String? email; final bool active; … - 07
How do you design a typed AppError hierarchy that flows from data layer to UI?
HardEvery layer in a Flutter app can fail differently.
// ── Domain hierarchy ── sealed class AppError implements Exception { final String message; const AppError(this.message); } class NetworkError extends AppError { const NetworkError([super.m = 'No network']); } class TimeoutError extends AppError { const TimeoutError([super.m = 'Timeout']); } class ServerError extends AppError { final int statusCode; const ServerError(this.statusCode, [super.m = 'Server error']); } class UnauthorizedError extends AppError { const UnauthorizedError([super.m = 'Sign in required']); } … - 08
How do you set up dependency injection at scale (composition root, scopes, environments)?
MediumDependency injection at scale is three problems, not one: where the graph is built, how long each object lives, and how it changes per environment.
// ── Composition root with Riverpod ── // 1) App scope final apiBaseUrlProvider = Provider<String>( (_) => const String.fromEnvironment('API_URL', defaultValue: 'https://staging.example.com'), ); … - 09
How do feature flags and staged rollouts fit into a Flutter app's architecture?
MediumFeature flags decouple deploy from release: ship the code dark, flip a switch later for some or all users.
// ── Flag client with bundled defaults ── class FeatureFlags { FeatureFlags._(this._values); final Map<String, dynamic> _values; static late FeatureFlags instance; … - 10
Who decides where the app navigates next — the widget, the state holder, or the router?
MediumDeciding where the user goes next is application logic, while pushing a route is a framework mechanism, and a layered app keeps those two apart.
// ── 1) Global gates: the router reads state, nobody pushes ── final router = GoRouter( refreshListenable: sessionNotifier, // re-evaluates on sign-in/out redirect: (context, state) { final signedIn = sessionNotifier.isSignedIn; final goingToLogin = state.matchedLocation == '/login'; … - 11
When is Clean Architecture the wrong choice for a Flutter app, and what do you keep instead?
MediumLayers exist to isolate change, so a layer you cannot name a change for is pure cost — and on a small app that cost is most of the codebase.
// ── Small app, still testable: two files, one seam ── abstract interface class OrdersRepository { Future<List<Order>> fetch(); } class HttpOrdersRepository implements OrdersRepository { … - 12
You inherit a Flutter app with logic in StatefulWidgets and singletons everywhere. How do you improve it without a rewrite?
HardStrangle it rather than rewrite it: freeze the old shape, build every new feature in the target shape, and migrate old code only when you have a reason to touch it.
// STEP 1 — a seam in front of the legacy code, no behaviour change abstract interface class ProfileRepository { Future<Profile> load(String id); } class LegacyProfileRepository implements ProfileRepository { … - 13
A reviewer rejects your domain entity because it imports package:flutter/material.dart for a Color field — is that pedantic?
MediumNo — that single import reverses the dependency arrow, so the layer that is supposed to be the most stable in the app now compiles only where Flutter exists.
// ❌ domain/order.dart — the arrow now points outward import 'package:flutter/material.dart'; class Order { const Order({required this.id, required this.statusColor}); final String id; … - 14
Your UserRepository has fourteen methods that mirror the REST routes one-for-one and returns Response objects — what would you change?
MediumThat is the HTTP client wearing a repository's name: a repository is a collection-like interface over a domain concept, so its shape comes from what callers need, not from what the backend happens to expose.
// ❌ The HTTP client with a new name abstract interface class UserRepository { Future<Response> getUserV2(int id); Future<Response> getUserOrdersPage(int id, int page); Future<Response> postUserAvatarUpload(int id, FormData body); // ...eleven more, one per route; every caller parses the JSON itself … - 15
Every rule about when an order can be cancelled lives in the Cubit and Order is just a bag of fields — what does that cost you?
MediumThat is an anemic domain model: the entity carries data and nothing else, so the rules that define what an order is get copied into every state holder that touches one.
// ❌ Anemic entity, rules scattered across state holders class Order { Order(this.status, this.paidAt); OrderStatus status; DateTime? paidAt; } … - 16
QA sends a screenshot with the loading spinner and the error banner on screen at the same time — what is wrong with the state model?
MediumThree independent fields —
isLoading,error,items— encode eight combinations while the screen has four legal ones, and the widget is faithfully rendering a state that should never have existed.// ❌ Eight combinations, four of them meaningless class ArticlesState { ArticlesState({this.isLoading = false, this.error, this.items = const []}); final bool isLoading; final AppError? error; final List<Article> items; … - 17
You edit your name on the settings screen and the header still shows the old one until restart — which layer do you fix, and how?
HardFix it in the repository: two screens each fetched their own copy, so there is no single source of truth — make the repository own the cached entity and hand out a stream that every screen listens to.
class ProfileRepository { ProfileRepository(this._api); final ProfileApi _api; Profile? _current; final _changes = StreamController<Profile>.broadcast(); … - 18
Signing out must clear the cart, close the chat socket and drop cached images — how do those features find out without importing each other?
HardDon't let features call each other at all: either one coordinator in the composition root calls a narrow interface that each feature exposes, or auth publishes a domain event and interested features subscribe.
// Each feature exposes one verb and imports nothing from its siblings. abstract interface class SessionScoped { Future<void> onSignedOut(); } class CartRepository implements SessionScoped { … - 19
Placing an order reserves stock, charges the card and clears the cart — which layer owns that sequence, and what happens when the charge fails?
HardA use case owns it — the sequence is business logic spanning three repositories, which is exactly what the domain layer exists for — and since there is no transaction across three services, the failure path is compensation plus idempotency, not a rollback.
sealed class PlaceOrderResult { const PlaceOrderResult(); } class OrderPlaced extends PlaceOrderResult { const OrderPlaced(this.id); final OrderId id; … - 20
Coming from Android you ask where a ViewModel lives in Flutter — yours is rebuilt every frame and loses its state on a tab switch. What is going on?
HardFlutter has no ViewModel lifecycle owner: the lifetime of a state holder is decided by where the object that creates it sits in the element tree, so "rebuilt every frame" means you constructed it inside
build, and "lost on tab switch" means you scoped it under a widget that got unmounted.// ❌ A new instance every frame, and nothing is ever disposed @override Widget build(BuildContext context) { return ChangeNotifierProvider.value( // .value never owns or disposes value: CartViewModel(repo), // constructed inside build child: const CartView(), …