Disclaimer! I know you can easily generate MVVM structure with AI, but understanding the fundamentals and how it works is what differentiates a software engineer from a vibe coder, and teaching the fundamentals is what this article is all about…
If your project folder is starting to feel like spaghetti and you’re scared to touch any file because everything is connected to everything… this article is for you.
In 2026, Clean Architecture is no longer “nice to have.” It’s how professional Flutter teams ship maintainable, testable, and scalable apps.
Today, I’m showing you a practical and tested approach that aligns with Flutter’s own official architecture recommendations: MVVM with a Feature-First folder structure. This is what the Flutter team themselves recommend, and I’ll show you how to actually use it in production.
By the end, you’ll have a clear folder structure, dependency rules, and a real working example you can start using today. Let’s clean this up.
WHY CLEAN ARCHITECTURE STILL MATTERS IN 2026
Flutter has grown massively, but the problems haven’t changed:
Features keep getting added
Teams grow
Requirements change constantly
The Flutter team’s own architecture guide says it best: Separation of Concerns is the most important principle to follow when designing your Flutter app.
When you apply it properly:
UI changes don’t break business logic
You can swap Firebase for Supabase with minimal pain
Testing becomes actually enjoyable
New developers can be onboarded in days instead of weeks
The key in 2026 is combining MVVM (the pattern Flutter officially recommends) with Feature-First folder organisation — grouping files by feature, not by type.
Flutter’s guide breaks every feature down into four key components: Views, View Models, Repositories, and Services. That’s exactly what we’re building today.
THE RECOMMENDED FOLDER STRUCTURE
Here’s the practical structure I recommend, and it’s built around Flutter’s official MVVM guidance:
lib/
├── core/ # Never depends on features
│ ├── di/ # Dependency Injection (Riverpod)
│ ├── network/
│ ├── utils/
│ ├── theme/
│ └── constants.dart
│
├── features/
│ ├── auth/
│ ├── notes/ # ← Example feature (we'll build this)
│ ├── tasks/
│ └── profile/
│
└── shared/ # Shared widgets, models if needed
Inside each feature (e.g. notes/), we follow Flutter's own MVVM layers:
notes/
├── presentation/ # UI layer (Flutter's recommended term)
│ ├── screens/ # Views — widget compositions
│ ├── widgets/
│ └── view_models/ # ViewModels — logic + UI state
│
├── domain/ # Optional: Domain layer for complex use-cases
│ ├── entities/
│ ├── repositories/ # Abstract contracts (interfaces)
│ └── use_cases/ # Interactors — only add when needed
│
└── data/ # Data layer
├── models/ # DTOs — raw API/DB response shapes
├── services/ # One class per data source (REST, local, platform)
└── repositories/ # Implementations of abstract contracts
Notice: Flutter’s official guide uses the term Services for what many tutorials call datasources. Services wrap individual API endpoints into a single class per data source. Repositories sit above them and handle business logic such as caching, error handling, and retries.
MVVM + DEPENDENCY RULES (The Flutter-Official Way)
Flutter’s architecture guide recommends MVVM (Model-View-ViewModel). Here’s how it maps:
The golden rule: dependencies only point inward (toward data).
View → ViewModel → Repository → Service
Views contain only display logic: if-statements to show/hide widgets, animation logic, and layout based on screen size. No business logic in widgets.
ViewModels contain all the logic: filtering, sorting, transforming data, managing UI state, and exposing commands that views call on button presses.
Repositories are the single source of truth for your app’s data. They handle caching, error handling, retry logic, and polling. One repository per data type.
Services are the lowest layer; they wrap external endpoints (REST APIs, local files, platform APIs) and expose
FutureorStreamobjects. They hold no state.
Views and ViewModels have a one-to-one relationship.
Repositories and ViewModels have a many-to-many relationship — one ViewModel can use several repositories, and one repository can serve many ViewModels.
This is Dependency Inversion in action, and it’s what Flutter officially recommends.
PRACTICAL EXAMPLE — Building a Notes Feature
Let’s implement this together, top to bottom.
- Services Layer (lowest — wraps data sources)
// data/services/notes_remote_service.dart
class NotesRemoteService {
final http.Client _client;
Future<List<Map<String, dynamic>>> fetchNotes() async {
final response = await _client.get(Uri.parse('/api/notes'));
return jsonDecode(response.body) as List<Map<String, dynamic>>;
}
}
// data/services/notes_local_service.dart
class NotesLocalService {
// Wraps local storage - SharedPreferences, SQLite, Hive etc.
Future<List<Map<String, dynamic>>> getCachedNotes() async { ... }
Future<void> cacheNotes(List<Map<String, dynamic>> notes) async { ... }
}
Services are thin. They do one job: talk to the data source and return raw data.
2. Repository Layer (source of truth — handles business logic)
// domain/repositories/notes_repository.dart
abstract class NotesRepository {
Stream<List<Note>> watchNotes();
Future<void> saveNote(Note note);
Future<void> deleteNote(String id);
}
// data/repositories/notes_repository_impl.dart
class NotesRepositoryImpl implements NotesRepository {
final NotesRemoteService _remote;
final NotesLocalService _local;
@override
Stream<List<Note>> watchNotes() async* {
// Serve from cache first, then fetch fresh data
final cached = await _local.getCachedNotes();
yield cached.map(Note.fromMap).toList();
final fresh = await _remote.fetchNotes();
await _local.cacheNotes(fresh);
yield fresh.map(Note.fromMap).toList();
}
}
The repository handles caching logic, error handling, and data transformation. The ViewModel never talks directly to services.
3. Domain Layer — Use-Cases (optional — add when needed)
Flutter’s guide recommends adding use cases only when:
You need to merge data from multiple repositories
The logic is exceedingly complex
The logic will be reused across multiple ViewModels
Don’t add use-cases by default — that’s over-engineering. Add them when you spot repetition.
// domain/use_cases/get_sorted_notes.dart
class GetSortedNotesUseCase {
final NotesRepository _notes;
final UserRepository _user; // merges data from two repositories
Stream<List<Note>> call() {
return _notes.watchNotes().map((notes) =>
notes..sort((a, b) => b.createdAt.compareTo(a.createdAt))
);
}
}
4. ViewModel Layer (UI logic + state)
// presentation/view_models/notes_view_model.dart
@riverpod
class NotesViewModel extends _$NotesViewModel {
@override
Future<List<Note>> build() async {
// Watch the repository - reactive updates automatically
return ref.watch(notesRepositoryProvider).watchNotes().first;
}
// Commands - exposed to the View for button presses etc.
Future<void> deleteNote(String id) async {
await ref.read(notesRepositoryProvider).deleteNote(id);
ref.invalidateSelf();
}
Future<void> saveNote(Note note) async {
await ref.read(notesRepositoryProvider).saveNote(note);
ref.invalidateSelf();
}
}
ViewModels expose commands — Dart functions that the view calls in response to user interaction. The view never knows what happens inside. That’s the command pattern in action.
5. View Layer (widgets — display only)
// presentation/screens/notes_screen.dart
class NotesScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final notesAsync = ref.watch(notesViewModelProvider);
return Scaffold(
body: notesAsync.when(
data: (notes) => NotesList(
notes: notes,
// Pass commands from ViewModel - view doesn't know implementation
onDelete: (id) => ref.read(notesViewModelProvider.notifier).deleteNote(id),
),
loading: () => const CircularProgressIndicator(),
error: (e, _) => Text('Error: $e'),
),
);
}
}
The view only has display logic, conditional rendering, animations, and layout. All data logic lives in the ViewModel. COMMON MISTAKES TO AVOID
Putting business logic inside widgets,
If your widget is doing data transformation, sorting, or API calls, that belongs in the ViewModel.Making repositories depend on each other,
Flutter's guide is clear: repositories should never be aware of each other. If you need data from two repositories, combine them in the ViewModel or in a use case.Adding use-cases everywhere by default,
The domain layer is optional. Flutter's own guide says to add use-cases only when needed - not for every feature. Unnecessary use-cases add boilerplate and cognitive load without benefit.Skipping Services - going directly from,
Repository to HTTP Services are what make your repositories testable. Without them, you can't mock your data layer properly. One service per data source is the official recommendation.Ignoring Dependency Injection,
Riverpod 3+ makes this clean and type-safe. Every component, service, and ViewModel should be injected, not instantiated directly.
Want the complete clean architecture starter template with the full Notes feature already implemented — including Riverpod 3+, services, repositories, a ViewModel, tests, and the full folder structure aligned with Flutter’s official guide?
It’s free in my newsletters. Just comment “CLEAN” below or use this link and I’ll send you my weekly newsletter, which will contain the GitHub repo link.
FINAL TIPS & WHEN TO START
Don’t rewrite your entire project. Start applying this to your next new feature.
Refactor one screen at a time, extract your services, then your repository, then lift logic into a ViewModel.
The official Flutter guide puts it well: these are guidelines, not rigid rules. Adapt them to your project’s actual needs. A small solo project might not need a domain layer at all. A large team app probably will.
Clean architecture isn’t about being perfect. It’s about making your future self and your teammates thankful they didn’t have to dig through a 500-line widget to find a bug.
That’s the practical way to implement Flutter architecture: MVVM with Feature-First organisation, aligned with what the Flutter team themselves recommend.
Views display. ViewModels manage state and logic. Repositories are your source of truth. Services talk to the outside world. Domain use-cases come in only when you genuinely need them.
If this article gave you clarity, hit the like button and subscribe for more production-grade Flutter content.
Now go organise that project.
I’ll see you in the next one — peace! ✌️
Samuel Adekunle
Tech With Sam
https://youtube.com/@techwithsam

Top comments (0)