App Architecture: Clean Architecture in Flutter
A code-first guide to applying Uncle Bob Clean Architecture in Flutter: the dependency rule, presentation/domain/data layers, entities, use cases, repositories, dependency injection with Riverpod, and a runnable folder structure.
Published on • August 18, 2026
AI Assistant

Flutter makes it easy to build a demo, and just as easy to watch a real app turn into a tangle of setState calls, direct API calls inside widgets, and business logic embedded in build() methods. Clean Architecture gives you a durable alternative. Popularized in mobile by Fernando Cejas’s 2014 “Architecting Android… the clean way?” and adapted to Flutter by ResoCoder’s TDD course, it applies Robert C. Martin’s dependency rule to three concrete layers: presentation, domain, and data. By the end of this post you will have a runnable feature end-to-end — an entity, a use case, an abstract repository, a remote data source, a Riverpod provider, and the widget that renders it — with every dependency pointing inward.
Prerequisites
- Flutter 3.x with Dart 3.x installed and an editor set up.
- Working knowledge of Dart: classes,
abstract,async/await, andfactoryconstructors. - A passing familiarity with Riverpod (
ProviderScope,Provider,AsyncNotifier) or a comparable DI/state container likeget_itorprovider. - The packages
flutter_riverpod,http, andequatableadded topubspec.yaml:
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.5.0
http: ^1.2.0
equatable: ^2.0.5
The dependency rule: the one law that matters
Everything else in Clean Architecture is a consequence of a single rule: source code dependencies only point inward. The domain layer — entities and use cases — knows nothing about Flutter, HTTP, or databases. The data layer knows how to fetch and serialize but depends on abstractions from the domain. The presentation layer composes both and knows about both. If you ever find an import 'package:flutter/...' or http inside your domain layer, the architecture has already leaked.
This yields the practical payoffs most teams care about: you can unit-test the entire business logic with zero widgets or mocking of I/O, and you can swap a REST API for GraphQL, Firebase, or a local database by changing only the data layer. The abstract repository contract in domain never changes.
The flutter_clean_architecture package (6.2.0 on pub.dev) ships its own twist on this: App, Domain, Data, and Device modules, with View, Controller, and Presenter classes plus a CLI (flutter pub run flutter_clean_architecture:cli create) that scaffolds the whole tree. It is a valid choice. In this post we use Riverpod for state management and DI instead, which is the more common pairing in modern Flutter codebases.
Folder structure
Clean Architecture does not prescribe a folder layout, but a convention makes the layers self-documenting. ResoCoder’s per-feature layout scales best; here is the version we will build:
lib/
├── core/
│ ├── error/failure.dart
│ └── network/api_client.dart
├── features/
│ └── posts/
│ ├── data/
│ │ ├── datasources/post_remote_data_source.dart
│ │ ├── models/post_model.dart
│ │ └── repositories/post_repository_impl.dart
│ ├── domain/
│ │ ├── entities/post.dart
│ │ ├── repositories/post_repository.dart
│ │ └── usecases/get_posts.dart
│ └── presentation/
│ ├── providers/post_providers.dart
│ └── pages/post_list_page.dart
└── main.dart
domain is pure Dart. data knows http, JSON, and models. presentation knows Riverpod and widgets. Notice there is no shared models/ folder at the top — each feature owns its layers, so features can be extracted or deleted as units.
The domain layer: the stable center
The domain layer holds business objects (entities), business rules (use cases), and contracts (abstract repositories). It imports nothing but Dart core and equatable.
Entity
An entity is a plain, immutable business object. No fromJson, no database IDs — JSON conversion is a data-layer concern. Equatable gives us value equality for free.
import 'package:equatable/equatable.dart';
class Post extends Equatable {
const Post({required this.id, required this.title, required this.body});
final int id;
final String title;
final String body;
@override
List<Object?> get props => [id, title, body];
}
Abstract repository
The repository interface is defined here, not in data, so the domain can depend on a contract rather than an implementation (dependency inversion, the D in SOLID). A use case calls PostRepository; it never knows whether the posts came from jsonplaceholder.typicode.com or SQLite.
import 'package:equatable/equatable.dart';
abstract class PostRepository {
Future<List<Post>> getPosts();
}
class PostRepositoryException extends Equatable implements Exception {
const PostRepositoryException(this.message);
final String message;
@override
List<Object?> get props => [message];
}
Use case
A use case orchestrates a single business action. It takes the repository through its constructor and exposes one call/execute method. It contains no HTTP, no JSON, no widgets — and can therefore be tested with a hand-written fake repository.
import '../entities/post.dart';
import '../repositories/post_repository.dart';
class GetPosts {
const GetPosts(this._repository);
final PostRepository _repository;
Future<List<Post>> call() => _repository.getPosts();
}
That is the entire domain for this feature: one entity, one contract, one use case, ~40 lines of pure Dart.
The data layer: where the app meets the outside world
The data layer implements the domain contract and talks to real sources. It operates on models, not entities, so serialization logic never leaks into the domain.
Model
import '../../domain/entities/post.dart';
class PostModel extends Post {
const PostModel({required super.id, required super.title, required super.body});
factory PostModel.fromJson(Map<String, dynamic> json) {
return PostModel(
id: json['id'] as int,
title: json['title'] as String,
body: json['body'] as String,
);
}
}
Remote data source
A thin, dumb layer that only performs HTTP and throws on non-200 responses. Error-to-domain translation happens one level up.
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/post_model.dart';
class PostRemoteDataSource {
const PostRemoteDataSource({required this.client});
final http.Client client;
static const _endpoint = 'https://jsonplaceholder.typicode.com/posts';
Future<List<PostModel>> getPosts() async {
final response = await client.get(Uri.parse(_endpoint));
if (response.statusCode != 200) {
throw Exception('Failed to load posts: ${response.statusCode}');
}
final data = jsonDecode(response.body) as List<dynamic>;
return data
.map((item) => PostModel.fromJson(item as Map<String, dynamic>))
.toList();
}
}
Repository implementation
The implementation composes the data source, catches low-level exceptions, and returns the domain’s List<Post>. It is the single source of truth for the data: future caching, fallbacks, and retries all live here.
import '../../domain/entities/post.dart';
import '../../domain/repositories/post_repository.dart';
import '../datasources/post_remote_data_source.dart';
class PostRepositoryImpl implements PostRepository {
const PostRepositoryImpl({required this.remoteDataSource});
final PostRemoteDataSource remoteDataSource;
@override
Future<List<Post>> getPosts() async {
try {
final models = await remoteDataSource.getPosts();
return models.map((model) => Post(
id: model.id,
title: model.title,
body: model.body,
)).toList();
} on Exception {
throw const PostRepositoryException('Could not load posts.');
}
}
}
The presentation layer: Riverpod providers and widgets
Riverpod wires everything together without leaking dependencies into widgets. Providers are lazy, testable, and can be overridden in tests. We expose three providers: one for the HTTP client, one for the data source, one for the repository, and one async provider for the loaded state.
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import '../../data/datasources/post_remote_data_source.dart';
import '../../data/repositories/post_repository_impl.dart';
import '../../domain/entities/post.dart';
import '../../domain/repositories/post_repository.dart';
import '../../domain/usecases/get_posts.dart';
final httpClientProvider = Provider<http.Client>((ref) => http.Client());
final postRemoteDataSourceProvider = Provider<PostRemoteDataSource>(
(ref) => PostRemoteDataSource(client: ref.watch(httpClientProvider)),
);
final postRepositoryProvider = Provider<PostRepository>(
(ref) => PostRepositoryImpl(remoteDataSource: ref.watch(postRemoteDataSourceProvider)),
);
final getPostsProvider = Provider<GetPosts>(
(ref) => GetPosts(ref.watch(postRepositoryProvider)),
);
final postsProvider = FutureProvider<List<Post>>(
(ref) => ref.watch(getPostsProvider)(),
);
The widget stays dumb. It watches postsProvider, reacts to loading/error/data, and delegates all behavior to the provider graph.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/entities/post.dart';
import 'post_providers.dart';
class PostListPage extends ConsumerWidget {
const PostListPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final postsAsync = ref.watch(postsProvider);
return Scaffold(
appBar: AppBar(title: const Text('Posts')),
body: postsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text('Error: $error')),
data: (posts) => ListView.builder(
itemCount: posts.length,
itemBuilder: (context, index) {
final post = posts[index];
return ListTile(
title: Text(post.title),
subtitle: Text(post.body, maxLines: 2, overflow: TextOverflow.ellipsis),
);
},
),
),
);
}
}
Putting it all together
Wiring is just bootstrapping the provider scope and launching the page. ProviderScope is Riverpod’s root container; every provider resolves lazily from there.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'features/posts/presentation/pages/post_list_page.dart';
void main() {
runApp(const ProviderScope(child: CleanArchitectureApp()));
}
class CleanArchitectureApp extends StatelessWidget {
const CleanArchitectureApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Clean Architecture',
theme: ThemeData(colorSchemeSeed: Colors.indigo),
home: const PostListPage(),
);
}
}
Trace a single request through the layers: the user opens PostListPage; postsProvider awaits GetPosts; the use case calls PostRepository; PostRepositoryImpl asks PostRemoteDataSource for JSON, maps models to Post entities; the entity bubbles back through the repository, the use case, and the provider; postsAsync.when renders the list. Every hop crosses a boundary through an interface, never through a concrete class from an outer layer.
The payoff of this wiring is testability. GetPosts can be verified with a fake repository, PostRepositoryImpl with a fake or mocked data source, and the widget with ProviderScope(overrides: [...]) feeding canned entities — no network, no emulator.
Conclusion & next steps
Clean Architecture in Flutter is a trade, not a trophy: you trade a little upfront ceremony for layers you can test, replace, and reason about. The dependency rule is the law; the three layers are the map; entities, use cases, abstract repositories, and models are the vocabulary; Riverpod (or get_it, or provider) is how you draw the wiring. If you used flutter_clean_architecture, swap the presenter/controller dance for the equivalents shown here — the domain you wrote is identical.
From here, extend the feature: add a GetPostById use case with a params object, introduce a local cache data source and have the repository choose between it and the remote one, add a Failure hierarchy to replace bare exceptions, and write tests for the domain layer first. When you are ready, apply the same three-layer shape to a second feature and watch how few files outside that feature need to change.