Becoming an Expert in Flutter with Clean Architecture: Part 2
How to Structure Your Flutter Project for Clean Architecture

Search for a command to run...
How to Structure Your Flutter Project for Clean Architecture

can't wait for the article 🤣
Lessons from Code, Content, and Consistency

Reliability basics: Outbox pattern + why it matters

Introducing Kafka/Redpanda + move to event-driven workflow

Connect Orders ↔ Inventory (first working service-to-service flow)

Inventory service + gRPC + proto contracts

Hey there 👋🏻 Welcome back to part two of the journey through Clean Architecture, or a big hello if you're here for the first time. I'm really glad you decided to check out this article, and I promise you won't regret it.

If you're new to this Clean Architecture series, I've already covered the basics of Clean Architecture. Here's a quick summary:
We started by talking about why Clean Architecture is important.
Next, we looked at the three main parts of Clean Architecture:
Presentation
Domain
Data layer
We explored each layer in detail, explaining what each one does, and using examples to help understand their roles and responsibilities.
If you have not checked it yet go ahead and take a look at it:
Pre-requisites: The pre-requisite for reading this article is above blog on Clean Architecure and a basic knowledge of Flutter.
This blog I will show you how to organize your project's folders for Clean Architecture, based on what we covered in the first article.
We'll use flutter_bloc for managing states. If you're not familiar with Bloc, it's okay. You can use any state management method that fits with Clean Architecture.
So, without further ado, let's dive in.
First of all, go to a directory of your choice and hit the below command in termial to create a flutter project.
flutter create my_app
After creating a project let's now go ahead and add a required dependency. Head over to pubspec.yaml and add the below dependencies:
dependencies:
data_connection_checker_tv: ^0.3.5-nullsafety // For checking internet connectivity
flutter_screenutil: ^5.9.0 // For creating responsive
dio: ^5.4.1 // For making http requests
get_it: ^7.6.7 // For dependency injection
logger: ^2.1.0 // For logging debug
flutter_bloc: ^8.1.4 // For State management
equatable: ^2.0.5 // For value based equality
dartz: ^0.10.1 // For functional programming
go_router: ^13.2.0 // For managing routing
hive: ^2.2.3 // For local data storage
hive_flutter: ^1.1.0 // For local data storage
Also, add following dependencies in dev_dependencies:
dev_dependencies:
flutter_test:
flutter hive_generator: ^2.0.1 // For generating hive classes
build_runner: ^2.4.8 // For code generation
In Clean Architecture, the primary two folders contain most of the necessary components are:
core
features
We also have other folders like common_widgets, di, routes, and a app folder. Let's create each folder under the lib folder.

This is the core of Clean Architecture, where we put into practice everything discussed in the first blog about Clean Architecture.
Okay so let's understand this. Our app has different parts, right? Like Login, SignUp, Home, Settings, Product, etc.
For each part, we make a separate folder in a feature folder.
Inside this, we create three more folders:
data,
domain, and
presentation
Yes, these folders are exactly what we talked about in the previous blog.
So as an example, Let's make a feature called Counter.

Cool! Now let's dive deep into each folder and understand how to implement it.
From what we learned in the last article, this layer is at the heart of Clean Architecture. It acts as a bridge between the presentation and data layers.
We'll make 3 more folders named Entities, Repositories, and Usecases and create respectively .dart files in them.

⚠️ Note: Since we'll be coding everything in the next article, I won't be showing all the code with you right now otherwise this blog will become lengthy. So Let's just focus on the structure and how it will look.
// counter_entity.dart
class CounterEntity extends Equatable {
//...
}
// counter_repository.dart
abstract class CounterInfoRepository {
//...
}
// get_count_info.dart
class GetCountInfo {
final CounterInfoRepository counterRepository;
GetCountInfo({required this.counterRepository});
//...
}
The data layer in Clean Architecture manages the app's data. It links the repository layer, which serves as the entry point, to actual data sources such as databases, files, and APIs.
We'll make three folders we're already familiar with: datasources, models, and repositories.

datasources folder, we have two files. One is for remote data, which uses DioClient to get the data and send it to the repository. The other is for local data, which uses Hive to cache, save, and get the data. Let's take a look at the code.// counter_local_data_source.dart
abstract class CounterLocalDataSource {
Future<void> cacheCountInfo({required CounterModel? counterToCache});
Future<CounterModel?> getCountInfo();
}
class CounterLocalDataSourceImpl implements CounterLocalDataSource {
@override
Future<CounterModel?> getCountInfo() async {//...}
@override
Future<void> cacheCountInfo({required CounterModel? counterToCache}) async {//...}
}
// counter_remote_data_source.dart
abstract class CounterRemoteDataSource {
Future<Response> getCounterInfo({required int count});
}
class CounterRemoteDataSourceImpl implements CounterRemoteDataSource {
final _dioClient = getIt.get<DioClient>();
@override
Future<Response> getCounterInfo({required int count}) async {//..}
}
class CounterModel extends CounterEntity {
const CounterModel({required super.countInfo});
factory CounterModel.fromJson({required String json}) {
return CounterModel(countInfo: json);
}
CounterModel copyWith({String? countInfo}) {
return CounterModel(countInfo: countInfo ?? this.countInfo);
}
}
class CounterRepositoryImpl implements CounterInfoRepository {
final CounterRemoteDataSource remoteDataSource;
final CounterLocalDataSource localDataSource;
final networkInfo = getIt.get<NetworkInfo>();
CounterRepositoryImpl({
required this.remoteDataSource,
required this.localDataSource,
});
@override
Future<Either<Failure, CounterModel>> getCountInfo(
{required int count}) async {//...}
}
}
getCountInfo.
If you're using flutter_bloc, put your business logic in the cubit/bloc folder like I did. If not, you can make a folder for your own state management.
And in pages and widgets, it's simple, you code all the UI-related stuff
So, that was the main part of the Clean Architecture which we just finished. Now, let's go through the responsibilities and uses of other folders one by one.
This folder contains the entry widget of the application. We use this app widget in the main.dart file's runApp() function.
So, make a file named app.dart and copy the following code into it:
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, __) => MaterialApp.router(
debugShowCheckedModeBanner: false,
locale: const Locale('en', 'US'),
title: kAppName,
theme: themeData,
routerConfig: router,
),
);
}
}
routing, localisation, screen utils, and more. Simple, right? Okay, let's move forward now.It's where we put all the widgets that are used all over our app. These are the widgets we need to use again and again on different pages and in different situations.
To make it clearer, some widgets in this folder are the Appbar, which gives a consistent top navigation bar on different screens; the BottomNavBar, which lets users easily move between the main features of the app from the bottom of the screen; and the Drawer, a menu that slides out and can be accessed from many places in the app.
⚠️ NOTE: I won't paste the entire code of each file here because some files have a lot of lines. You'll be able to access this code at the end of this blog. So stay tuned and let's keep going.
As the name suggests, this section defines all the core functionalities of the application, such asconstants, network, local db, etc. Let's now break down this folder and see what it contains:

I made this folder because I'm using flutter_bloc in this project for managing states.
This folder contains a bloc class called base_bloc.dart and a state class called base_state.dart.
Essentially, this will serve as the foundation for all the cubits we plan to create.
class BaseBloc extends BlocObserver {
@override
void onEvent(Bloc bloc, Object? event) {
super.onEvent(bloc, event);
}
@override
void onTransition(Bloc bloc, Transition transition) {
super.onTransition(bloc, transition);
}
@override
void onError(BlocBase bloc, Object error, StackTrace stackTrace) {
super.onError(bloc, error, stackTrace);
}
}
Similarly, we will create a state class where we will define common states as shown below:
abstract class BaseState extends Equatable {}
class StateInitial extends BaseState {
@override
List<Object> get props => [];
}
class StateOnSuccess<T> extends BaseState {
final T response;
StateOnSuccess(this.response);
@override
List<Object?> get props => [];
}
//...define other state
Remember we added the data_connection_checker_tv package in pubspec.yaml? We'll create a class named NetworkInfo to handle connectivity logic and use this package.
abstract class NetworkInfo {
Future<bool>? get isConnected;
}
class NetworkInfoImpl implements NetworkInfo {
final DataConnectionChecker connectionChecker;
NetworkInfoImpl(this.connectionChecker);
@override
Future<bool> get isConnected => connectionChecker.hasConnection;
}
isConnected, a boolean.💡Use Case: You will see the use of this in the Data Layer's Repository Implementation.

In this folder, we have two files
Exception - This file lists the Remote and Local errors we could encounter when making API calls or fetching local data
// exceptions.dart
class ServerException implements Exception{}
class CacheException implements Exception{}
Failure - We're using dartz, a package for functional programming, so we'll return two things from a function. If you're not familiar with functional programming, don't worry. We'll cover what we need to know in the next article.
We have different types of failures, such as ServerFailure, CacheFailure, NetworkFailure, and more. We define all these types in this class and use them.
abstract class Failure {
final String errorMessage;
const Failure({required this.errorMessage});
}
class ServerFailure extends Failure {
ServerFailure({required String errorMessage})
: super(errorMessage: errorMessage);
}
//..Other failures
class HiveHelper {
static init() async {
await Hive.initFlutter();
// register hive adapters
await openBox();
}
static openBox() async {
// Open Hive boxes
}
}
In this folder, we define everything related to networking such as network Clients, Endpoints, and Interceptors. We have created three folders for these:
client
endpoints
interceptors
Let's explore each one and learn what they do.
// dio_client.dart
class DioClient {
// dio instance
final Dio _dio;
// injecting dio instance
DioClient(this._dio) {
_dio
..options.baseUrl = Endpoints.baseUrl
..options.connectTimeout =
const Duration(milliseconds: Endpoints.connectionTimeout)
..options.receiveTimeout =
const Duration(milliseconds: Endpoints.receiveTimeout)
..options.responseType = ResponseType.json
..interceptors.add(DioInterceptor())
..interceptors.add(LogInterceptor());
}
Future<Response> get(...) async {...}
Future<Response> post(...) async {...}
Future<Response> put(...) async {...}
Future<Response> delete(...) async {...}
}
We've made a DioClient class that includes all the necessary network calls such as get, post, put, delete.
There's also a dioexception class for managing network errors and sending formatted messages to the user.
Let's create it too.
// dio_exceptions.dart
class DioExceptions implements Exception {
late String message;
DioExceptions.fromDioError(DioException dioError) {
switch (dioError.type) {
case DioExceptionType.cancel:
message = "Request to API server was cancelled";
break;
// Handle other DioError
}
String _handleError(int? statusCode, dynamic error) {
switch (statusCode) {
case 400:
return error['message'] ?? 'Bad request';
// Handle other status code
}
}
}
The app often includes an authentication module where users register and log in.
To prevent unauthorized access, we require an access token for every request. Instead of manually adding the access token to every API call, which can be time-consuming, we use an interceptor to handle it automatically.
For more information about interceptors, read this blog:
Let's create a Dio Interceptor
class DioInterceptor extends InterceptorsWrapper {
@override
void onRequest(
RequestOptions options, RequestInterceptorHandler handler) async {
log("Request[${options.method}] => PATH: ${options.path}");
// Pass accessToken in header of every request
super.onRequest(options, handler);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
log("Response Status Code: [$response]");
super.onResponse(response, handler);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
log("Error[${err.response?.statusCode}] => PATH: ${err.requestOptions.path}");
super.onError(err, handler);
}
}
In a production app, we'll have many APIs to work with. To organize all the API URLs and their endpoints, we create a separate class called Endpoints. This way, we can easily access the base URL and API endpoints.
Let's create it.
class Endpoints {
Endpoints._();
// base url
static const String baseUrl = "";
// receiveTimeout
static const int receiveTimeout = 150000;
// connectTimeout
static const int connectionTimeout = 15000;
// endpoints
static const String login = "$baseUrl/login";
}
We've finally finished the core folder. I hope you now understand what's inside each folder and their responsibilities.
Let's move on and explore the other folders.
Dependency injection is a way to organize your code so that a class gets its dependencies from somewhere else instead of making them itself. This means your class doesn't have to set up these dependencies; they are given to it by something called an injector.
If you want to learn more about dependency injection, you can check this blog as it will explain to you from the core what is DI and why we need it.
In the context of Clean Architecture, we've made classes like DioClient, NetworkInfo, Hive, etc. Instead of creating a new instance of these classes every time, we inject them at the start of the app and use the same instance everywhere in the app.
Later, we'll add more components like Repository, Data sources, Usecases, etc. We'll inject these into our business logic too, which is why we use dependency injection.
For Flutter, there's a package called get_it that helps with this.
Remember the di folder in the lib folder? We'll create a service_locator.dart class there to set this up.
final getIt = GetIt.instance;
Future<void> setupLocator() async {
await HiveHelper.init();
getIt.registerLazySingleton<Dio>(() => Dio());
getIt.registerLazySingleton<DioClient>(() => DioClient(getIt<Dio>()));
getIt.registerLazySingleton<NetworkInfo>(
() => NetworkInfoImpl(DataConnectionChecker()));
}
final dioClient = getIt.get<DioClient>();
That's it. As simple as that.
We use the go_router package for routing in Flutter. It makes navigating between screens easy by using URLs.
We'll make a path.dart file to list all the paths used in the app.
class Paths {
static const loginPage = '/login';
static const homePage = '/home';
}
routes.dart file where will define router for our application:final router = GoRouter(
initialLocation: Paths.login,
routes: [
GoRoute(
path: Paths.login,
builder: (context, state) => const LoginPage(),
),
GoRoute(
path: Paths.home,
builder: (context, state) => const HomePage(),
),
],
);
router in MaterialApp.router() 's routeConfig parameter like thisMaterialApp.router(
// other config
routerConfig: router,
),
main.dart is the starting point of the application.
Here, we set up a logger for debugging, decide the app's orientation, and adjust other settings before the app runs.
final logger = Logger();
Future<void> main() async {
runZonedGuarded(
() async {
WidgetsFlutterBinding.ensureInitialized();
await setupLocator();
service.SystemChrome.setPreferredOrientations(
[service.DeviceOrientation.portraitUp]).then((_) {
runApp(const App());
});
},
(error, stackTrace) => logger.e(error.toString()),
);
}
We talked about setting up Flutter project folders for Clean Architecture, including project setup, dependencies, and how to organize folders for core, features, and other key parts.
We discussed the domain, data, and presentation layers, highlighting their role in Clean Architecture.
We explored managing states with flutter_bloc, how to set up dependencies, and how to build a responsive and easy-to-maintain app structure.
We also covered dependency injection, routing, and essential features like networking and error handling, giving a full guide on applying Clean Architecture in Flutter projects.
We've gone through everything important about Clean Architecture, from the basic ideas and uses to organizing the project.
Now, it's time to start coding, isn't it?
In the next article, the last one in this Clean Architecture series, we'll build an app from the ground up using Clean Architecture concepts.
If you've read this far, I hope you enjoyed it and learned something. If you did, I'd love to hear your thoughts and would appreciate a like to encourage me to write more articles like this.
We'll meet again in the next article. Until then...
