Flutter Lesson 73 of 83 4 min read
Flutter App Architecture: Layers and Folder Structure
Structure a Flutter app with clear layers and a feature-first folder layout: UI, logic and data, and the rules for what depends on what.
On this page
Architecture is deciding where code goes and what may depend on what. A good structure means that when you change how data is stored, no screen has to change, and when a designer moves a button, no business rule is touched.
The problem #
A screen written the quick way does everything:
class _OrdersPageState extends State<OrdersPage> {
List<Order> _orders = [];
Future<void> _load() async {
final response = await http.get(Uri.parse('https://api.example.com/orders')); // networking
final list = jsonDecode(response.body) as List; // parsing
final orders = list.map((e) => Order.fromJson(e)).toList();
orders.removeWhere((o) => o.isCancelled); // business rule
final prefs = await SharedPreferences.getInstance(); // caching
await prefs.setString('orders', response.body);
setState(() => _orders = orders); // UI state
}
// ...and the build method
}
It works. It also cannot be tested without a server, cannot be reused by another screen, and mixes five reasons to change into one file.
Two layers, four kinds of class #
┌──────────────────────────── UI layer ─────────────────────────────┐
│ View widgets: what the user sees and touches │
│ ViewModel the screen's state and the actions it offers │
└───────────────────────────────┬───────────────────────────────────┘
│ calls
┌──────────────────────────── Data layer ───────────────────────────┐
│ Repository the single source of truth for one kind of data │
│ Service talks to one outside system: an API, a database │
└───────────────────────────────────────────────────────────────────┘
| Class | Responsibility | Knows about |
|---|---|---|
| View | Displays state, forwards user actions | Its view model |
| ViewModel | Holds the screen’s state, turns actions into calls | Repositories |
| Repository | Decides where data comes from (network or cache), converts raw data into models, enforces rules | Services |
| Service | Wraps one data source and returns raw results | Nothing in the app |
The models (Order, User, Product) are plain immutable classes used by every layer.
The dependency rule #
Dependencies point downwards only.
- A view never calls a service or a repository directly.
- A repository never imports a widget.
- A service knows nothing about the rest of the app.
Following this one rule gives you most of the benefit. You can replace the API with a different one by changing a service. You can test a view model with a fake repository. You can redesign a screen without reading any data code.
Folder structure #
Group by feature first, then by layer inside the feature.
lib/
main.dart # entry point: creates dependencies, runs the app
app.dart # MaterialApp, theme, router
features/
orders/
data/
order_api.dart # service
order_repository.dart # repository
domain/
order.dart # model
ui/
orders_page.dart # view
orders_view_model.dart # view model
widgets/
order_tile.dart
auth/
data/
domain/
ui/
core/
network/
api_client.dart # shared HTTP setup
storage/
key_value_store.dart
theme/
app_theme.dart
widgets/ # widgets shared by several features
error_view.dart
primary_button.dart
utils/
formatters.dart
test/
features/
orders/
order_repository_test.dart
orders_view_model_test.dart
orders_page_test.dart
Everything about orders is in one folder. Deleting a feature means deleting a folder. Two developers working on different features rarely touch the same files.
The alternative, layer-first (lib/models/, lib/screens/, lib/services/), is fine for a small app and common in tutorials. In a large app, one feature ends up spread across six distant folders.
What is the “domain” layer? #
Some architectures add a third layer between UI and data, holding use cases: one class per business operation, such as PlaceOrder or GetNearbyShops. It is useful when:
- Logic combines several repositories.
- The same logic is needed by several view models.
- The rules are complicated enough to deserve their own tests.
For most apps, start without it. Add a use case when a view model starts to contain logic that another screen also needs.
Naming #
| Thing | Convention | Example |
|---|---|---|
| Files | snake_case.dart | order_repository.dart |
| A screen | ...Page or ...Screen | OrdersPage |
| State holder | ...ViewModel, ...Notifier, ...Cubit | OrdersViewModel |
| Repository | ...Repository | OrderRepository |
| Service | ...Api, ...Service, ...Dao | OrderApi |
Pick one set and use it everywhere. Consistency lets anyone find a file without searching.
How much is enough? #
| App | Reasonable structure |
|---|---|
| A learning project, one to three screens | Widgets with setState. One file per screen |
| A small app, a handful of screens | Views, view models (ChangeNotifier), one repository per data type |
| A product with a team | Full feature-first layout, repositories, services, dependency injection, tests per layer |
Do not build the third for an app that needs the first. Structure should grow with the app, and moving code into layers later is straightforward.
Signs the structure is working #
- You can describe where any piece of code lives without looking.
- A change to the API touches files in
data/only. - View models can be tested without Flutter widgets.
- Views contain almost no
ifstatements about business rules. - New features follow the same pattern as existing ones.
The next lesson builds one feature this way, end to end.
Try it yourself #
Take an app you built earlier in the course and sort its code into four groups: views, state, repositories and services. Write down each place where a widget makes a network call or reads storage directly. Those are the lines to move first.