Engineering

Flutter Architecture: Layers, Widgets and the Element Tree

Flutter is a stack of layers, each written in terms of the one below it — and each one you can drop down to. Understanding that stack is what separates apps that scale from apps that get rewritten.

Appix Engine 9 min read
Flutter Architecture: Layers, Widgets and the Element Tree

A layered system, all the way down

Flutter's architecture is deliberately layered, and every layer is replaceable by the one beneath it. Nothing is hidden behind a wall you can't get past — which is unusual, and it's the main reason large Flutter codebases stay workable.

  • Embedder — platform-specific entry point. Sets up the surface, the event loop, and the thread the engine runs on. Written in Java/Kotlin, Objective-C/Swift, or C++.
  • Engine — mostly C++. Hosts the Dart runtime, the graphics stack (Impeller/Skia), text layout and file/network plumbing.
  • Framework — the Dart code you actually import: foundation, rendering, widgets, and the Material and Cupertino libraries on top.
  • Your app — which is just more widgets.

The practical consequence: when a Material widget doesn't do what you need, you don't file a bug and wait. You drop to the widgets layer and compose it yourself. When the widgets layer doesn't do what you need, you write a custom RenderObject. Each escape hatch is one level down, not off a cliff.

Everything is a widget — including the things that aren't UI

Padding is a widget. Alignment is a widget. Theme, gesture handling, scroll physics, even the app itself — all widgets. This feels strange for about a week and then becomes the thing you miss in every other framework, because composition replaces configuration.

Instead of a button with thirty properties, you have a small button composed of padding, decoration, ink response and a gesture detector — each of which you can swap independently.

// Composition over configuration
Widget build(BuildContext context) {
  return Padding(
    padding: const EdgeInsets.all(16),
    child: DecoratedBox(
      decoration: const BoxDecoration(color: Color(0xFF1478F2)),
      child: GestureDetector(
        onTap: _submit,
        child: const Text('Send enquiry'),
      ),
    ),
  );
}

StatelessWidget vs StatefulWidget

A StatelessWidget is a pure function of its inputs: same constructor arguments, same output. A StatefulWidget is still immutable — but it creates a companion State object that survives rebuilds and lives on the element tree.

That split is the source of the most common beginner bug: putting state in the widget instead of the State object, then wondering why it resets. The widget is thrown away on every rebuild. The State is not.

Rule of thumb we apply on client projects: if a value must survive a rebuild, it belongs in State or in a state-management layer above the widget — never in a field on the widget itself.

BuildContext is a handle on the element tree

BuildContext is not a bag of utilities; it is the element's position in the tree. That's why Theme.of(context) works — it walks up from that position looking for the nearest matching InheritedWidget.

It's also why using a context after an async gap is dangerous: the element may no longer be mounted. Every use_build_context_synchronously lint you've suppressed is this problem waiting to happen.

How we structure a production app

Flutter is unopinionated about app architecture, which means the discipline has to come from you. The structure we use on client work, and hand over documented:

  • Feature-first folders, not layer-first. features/checkout/ containing its own data, domain and presentation — so a feature can be deleted in one action.
  • A repository layer between the UI and the network, so screens never touch an HTTP client and tests never touch the network.
  • Immutable state objects with explicit loading/error/data cases, so the UI can't render an impossible combination.
  • Widgets that only render. If a widget file contains business rules, that logic has no unit test and it will break silently.

The test that tells you the architecture is right

Can a new engineer delete one feature folder and have the app still compile? If yes, your boundaries are real. If deleting checkout breaks the profile screen, you have a distributed monolith in a widget tree, and it will get slower to change every month.

That's the question we ask during code audits, and it predicts the health of a Flutter codebase better than any metric a linter produces.

Building something with Flutter?

We'll tell you honestly whether Flutter is right for it — including when it isn't. Free 30-minute call, no pitch deck.