Lesson 22 of 77 advanced

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

Architecture patterns are like kitchen layouts. MVC is the classic home kitchen — quick to set up, fine for cooking for 4. MVVM is a restaurant kitchen — clear stations, works well at scale. MVI is a commercial bakery — strict process flow, every output predictable, great for complex orders. Clean Architecture is a multi-restaurant food hall — maximum separation, each section can operate independently. Choosing the wrong kitchen for your restaurant size is an expensive mistake.

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

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. 1. WorkspaceState is immutable — copyWith creates new instances instead of mutating
  2. 2. WorkspaceViewModel extends StateNotifier — all state changes go through 'state =' assignments
  3. 3. loadWorkspaces updates state to loading, then to loaded or error — no intermediate null states
  4. 4. In MVI/BLoC: events (Intents) are sealed class hierarchies — exhaustive pattern matching in the BLoC
  5. 5. ChatBloc.on(handler) registers event handlers — each event type has one handler
  6. 6. Optimistic update: emit the new message immediately before awaiting server confirmation
  7. 7. ProcessPaymentUseCase validates business rules before touching the repository
  8. 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?
Two issues: error handling and a DI best practice violation.
Show answer
Bug 1: No try-catch around repository.getUser() — any network error will crash the BLoC stream and leave the UI in a broken state. Wrap in try-catch and emit UserError(e.toString()) on failure. Bug 2: 'repository' is public — in a BLoC, dependencies should be private (final UserRepository _repository) to enforce encapsulation. External code should not access BLoC internals through its dependencies.

Explain like I'm 5

Imagine you're organizing a toy room. A small room for one kid? Just toss everything in bins by color (MVC). A shared room for a few kids? Label bins by type: cars in one, dolls in another (MVVM). A big playroom for a class? Have separate sections with strict rules about what goes where (Clean Architecture). There's no 'best' layout — it depends on how many kids and how messy things get.

Fun fact

The MVC pattern was invented by Trygve Reenskaug in 1978 for Smalltalk-80. It predates the web, mobile apps, and even personal computers as we know them. The fact that we're still debating it in 2024 Flutter apps shows how fundamental the separation of concerns principle is — the specific pattern matters less than the principle it enforces.

Hands-on challenge

You're interviewing at a company. They ask: 'Our app is a 15-screen fintech app with a team of 4 Flutter devs, expected to live for 3+ years, with complex payment and claims processing logic. What architecture would you recommend and why?' Write a structured answer covering: pattern choice, justification, tradeoffs you're accepting, and folder structure.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery