MVVM vs MVI vs Clean vs MVC — Tradeoffs
How to Answer 'Why Did You Choose This Architecture?' Without Being Dogmatic
Open interactive version (quiz + challenge)Real-world analogy
What is it?
MVVM, MVI, Clean Architecture, and MVC are organizational patterns for Flutter codebases. Each makes different tradeoffs between simplicity, testability, scalability, and overhead. A senior engineer can articulate when each is appropriate and why — not which one is 'correct'.
Real-world relevance
In a SaaS collaboration app: the onboarding flow uses simple StatefulWidget (MVC-style) for quick iteration. The workspace dashboard uses Riverpod StateNotifier (MVVM) for reactive state. The real-time chat uses BLoC (MVI) for auditable message state transitions. The payment processing module uses Clean Architecture with Use Cases for isolated business logic testing. One app, multiple patterns applied pragmatically.
Key points
- MVC — Model View Controller — Controller handles input, updates Model, View observes Model. Simple, fast to write, works for small apps. Problem: the Controller becomes a 'Massive View Controller' — logic, networking, and UI code all merge into one class. In Flutter, StatefulWidget with setState() is effectively MVC. Fine for prototypes and forms with simple state.
- MVVM — Model View ViewModel — ViewModel holds UI state and business logic, exposes observables (streams, ValueNotifier, ChangeNotifier). View binds to ViewModel and renders state. No direct View→Model dependency. Flutter's ChangeNotifier + Provider and Riverpod's StateNotifier are MVVM. Good balance of testability and simplicity. The dominant pattern in mid-size apps.
- MVI — Model View Intent — Unidirectional data flow: View emits Intents (user actions) → ViewModel processes → emits a new immutable State → View renders state. BLoC is Flutter's MVI implementation (Events → BLoC → States). Predictable, debuggable, great for complex state. Overhead is justified in large teams or feature-heavy screens. Every state transition is explicit and auditable.
- Clean Architecture — Separates code into layers: Presentation (UI/ViewModel), Domain (Use Cases, Entities, Repository interfaces), Data (Repository implementations, DTOs, API). Each layer has one direction of dependencies — inward. Maximum testability. Maximum flexibility. Maximum initial overhead. Best for apps with 5+ developers, long maintenance lifetimes, or enterprise requirements.
- When to Choose MVVM — Use MVVM when: the app is mid-size (5-20 screens), the team is 1-3 developers, you need testability without full Clean Architecture overhead, state is per-screen rather than cross-cutting. Provider + ChangeNotifier or Riverpod StateNotifier are excellent MVVM implementations. Don't add layers you don't need.
- When to Choose BLoC/MVI — Use BLoC when: multiple events can trigger the same state transition, you need state history (for undo), complex async sequences (login → fetch profile → check permissions), large teams where explicit event types serve as documentation, or you need flutter_bloc's built-in RxDart-style stream operators.
- When to Choose Clean Architecture — Use Clean Architecture when: the app will live for 3+ years, domain logic is complex and shared across multiple entry points (mobile + web + desktop), the team is 5+, you need to swap data sources (change from REST to GraphQL, add offline support), or business rules must be tested without any infrastructure.
- The 'Right' Architecture Doesn't Exist — The interview trap is being dogmatic. A senior engineer says: 'It depends on team size, project lifetime, complexity, and testability requirements.' A junior says 'BLoC is always best' or 'Clean Architecture is the only correct way.' Pragmatism beats purity. Over-engineering a simple app is as bad as under-engineering a complex one.
- Migration Reality — Most production apps start simple and grow. A pragmatic approach: start with MVVM (Provider or Riverpod), extract Use Cases when business logic becomes complex, add Repository abstraction when you need multiple data sources. Don't add Clean Architecture layers until the pain justifies the overhead.
- Shared Patterns Across All Architectures — Regardless of architecture: keep UI logic out of business logic, keep network code out of widgets, use dependency injection, write business logic tests without Flutter. These four rules improve any codebase. The 'architecture' label is secondary to applying these fundamentals consistently.
- Interview Answer Framework — Answer 'Why this architecture?' with: (1) What was the team/project context? (2) What were the key requirements (testability, team size, long-term maintenance)? (3) What were the tradeoffs you accepted? (4) What would you change with hindsight? This structured answer demonstrates senior-level thinking, not pattern evangelism.
Code example
// === MVVM with Riverpod StateNotifier ===
// State
class WorkspaceState {
final List<Workspace> workspaces;
final bool isLoading;
final String? error;
const WorkspaceState({this.workspaces = const [], this.isLoading = false, this.error});
WorkspaceState copyWith({List<Workspace>? workspaces, bool? isLoading, String? error}) =>
WorkspaceState(workspaces: workspaces ?? this.workspaces, isLoading: isLoading ?? this.isLoading, error: error);
}
// ViewModel (StateNotifier)
class WorkspaceViewModel extends StateNotifier<WorkspaceState> {
final WorkspaceRepository _repository;
WorkspaceViewModel(this._repository) : super(const WorkspaceState());
Future<void> loadWorkspaces() async {
state = state.copyWith(isLoading: true);
try {
final workspaces = await _repository.getAll();
state = state.copyWith(workspaces: workspaces, isLoading: false);
} catch (e) {
state = state.copyWith(error: e.toString(), isLoading: false);
}
}
}
// === MVI with BLoC ===
// Intent (Event)
abstract class ChatEvent {}
class SendMessage extends ChatEvent { final String content; SendMessage(this.content); }
class LoadHistory extends ChatEvent { final String channelId; LoadHistory(this.channelId); }
// State
abstract class ChatState {}
class ChatInitial extends ChatState {}
class ChatLoading extends ChatState {}
class ChatLoaded extends ChatState { final List<Message> messages; ChatLoaded(this.messages); }
class ChatError extends ChatState { final String message; ChatError(this.message); }
// BLoC (processes intents → emits states)
class ChatBloc extends Bloc<ChatEvent, ChatState> {
final ChatRepository _repository;
ChatBloc(this._repository) : super(ChatInitial()) {
on<LoadHistory>(_onLoadHistory);
on<SendMessage>(_onSendMessage);
}
Future<void> _onLoadHistory(LoadHistory event, Emitter<ChatState> emit) async {
emit(ChatLoading());
try {
final messages = await _repository.getMessages(event.channelId);
emit(ChatLoaded(messages));
} catch (e) {
emit(ChatError(e.toString()));
}
}
Future<void> _onSendMessage(SendMessage event, Emitter<ChatState> emit) async {
// Optimistic update — add to list immediately, confirm later
if (state is ChatLoaded) {
final current = (state as ChatLoaded).messages;
final optimistic = Message(content: event.content, isPending: true);
emit(ChatLoaded([...current, optimistic]));
}
await _repository.sendMessage(event.content);
}
}
// === Clean Architecture — all three layers visible ===
// Domain
abstract class PaymentRepository { Future<Receipt> processPayment(PaymentRequest request); }
class ProcessPaymentUseCase {
final PaymentRepository _repo;
ProcessPaymentUseCase(this._repo);
Future<Receipt> call(PaymentRequest request) async {
if (request.amount <= 0) throw ValidationException('Amount must be positive');
if (!request.currency.isSupported) throw ValidationException('Unsupported currency');
return _repo.processPayment(request);
}
}
// Data
class PaymentRepositoryImpl implements PaymentRepository {
final Dio _dio;
PaymentRepositoryImpl(this._dio);
@override
Future<Receipt> processPayment(PaymentRequest request) async {
final response = await _dio.post('/payments', data: request.toJson());
return Receipt.fromJson(response.data);
}
}Line-by-line walkthrough
- 1. WorkspaceState is immutable — copyWith creates new instances instead of mutating
- 2. WorkspaceViewModel extends StateNotifier — all state changes go through 'state =' assignments
- 3. loadWorkspaces updates state to loading, then to loaded or error — no intermediate null states
- 4. In MVI/BLoC: events (Intents) are sealed class hierarchies — exhaustive pattern matching in the BLoC
- 5. ChatBloc.on(handler) registers event handlers — each event type has one handler
- 6. Optimistic update: emit the new message immediately before awaiting server confirmation
- 7. ProcessPaymentUseCase validates business rules before touching the repository
- 8. PaymentRepositoryImpl handles only HTTP concerns — no business logic lives here
Spot the bug
class UserBloc extends Bloc<UserEvent, UserState> {
final UserRepository repository;
UserBloc(this.repository) : super(UserInitial()) {
on<LoadUser>((event, emit) async {
final user = await repository.getUser(event.id);
emit(UserLoaded(user));
});
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Flutter Architecture — Official Docs (Flutter Official)
- Very Good Ventures Architecture Guide (Very Good Ventures)
- BLoC Architecture (BLoC Library)
- Riverpod Architecture (Riverpod)
- Flutter MVVM Tutorial (Flutter Official)