Introduction to App Architecture
Introduction to App Architecture
As your Flutter application grows beyond a handful of screens, you will quickly face a hidden enemy: spaghetti code. Logic for fetching data, transforming it, and rendering it all lives inside build() methods, widgets become impossible to test, and a single change breaks five unrelated things. App architecture is the set of structural decisions you make up front to prevent this collapse — deciding how responsibilities are divided, how data flows, and how components communicate.
This lesson introduces the why behind architecture patterns. You do not need to memorise any specific pattern yet; instead, focus on understanding the three core problems every pattern is designed to solve: testability, scalability, and maintainability.
The Spaghetti-Code Problem
Consider a screen that loads a list of users from a REST API, filters them, and displays them. Without architecture, a developer often puts everything in one place:
Anti-Pattern: Everything in the Widget
// BAD: A widget that does too much
class UserListScreen extends StatefulWidget {
const UserListScreen({super.key});
@override
State<UserListScreen> createState() => _UserListScreenState();
}
class _UserListScreenState extends State<UserListScreen> {
List<Map<String, dynamic>> _users = [];
bool _isLoading = false;
String _errorMessage = '';
@override
void initState() {
super.initState();
_fetchAndFilter();
}
// Business logic, HTTP calls, and error handling all mixed together
Future<void> _fetchAndFilter() async {
setState(() => _isLoading = true);
try {
final response = await http.get(Uri.parse('https://api.example.com/users'));
final data = jsonDecode(response.body) as List;
setState(() {
_users = data
.map((e) => e as Map<String, dynamic>)
.where((u) => u['active'] == true) // business rule hidden here
.toList();
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = 'Failed to load users: $e';
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) return const CircularProgressIndicator();
if (_errorMessage.isNotEmpty) return Text(_errorMessage);
return ListView.builder(
itemCount: _users.length,
itemBuilder: (_, i) => ListTile(title: Text(_users[i]['name'] as String)),
);
}
}
This code works, but consider what happens six months later:
- You want to write a unit test for the filtering logic — you cannot, because it is buried in a widget that requires a full Flutter test environment.
- A second screen also needs the same user list — you copy-paste the HTTP call, creating duplication.
- The API endpoint changes — you hunt through widget files instead of one data layer.
- A new developer joins and cannot tell where the business rules are.
The Three Pillars Architecture Must Serve
1. Testability
Code that cannot be tested cannot be trusted at scale. When business logic is embedded in widgets, you must spin up the entire Flutter widget tree just to verify that a filter predicate works. A well-architected app extracts that logic into plain Dart classes — classes that can be tested with a simple dart test command, no Flutter framework needed.
2. Scalability
A feature that takes one day to build in a 5-screen app should not take one week in a 50-screen app. When responsibilities are clearly separated, adding a new screen does not require understanding or modifying unrelated parts of the codebase. Teams can work in parallel on different layers without constantly stepping on each other.
3. Maintainability
Apps are maintained far longer than they are initially written. Maintainability means a developer — including your future self — can read a file, immediately understand its single responsibility, and change it with confidence that the blast radius of that change is limited and predictable.
A Well-Structured Alternative
The same user list screen, split across layers, becomes dramatically easier to work with:
Better Pattern: Separated Responsibilities
// 1. DATA LAYER — knows nothing about Flutter widgets
class UserRepository {
final http.Client _client;
UserRepository(this._client);
Future<List<User>> fetchActiveUsers() async {
final response = await _client.get(Uri.parse('https://api.example.com/users'));
if (response.statusCode != 200) throw Exception('Server error ${response.statusCode}');
final data = jsonDecode(response.body) as List;
return data
.map((e) => User.fromJson(e as Map<String, dynamic>))
.where((u) => u.isActive) // business rule lives here, easy to test
.toList();
}
}
// 2. DOMAIN LAYER — pure Dart, zero Flutter imports
class User {
final String id;
final String name;
final bool isActive;
const User({required this.id, required this.name, required this.isActive});
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'] as String,
name: json['name'] as String,
isActive: json['active'] as bool,
);
}
// 3. PRESENTATION LAYER — widget only knows about display
class UserListScreen extends StatelessWidget {
final List<User> users;
const UserListScreen({super.key, required this.users});
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: users.length,
itemBuilder: (_, i) => ListTile(title: Text(users[i].name)),
);
}
}
UserRepository and User contain zero Flutter imports. You can test all of the data-fetching and filtering logic with a plain Dart test and a mock HTTP client — no WidgetTester, no pumpWidget, no overhead.Common Architecture Patterns in Flutter
The Flutter ecosystem has converged around several named patterns, each of which applies the separation-of-concerns principle in slightly different ways:
- MVC (Model-View-Controller) — the classic triad; Controller mediates between Model and View.
- MVP (Model-View-Presenter) — View is passive; Presenter holds all presentation logic.
- MVVM (Model-View-ViewModel) — ViewModel exposes observable streams/state; View reacts. Popular with Provider and Riverpod.
- BLoC (Business Logic Component) — events in, states out; strict unidirectional data flow. Flutter-team-endorsed.
- Clean Architecture — concentric layers (Entities, Use Cases, Interface Adapters, Frameworks) with strict dependency rules.
Summary
Architecture is the discipline of deciding where code lives and why. The spaghetti-code anti-pattern mixes data-fetching, business logic, and UI rendering inside widgets, producing code that is impossible to test in isolation and painful to change. A structured approach separates these concerns into distinct layers — typically data, domain, and presentation — each with a single, clearly defined responsibility. Every named pattern you will learn in this tutorial (MVVM, BLoC, Clean Architecture) is a specific answer to the same three questions: How do I keep this testable? How does this scale? How does a new developer understand and safely modify this code?