DEV Community

Samuel Adekunle
Samuel Adekunle

Posted on • Originally published at techwithsam.dev

Clean Architecture in Flutter 2026 - Practical Implementation Guide

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

image

The golden rule: dependencies only point inward (toward data).

View → ViewModel → Repository → Service
Enter fullscreen mode Exit fullscreen mode
  • 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 Future or Stream objects. 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.

  1. 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 { ... }
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. You need to merge data from multiple repositories

  2. The logic is exceedingly complex

  3. 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))
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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'),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The view only has display logic, conditional rendering, animations, and layout. All data logic lives in the ViewModel. COMMON MISTAKES TO AVOID

  1. Putting business logic inside widgets, 

    If your widget is doing data transformation, sorting, or API calls, that belongs in the ViewModel.

  2. 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.

  3. 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.

  4. 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.

  5. 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)