Hey Flutter developers! π After spending years building Flutter applications and experimenting with different architectural patterns, I wanted to share my insights about what works, what doesn't, and most importantly - why. Let's dive into the world of architectural patterns and see how they fit (or don't fit) with Flutter's reactive paradigm.
The Foundation: What Makes a Good Architecture?
Before we jump into specific patterns, let's establish what we're looking for in a good architecture:
- Clear separation between business logic and UI
- Consistent pattern usage throughout the app
- Scalability and flexibility for future changes
- Easy to understand and maintain by the team
- Complementing Flutter's reactive framework, not fighting against it
Let's analyze each pattern through these lenses.
MVC in Flutter: The Square Peg in a Round Hole
// Traditional MVC Implementation (Anti-pattern in Flutter)
class UserController {
final UserModel _model;
BuildContext? _context; // π« Red flag: Controller shouldn't hold BuildContext
UserController(this._model);
void setContext(BuildContext context) {
_context = context;
}
void updateUserName(String name) {
_model.name = name;
// π« Directly manipulating view - against Flutter's reactive nature
if (_context != null) {
(_context!.findAncestorStateOfType<_UserViewState>())?.rebuild();
}
}
}
class UserView extends StatefulWidget {
final UserController controller;
final UserModel model; // π« View directly knows about Model
UserView(this.controller, this.model);
@override
_UserViewState createState() => _UserViewState();
}
Why MVC Doesn't Fit Flutter
- Tight Coupling: The View directly knows about the Model, making it hard to swap implementations
- Imperative Updates: Controllers often try to directly manipulate the View, fighting against Flutter's declarative nature
- State Management Confusion: MVC doesn't have a clear answer for Flutter's state management needs
MVP: A Step in the Right Direction
MVP separates concerns better than MVC by introducing a Presenter that handles the communication between View and Model.
// MVP Implementation
abstract class UserViewContract {
void updateUserName(String name);
void showError(String error);
}
class UserPresenter {
final UserViewContract _view;
final UserRepository _repository;
UserPresenter(this._view, this._repository);
Future<void> loadUser() async {
try {
final user = await _repository.getUser();
_view.updateUserName(user.name);
} catch (e) {
_view.showError(e.toString());
}
}
}
class UserScreen extends StatefulWidget {
@override
_UserScreenState createState() => _UserScreenState();
}
class _UserScreenState extends State<UserScreen> implements UserViewContract {
late UserPresenter _presenter;
String _userName = '';
@override
void initState() {
super.initState();
_presenter = UserPresenter(this, UserRepository());
_presenter.loadUser();
}
@override
void updateUserName(String name) {
setState(() => _userName = name);
}
@override
void showError(String error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error))
);
}
}
MVP Pros and Cons in Flutter
β Pros:
- Better separation of concerns
- Testable presentation logic
- Clear contract between View and Presenter
β Cons:
- Still somewhat imperative
- Can lead to massive Presenter classes
- Requires lot of boilerplate code
MVVM: Flutter's Sweet Spot
MVVM aligns perfectly with Flutter's reactive nature through the use of streams or change notifiers.
class UserViewModel extends ChangeNotifier {
final UserRepository _repository;
String _userName = '';
bool _isLoading = false;
String? _error;
UserViewModel(this._repository);
String get userName => _userName;
bool get isLoading => _isLoading;
String? get error => _error;
Future<void> loadUser() async {
_isLoading = true;
_error = null;
notifyListeners();
try {
final user = await _repository.getUser();
_userName = user.name;
} catch (e) {
_error = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
}
class UserScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => UserViewModel(UserRepository()),
child: Consumer<UserViewModel>(
builder: (context, viewModel, _) {
if (viewModel.isLoading) {
return CircularProgressIndicator();
}
if (viewModel.error != null) {
return Text('Error: ${viewModel.error}');
}
return Text('User: ${viewModel.userName}');
},
),
);
}
}
Why MVVM Shines in Flutter
- Reactive by Design: Perfect fit for Flutter's declarative UI
- Clean Separation: ViewModel doesn't know about the View
- Easy State Management: Works great with providers, riverpod, or bloc
- Testability: ViewModels are easily testable
MVI: The New Kid on the Block
MVI (Model-View-Intent) brings unidirectional data flow and immutable state to Flutter.
// State
@freezed
class UserState with _$UserState {
const factory UserState({
required String userName,
required bool isLoading,
String? error,
}) = _UserState;
}
// Intent
sealed class UserIntent {
const UserIntent();
}
class LoadUserIntent extends UserIntent {
const LoadUserIntent();
}
class UpdateUserNameIntent extends UserIntent {
final String newName;
const UpdateUserNameIntent(this.newName);
}
// Model
class UserModel extends StateNotifier<UserState> {
final UserRepository _repository;
UserModel(this._repository) : super(UserState.initial());
void handleIntent(UserIntent intent) async {
switch (intent) {
case LoadUserIntent():
state = state.copyWith(isLoading: true, error: null);
try {
final user = await _repository.getUser();
state = state.copyWith(
isLoading: false,
userName: user.name,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
}
case UpdateUserNameIntent(newName: final name):
state = state.copyWith(userName: name);
}
}
}
MVI Benefits and Challenges
β Benefits:
- Predictable state management
- Easy to debug
- Clear data flow
β Challenges:
- More boilerplate
- Steep learning curve
- Might be overkill for simple apps
Making the Right Choice
After working with all these patterns, here's my practical advice:
Small to Medium Apps: Go with MVVM. It's simple, effective, and works great with Flutter's reactive nature.
Large Apps: Consider MVI if you need strict state management and predictable data flow.
MVP: Good alternative if you're coming from Android/iOS and familiar with it.
MVC: Avoid in Flutter - it fights against the framework's design.
Conclusion
While all patterns have their merits, MVVM emerges as the most natural fit for Flutter development. It provides the right balance of separation of concerns, testability, and reactivity without fighting against Flutter's natural patterns.
Remember, the best pattern is the one that:
- Your team understands
- Scales with your app
- Makes maintenance easier
- Works WITH Flutter, not against it
What's your experience with these patterns in Flutter? Let me know in the comments below! π
I hope this helps you make better architectural decisions in your Flutter projects. If you found this useful, don't forget to follow me for more Flutter content! π
Top comments (1)
Great writeup Arslan! I love your progressive reasoning going first from a traditional MVC pattern then to MVP/MVVM to better fit into Flutter UI's declarative nature.
Its my first time seeing MVI and I can definitely see the benefits of havung specific intents that represent particular actions.
If I might suggest one more mvp/mvvm implementation it would be using streams and rx.dart, and the view can then listen to state changes using StreamBuilder optionally using something like GetIt as a service locator.
Enjoyed the writeup and learning smth new!