Infrastructure as Code Best Practices with Terraform for DevOps
João Victor Alhadas | Dec 17, 2024
Flutter is a Google framework used to build natively compiled applications for mobile, web, desktop and embedded devices with a single codebase. You can easily start a new Flutter project by following the guide: Getting started – Flutter.
Cheesecake Labs is currently the TOP #10 Mobile and Web App Development Company Worldwide and has delivered awesome digital products to our clients including Flutter apps.
When discussing architecture on Flutter apps, there are many ways to structure your code and widgets. The main goal of this proposal is to make your project easy to scale and keep everything organized at same time.
On this post, we’ll use the following packages:
When you start a new project from scratch, you may have a project structure like this:
.
├── android
├── assets
├── build
├── ios
├── lib
├── test
├── README.md
├── analysis_options.yaml
├── pubspec.lock
├── pubspec.yaml
So let’s start setting up our directories in the lib folder as below:
.
├── components
├── constants
├── core
├── interfaces
├── middlewares
├── modules
├── utils
├── main.dart
└── routes.dart
Now our basic structure is set up! Don’t worry about all those folders and files, throughout the next topics we’ll cover the purpose of each one and why we need all of them.
Like React/React Native projects here at Cheesecake Labs, we work with Atomic System/Components to make our widgets more reusable and organized.
source: Brad Frost (2016)
In essence, we structure the components as:
Considering the information above, we can organize the components folder like:
.
├── atoms
├── molecules
├── organisms
└── templates
If you want to dive into Atomic System, we recommend the following articles:
Constants are the code that is immutable in your application. The following can be considered a constant:
For example, if you need to change the color of the application or the size of the font that you are using, you’ll need to replace the old constant value in one file only.
The core layer is responsible for the services which the app will use all along its lifecycle. There are two core services we usually set up:
We can create a dart file and register our instances as the following:
GetIt getIt = GetIt.instance;
void startGetItModules() {
_networkModules();
...
}
void _networkModules() {
getIt.registerSingleton<Dio>(
HttpHelper(dotenv.env['BASE_URL']!).addInterceptor(AuthInterceptor()).dio,
);
}
...
It will allow us to provide dependencies to our classes, keep the instances easy to change for different implementations, and help separate the module layers.
To help us work with navigation more efficiently, let’s create a custom navigator:
class RouteNavigator {
static final GlobalKey<NavigatorState> navigatorKey =
GlobalKey<NavigatorState>();
static Future<dynamic>? pushReplacementNamed<Arguments>(
{required String routeName, Arguments? arguments}) {
return navigatorKey.currentState
?.pushReplacementNamed(routeName, arguments: arguments);
}
static void pop() {
navigatorKey.currentState?.pop();
}
...
}
And to start using this navigator, you need to pass the navigatorKey to your
MaterialApp.
class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'My App',
navigatorKey: RouteNavigator.navigatorKey,
onGenerateRoute: (routeSettings) {
return MaterialPageRoute(builder: routes[routeSettings.name]!);
},
...
);
}
}
By using this class, we’ll be able to navigate through the app with no context required, just by calling:
RouteNavigator.pushNamed(...).
When we develop an app, we usually need to communicate with an API or some external resource, such as a local database and sensors. So, the interfaces folder is the place where you store the files that will help you with this communication.
A common example is the creation of a Dio instance to work with HTTP Requests:
class HttpHelper {
final String _url;
late final BaseOptions _options;
late final Dio _dio;
Dio get dio => _dio;
HttpHelper(this._url) {
_buildBaseOptions();
_buildHttp();
}
void _buildBaseOptions() {
_options = BaseOptions(
baseUrl: _url,
responseType: ResponseType.json,
);
}
void _buildHttp() {
_dio = Dio(_options);
}
HttpHelper addInterceptor(Interceptor interceptor) {
_dio.interceptors.add(interceptor);
return this;
}
}
Middlewares are responsible for providing an extension to an event or action from your application. This can be an HTTP request interceptor, a logger or something similar.
Here you can see an example of a middleware that throws a custom error when the request to an API returns the status code 401:
class AuthInterceptor extends Interceptor {
@override
void onError(DioError err, ErrorInterceptorHandler handler) {
if (err.response != null) {
if (err.response?.statusCode == 401) {
throw UnauthorizedException();
}
}
handler.next(err);
}
}
This folder is the most important, as it contains all modules that implement our features and its business rules. Here we’ll use an hexagonal architecture approach in a way that allows us to separate our application layer from the domain layer and the infrastructure layer. So, every module will be structured according to the following architecture:
With this architecture, the code will be more organized, but we also need to modularize it to make it easy to add a new feature, modify the old ones and make the code more reusable.
So, we suggest you structure the modules as:
.
└── feature
├── blocs
│
├── data
│ └── datasource //abstract datasources
│ └── repositories //implementation of abstract repositories
│
├── datasource //implementation of abstract datasources
│
├── domain
│ └── entities
│ └── repositories //abstract repositories
│
└── screens
Separating the modules this way allows us to use different implementations for every layer. We can, for example, create multiple different screens with the same domain logic or even change all the data source providers without affecting the entire application. For example, our app is separated into independent pieces which are able to work individually.
Moreover, this approach supports testability, which is a good point for building a reliable project and improving code quality.
When you are dealing with complex projects that contain many features, this architecture is a lot of help when it comes to keeping everything organized, easy to modify, and extremely simple to maintain and to test the code along the development process.
This is just an efficient way we’ve come up with to structure our Flutter projects here at Cheesecake Labs and we hope it helps you as well.
Would you like to know more about how to use Flutter to get your project off the drawing board? Contact us!!