Flutter Advanced Lesson 92 of 135 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.