Understanding Flet Architecture
Flet is a modern Python framework that allows you to build interactive multi-platform apps using the same declarative UI approach found in Flutter. While it’s remarkably easy to get a simple app running, real-world projects demand a clear architecture—a set of design patterns and a structured project layout—to keep your code maintainable, testable, and scalable.
Flet architecture isn’t a single prescribed pattern; it’s the art of deciding how to separate concerns, manage state, and organize your files. In this tutorial you’ll learn what architectural patterns work well with Flet, why they matter, how to implement them with practical code examples, and the best practices that professional Flet developers follow.
Why Architecture Matters in Flet Apps
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Without a deliberate architecture, a Flet application can quickly become a tangled mess of UI code mixed with business logic, network calls, and state scattered across event handlers. This leads to:
- Hard to test – You can’t test logic independently from the UI.
- Difficult to maintain – Changing a UI element might break a core calculation.
- Poor reusability – You end up duplicating logic across different views.
- State chaos – The app’s state is hidden inside control properties, making debugging a nightmare.
A well-defined architecture brings clarity: the UI layer only knows how to draw and capture user interactions, while a separate layer handles what happens behind the scenes. Flet’s event-driven model and its support for custom control classes make it an ideal candidate for classic patterns like MVC, ViewModel, or even a simple layered architecture.
Core Design Patterns for Flet
1. Simple Page-Based Architecture (Flat)
For tiny tools or prototypes, you can place all logic inside a single main function or a single page class. This isn’t a “pattern” per se, but it’s the starting point for many developers. Here’s how it looks:
import flet as ft
def main(page: ft.Page):
page.title = "Counter"
count = 0
def increment(e):
nonlocal count
count += 1
text.value = f"Count: {count}"
page.update()
text = ft.Text(f"Count: {count}", size=32)
button = ft.ElevatedButton("Increment", on_click=increment)
page.add(text, button)
ft.app(target=main)
While functional, this approach tightly couples the UI definition, state storage, and event logic. As soon as you need a second screen or a more complex state, it becomes unwieldy.
2. Model-View-Controller (MVC) in Flet
MVC separates the application into three interconnected components:
- Model – Pure data and business rules, completely independent of Flet.
- View – The Flet controls and layout that present the model to the user.
- Controller – Glue that handles user input, updates the model, and refreshes the view.
Below is a complete MVC counter app. Notice how the model knows nothing about Flet, and the view is just a class that builds controls.
# model.py
class CounterModel:
def __init__(self):
self._count = 0
@property
def count(self):
return self._count
def increment(self):
self._count += 1
def decrement(self):
self._count -= 1
# view.py
import flet as ft
class CounterView:
def __init__(self, controller):
self.controller = controller
self.count_text = ft.Text("0", size=32)
self.inc_btn = ft.ElevatedButton("+", on_click=controller.increment)
self.dec_btn = ft.ElevatedButton("-", on_click=controller.decrement)
self.layout = ft.Row(
controls=[self.dec_btn, self.count_text, self.inc_btn],
alignment=ft.MainAxisAlignment.CENTER,
)
def update_count(self, value):
self.count_text.value = str(value)
self.count_text.update()
# controller.py
class CounterController:
def __init__(self, model, view):
self.model = model
self.view = view
def increment(self, e):
self.model.increment()
self.view.update_count(self.model.count)
def decrement(self, e):
self.model.decrement()
self.view.update_count(self.model.count)
# main.py
import flet as ft
from model import CounterModel
from view import CounterView
from controller import CounterController
def main(page: ft.Page):
page.title = "MVC Counter"
model = CounterModel()
controller = CounterController(model, None)
view = CounterView(controller)
controller.view = view # wire back the view
page.add(view.layout)
ft.app(target=main)
With MVC you can now unit-test the CounterModel without any Flet dependency, change the UI styling without touching logic, and even swap the view for a terminal version if needed.
3. ViewModel / State Separation Pattern (MVVM-style)
In more complex apps, a ViewModel (or a dedicated state object) sits between the View and the Model, exposing observable properties that the UI reacts to. Flet doesn’t have built-in two-way binding, but you can simulate it by keeping a plain Python object that notifies the view explicitly.
# viewmodel.py
class TaskViewModel:
def __init__(self):
self.tasks = []
self._listeners = []
def add_listener(self, callback):
self._listeners.append(callback)
def _notify(self):
for cb in self._listeners:
cb()
def add_task(self, description):
self.tasks.append({"desc": description, "done": False})
self._notify()
def toggle(self, index):
self.tasks[index]["done"] = not self.tasks[index]["done"]
self._notify()
# view.py
import flet as ft
class TaskView:
def __init__(self, viewmodel):
self.vm = viewmodel
self.task_list = ft.Column()
self.input_field = ft.TextField(hint_text="New task")
self.add_btn = ft.ElevatedButton("Add", on_click=self.add_clicked)
self.vm.add_listener(self.refresh)
def add_clicked(self, e):
self.vm.add_task(self.input_field.value)
self.input_field.value = ""
self.input_field.update()
def refresh(self):
self.task_list.controls.clear()
for i, task in enumerate(self.vm.tasks):
checkbox = ft.Checkbox(
label=task["desc"],
value=task["done"],
on_change=lambda e, idx=i: self.vm.toggle(idx),
)
self.task_list.controls.append(checkbox)
self.task_list.update()
This pattern works brilliantly when you have multiple views that need to stay in sync. The ViewModel becomes the single source of truth, and the UI simply projects that state.
4. Event-Driven and Reactive Patterns
Flet itself is event-driven: button clicks, text changes, and routing events all invoke callbacks. You can leverage this by building a central event bus or using simple observer callbacks, as shown above. For larger apps, consider a lightweight dispatcher:
class EventBus:
_listeners = {}
@staticmethod
def on(event, callback):
EventBus._listeners.setdefault(event, []).append(callback)
@staticmethod
def emit(event, data=None):
for cb in EventBus._listeners.get(event, []):
cb(data)
This allows decoupled communication: a service can emit "task:updated" and any number of UI components can react, without knowing about each other.
Project Structure Recommendations
Your folder layout should reflect the chosen architecture. Here are three practical structures you can adopt, ordered by project size.
Flat Structure (for tiny apps or prototypes)
my_flet_app/
├── main.py
└── requirements.txt
All logic lives inside main.py. Acceptable only when you have a single screen and fewer than ~100 lines of code.
Modular Structure (for medium apps)
my_flet_app/
├── main.py
├── app/
│ ├── __init__.py
│ ├── views/
│ │ ├── __init__.py
│ │ ├── home.py
│ │ └── settings.py
│ ├── models/
│ │ ├── __init__.py
│ │ └── user.py
│ ├── controllers/
│ │ ├── __init__.py
│ │ └── user_controller.py
│ └── services/
│ ├── __init__.py
│ └── api_client.py
├── assets/
│ └── icon.png
└── requirements.txt
Here each view gets its own file, models are plain data classes, and controllers orchestrate updates. main.py stays lean, only initialising the app and setting up routing.
Scalable Structure (for large teams)
my_flet_app/
├── main.py
├── app/
│ ├── __init__.py
│ ├── core/
│ │ ├── config.py
│ │ └── event_bus.py
│ ├── features/
│ │ ├── auth/
│ │ │ ├── views.py
│ │ │ ├── models.py
│ │ │ ├── controllers.py
│ │ │ └── services.py
│ │ └── dashboard/
│ │ ├── views.py
│ │ └── controllers.py
│ └── shared/
│ ├── widgets.py
│ └── utils.py
├── tests/
│ ├── unit/
│ └── integration/
└── requirements.txt
This structure groups code by feature, making it easy to navigate and work on in parallel. The core package holds cross-cutting concerns, while shared contains reusable UI components.
Best Practices for Flet Architecture
- Keep UI and logic in separate files – Even if you start small, split out business rules early. It saves hours of refactoring later.
- Use Flet’s built-in routing and views – Instead of manually swapping controls, leverage
page.go()andft.Viewto structure navigation. This keeps each screen encapsulated. - Never store business state on UI controls – Don’t rely on
TextField.valueas your primary data store. Push data into a model or ViewModel and let the UI reflect it. - Prefer composition over inheritance – Build custom controls by composing existing Flet controls inside a class, rather than deeply extending them. It’s simpler and less error-prone.
- Write unit tests for models and controllers – Since they don’t depend on Flet, you can test them with plain pytest. Only integration tests need a running Flet session.
- Handle async wisely – When calling external APIs, use
async/awaitand Flet’spage.run_task()or dedicated async handlers to avoid freezing the UI. - Document your architecture choice – Add a short
ARCHITECTURE.mdexplaining the pattern you’ve chosen. New team members will thank you.
Conclusion
Flet gives you the freedom to build apps rapidly, but without a solid architecture, that speed quickly turns into technical debt. By adopting a clear separation of concerns—whether it’s MVC, MVVM, or a simple layered approach—and organising your project into a logical structure, you create applications that are easier to debug, extend, and test. Start with the modular structure shown above, keep your UI declarative and your models pure, and you’ll enjoy a development experience that scales gracefully from a weekend prototype to a full production app.