Why architecture: the price of a 1000-line widget
One screen sends the request, decodes the JSON, computes the price, shows the error — all in a single file. It works — until the second screen needs the same data.
The problem is not aesthetic, it is entirely practical. In a widget like that:
- There is no test. To check the logic you have to raise the widget with
pumpWidgetand hit the real network. - There is duplication. The second screen rewrites the same request, the same parsing, the same error message.
- Change spreads. When the API renames
price_centstoprice, the fix is not in one place — it is in every file that reads that JSON key. - You cannot make decisions. "Let's add caching" is not a change to one class; it has to be applied in every screen.
Architecture is the answer to these problems: rules about where a piece of code lives and who it may talk to. Folders are a consequence of those rules, not the goal.
class ProductsPage extends StatefulWidget {
const ProductsPage({super.key});
@override
State<ProductsPage> createState() => _ProductsPageState();
}
class _ProductsPageState extends State<ProductsPage> {
List<dynamic> _items = [];
bool _loading = true;
String? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
// Endpoint və token widget-in içində.
final response = await http.get(
Uri.parse('https://api.example.com/v1/products?active=true'),
headers: {'Authorization': 'Bearer $kToken'},
);
if (response.statusCode != 200) {
setState(() {
_error = 'Xəta: ${response.statusCode}';
_loading = false;
});
return;
}
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
setState(() {
_items = decoded['data'] as List<dynamic>;
_loading = false;
});
} catch (e) {
setState(() {
_error = e.toString(); // istifadəçi "SocketException" görür
_loading = false;
});
}
}
@override
Widget build(BuildContext context) {
if (_loading) return const Center(child: CircularProgressIndicator());
if (_error != null) return Center(child: Text(_error!));
return ListView.builder(
itemCount: _items.length,
itemBuilder: (context, i) {
final raw = _items[i] as Map<String, dynamic>;
// Biznes qaydası widget-in içində: qəpik → manat.
final price = (raw['price_cents'] as int) / 100;
return ListTile(
title: Text(raw['title'] as String),
subtitle: Text('$price AZN'),
);
},
);
}
}The "everything in one place" screen. Note the URL, the token, the JSON keys, the business rule (cents → currency) and the error text — all inside the widget.
| Symptom | The real cause | Which layer fixes it |
|---|---|---|
| A second screen needs the same data and the code gets copied | The data-loading logic lives inside the widget | Repository — written once, used by both screens |
| Tests hit the real API | The widget creates the HTTP client itself | Service + DI — a fake implementation is passed in tests |
| The API renamed one key and six files broke | JSON keys are read directly in the UI | DTO + mapper — the change stays in one file |
| The price is formatted differently on two screens | The business rule is duplicated in widgets | Domain model — the rule lives in the model itself |
| Adding an offline cache means opening every screen | The data's source is tied to the UI | Repository — the caching decision is made in one place |
Notice something: not one row in that table says "the code looks ugly". Every row is a measurable cost — lines written, files broken, how long the test suite takes.
That is the right frame for the whole architecture conversation. "Clean architecture" is not a fashion; it is an answer to concrete questions:
- Who will need this data a second time?
- Can I test this logic without a network?
- How many files does this change force me to open?
There is a flip side: writing three layers for every class is also a cost. Throughout this branch each layer is presented with both its benefit and its price — and the final stage has a topic dedicated to knowing when to simplify.
Practice. Open your own project (or a familiar open-source one), find the largest widget file and write down, one by one, which of the five symptoms above it has. One line per symptom: "symptom → which layer would fix it". Done means: you have a list of at least three lines, each naming a concrete file.
📚 Sources and documentation
- App architecture: introductionofficialdocs.flutter.dev
The entry page of the Flutter team's architecture section — it maps out every sub-page.
- Architecture conceptsofficialdocs.flutter.dev
The official explanation of terms like separation of concerns, layers and single source of truth.
- Architecture recommendationsofficialdocs.flutter.dev
"Use clearly defined data and UI layers" sits here, at the highest priority (strongly recommend).