A new Flutter project can become complicated before the first feature works. There are folders to name, state management packages to compare, and patterns to choose. It is easy to mistake those decisions for progress.
A more useful starting point is one small feature with clear responsibilities. Consider a reading list: load saved articles, display them, and let the reader save another one. That is enough to discover the boundaries your application actually needs.
Start with responsibilities
Flutter’s architecture guide separates presentation from data, with views and view models on one side, and repositories and services on the other. These are useful responsibilities to recognize even if your project uses different names. See the official Flutter architecture guide.
For a reading list, the split could look like this:
- View: displays articles, a loading indicator, an empty state, or an error.
- State holder: decides what the screen should display and responds to user actions.
- Repository: provides the saved articles in the format the feature understands.
- Service: communicates with the database or API.
The important question is whether a change to your API response forces you to edit widget code. If it does, the boundary probably needs attention.
Give the feature a small interface
Begin with the operation the screen needs. Here is a deliberately small Dart example:
class SavedArticle {
const SavedArticle({required this.id, required this.title});
final String id;
final String title;
}
abstract interface class ReadingListRepository {
Future<List<SavedArticle>> load();
}
class InMemoryReadingListRepository implements ReadingListRepository {
@override
Future<List<SavedArticle>> load() async {
return const [
SavedArticle(id: 'first-note', title: 'A useful first feature'),
];
}
}
This interface does not need a generic base repository, a networking framework, or a dependency injection package. It gives you a seam: production code can load remote data while a test can return a predictable list.
Keep the example’s limitation in mind. It only loads articles. Saving, caching, and conflict handling should be designed when the feature actually needs them.
Make every screen state intentional
A list of articles is only one state. A usable feature also accounts for the moments before and around that result.
| State | What the reader should see |
|---|---|
| Loading | A clear indication that the request started |
| Empty | An explanation and a useful next action |
| Loaded | The reading list |
| Failed | A readable error and a way to try again |
| Refreshing | Existing content with a refresh indicator |
Refreshing deserves special care. Throwing away useful content just because a new request started makes the screen feel less reliable. Decide whether your feature should keep the last successful result while refreshing.
Organize around the change you expect
For a small feature, an understandable folder layout might be enough:
lib/
reading_list/
saved_article.dart
reading_list_repository.dart
reading_list_state.dart
reading_list_screen.dart
Split it further when the files or responsibilities become difficult to navigate. The test is practical: can another developer find the code that owns a behavior without opening half the project?
A folder structure will not enforce a dependency boundary for you. Code review, imports, and tests still matter.
Test the boundary before adding layers
A useful first test asks what happens when the repository fails. Another checks an empty result. A third checks that a later refresh does not accidentally replace newer data with an older request.
These tests reveal more about the design than testing whether a widget contains a particular padding value.
Start with one feature, make its states explicit, and keep the data contract small. Add another layer when you can explain the problem it solves in the app you have today.