Sparround

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 pumpWidget and 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_cents to price, 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.

dart
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.

SymptomThe real causeWhich layer fixes it
A second screen needs the same data and the code gets copiedThe data-loading logic lives inside the widgetRepository — written once, used by both screens
Tests hit the real APIThe widget creates the HTTP client itselfService + DI — a fake implementation is passed in tests
The API renamed one key and six files brokeJSON keys are read directly in the UIDTO + mapper — the change stays in one file
The price is formatted differently on two screensThe business rule is duplicated in widgetsDomain model — the rule lives in the model itself
Adding an offline cache means opening every screenThe data's source is tied to the UIRepository — 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).