Rating: 4.27 / 5 Based on 33 reviews
Many aspects have to be considered when designing a high-quality Flutter application. But the first and most important of them all is the architecture. If this step is done incorrectly, it can greatly hinder hybrid mobile apps' future development and scalability. A poorly architectured application also requires frequent and expensive refactors which you don’t want.
We describe our approach to feature-based architecture along with some of the design decisions that should be made that may help you with your Flutter application development.
In this article, you will learn about:
In the realm of cross-platform mobile app development, the Flutter framework is one of the most popular choices. At LeanCode, we believe that there are a couple of factors that you must consider before starting your next Flutter project. One of them that matters the most is architecture. It’s not an easy task. You need to keep in mind, from the beginning, that the mobile architecture of your Flutter app should reflect your business idea but still be as flexible as possible to be able to scale anytime.
At LeanCode, the architecture of mobile Flutter applications is driven by the experience gained during the completion of 40+ successful projects of various sizes. This experience made us come to the conclusion that the best to follow is the feature-based Flutter architecture approach. This architecture scales from small to large projects. While some components of the cross-platform application architecture are refreshed to adapt to new community standards, but most remain the same due to being battle-tested on real-world projects.
In general, feature-based Flutter architecture is an approach to structuring and organizing mobile apps developed with Flutter. It breaks down the app into smaller parts - features. Each feature focuses on a specific set of functionality, like showing a list or handling user login, and they act like building blocks that can be put together to make the whole application.
So feature-based Flutter app architecture promotes separation of concerns, reusability, maintainability, and scalability, making it an effective choice for developing complex and still-changing applications. Each feature is self-contained and can be developed, tested, and maintained independently. But also shared components and resources can be reused across different features, reducing duplicated code and ensuring consistency throughout the application.
This separation allows development teams to work simultaneously on different features without causing conflicts or dependencies between them. It also enables easier debugging, testing, and updates, as changes to one feature are less likely to impact other parts of the application. This leads to more comprehensive and reliable testing.
In the context of building mobile applications for enterprises or complex projects, such architecture offers a robust framework for creating and maintaining high-quality apps. It enables teams to deliver features but still respond swiftly to changing requirements, also those based on user feedback and market trends.
Let’s say we are developing a feature for a comment section under a post in a social media application. A user would be able to see the list of comments, upvote comments, and add their own comments.
The file structure will be feature-based. This means things related to the comment section will be closed under the same directory. This is opposed to type/function based approach, where files are grouped by their function. This enables greater scalability and flexibility:
Our comment section feature would look the following way:
social-app/
└── lib
└── features
└── comment_section
├── bloc
│ └── comment_section_cubit.dart
├── comment_section.dart
└── widgets
└── upvote_button.dart
Since this is Flutter, most often than not, an entrypoint for your feature will be a widget. In our case, it is the widget responsible for showing a comment section. A feature entrypoint is required to set up all dependencies used within a feature. This includes external dependencies and those that will be injected into the widget tree.
For dependency injection, we favor the community standard package:provider. It allows for tying the lifetime of a dependency to a widget tree. The dependency is injected when the tree is created and disposed once the tree is unmounted. The injected values are also scoped to a specific widget tree, further assuring us of the self-containment of a feature. Since provider is merely a wrapper around flutter-native’s InheritedWidget, we can leverage flutter tools without locking ourselves into a different paradigm. While we acknowledge the shortcomings of package:provider (such as not having the compile-time safety when consuming dependencies), we believe alternative solutions claiming to solve this problem, such as package:riverpod introduce other, larger issues.
Let’s see the entrypoint of the comment section feature:
class CommentSection extends StatelessWidget {
const CommentSection({super.key, required this.postId});
final Guid postId; // (1a)
Widget build(BuildContext context) {
return BlocProvider( // (2)
create: (context) => CommentSectionCubit(
postId: postId, // (1b)
client: context.read<ApiClient>(), // (3)
)..initialize(),
child: _CommentSectionBody(), // (4)
);
}
}
This widget accepts in the constructor all data needed to initialize a comment section. In this case, it is only the postId (1a) that is then passed to the state manager (1b). Cubit for this feature is provided (2) to the widget tree and will be automatically disposed when unmounting. The cubit itself also needs the API client to make requests; thus, it is injected (3) (more on that in a later section). The ApiClient is considered a global dependency, as it is injected into the root of the application. Finally, once everything is set up, we return the child responsible for drawing all the user interface and listening to the state manager (4).
Other than a handful of globally-injected services, the dependencies of a feature are clearly defined by the constructor of the entrypoint.
Disclaimer for “really large-scale” projects: while the Flutter widget tree gets more and more nested, there could be an issue when StackOverflowError is being thrown because of that depth. This issue is tracked on the Flutter repository and can be found here, along with a workaround. This might occur when there are a lot of global Providers nested in each other on top of the widget tree. To avoid this, you can switch to another dependency injection tool. When it comes to global dependencies, we don’t need them bound to a specific element’s lifecycle.
State management for UI usually boils down to two concepts: data representing the state and some functions that alter this data. In this setting, UI is a function of the state. package:bloc is no different and is our solution of choice. It introduces a clear distinction between said data and functions. This distinction promotes the immutability of state, which then allows for a fully declarative UI. Additionally, the simplicity results in an easy to reason about code.
A Cubit is responsible for guiding the behavior of the UI through its state. It does not have access to BuildContext, making it completely detached from the rendering pipeline. This ensures greater separation and testing in isolation. However, not all UI behavior should be a function of the state. Most notably, one-off events that are not worth persisting in the state. In the case of a comment section, failing to upvote a comment could be a UI event. We don’t particularly care about remembering that it happened, but we surely want to make the UI reflect that it did. For this, we use package:bloc_presentation, which simply adds an additional stream to a Bloc for one-off events called presentation events. To learn more, visit the package's repository..
Let’s see how a CommentSectionCubit would look like:
class CommentSectionCubit extends Cubit<CommentSectionState>
with BlocPresentationMixin {
CommentSectionCubit({
required this.postId,
required this.client,
}) : super(const CommentSection.initial());
final Guid postId;
final ApiClient client;
Future<void> initialize() {
emit(const CommentSectionState.inProgress());
// ... fetch comments
if (response.failed) {
emit(CommentSection.failure(reason: /* ... */));
} else {
emit(CommentSection.ready(comments: comments));
}
}
Future<void> upvoteComment(Guid commentId) {
// ... try to upvote
if (response.failed) {
emitPresentation(CommentSectionEvent.failedUpvote);
}
}
// ...
}
The Cubit starts in an empty initial state, indicating that no work has yet been done. The initialize method fetches comments and indicates a hard failure in case of errors. In the failure state, no methods should work since they will all most likely need the ready state to access fetched comments. Recovering from hard failures is possible by calling initialize again or, better yet, through a dedicated refresh method (which would be called by a pull-to-refresh). In the upvoteComment method, we can emit a presentation event in case of an error since failing to upvote is not a particularly interesting thing to persist.
For state, we use package:freezed which enables an easy way to define union types with value-equality. This distinction between state types (initial, inProgress, ready, etc) makes it clear which methods should and which shouldn’t be allowed in some state.
CommentSectionState would look like the following:
class CommentSectionState with _$CommentSectionState {
const factory CommentSectionState.initial() = CommentSectionStateInitial;
const factory CommentSectionState.inProgress() =
CommentSectionStateInProgress;
const factory CommentSectionState.failure({
required CommentSectionFailureReason reason,
}) = CommentSectionStateFailure;
const factory CommentSectionState.ready({
required List<Comment> comments,
}) = CommentSectionStateReady;
}
And finally, our presentation events:
enum CommentSectionEvent implements BlocPresentationEvent {
failedUpvote,
}
At LeanCode, we design backends to be tailored for the clients (“backend-for-frontend”). This means, instead of having a generic REST endpoint /posts/:postId/comments, which would probably return extra data which the client wouldn’t use, or too little data forcing the client to make additional requests to other endpoints, we design a dedicated endpoint for this mobile screen.
This approach has the benefit of feature-level endpoint optimization and, once again, isolation between features. One less visible but still important benefit is that it removes the need for the repository level. Instead, the API is already tailored to our needs, so we can directly make requests in a cubit through some API client.
For instance, the initialize method in the cubit would do the following:
final response = await client.get(GetCommentSection(postId: postId));
Where client is a generic HTTP client which can handle request blueprints, and GetCommentSection is such a blueprint encoding all information needed to reach the appropriate endpoint. At LeanCode, these blueprints are automatically generated using our contracts generator, which gives us type-safe backend-mobile communication.
Sometimes when we want to do additional processing on the fetched data, a need for a repository arises. A notable example is caching/offline mode. The feature based architecture allows us to make such decisions on a feature-level. In such a case, we can introduce a repository that encloses all additional data logic and inject it into the cubit instead of the ApiClient.
Once we have a source of truth to draw the UI, a Cubit, we render Flutter widgets depending on Cubit’s state. Due to the state being expressed as a union, we protect ourselves from rendering wrong layout widgets. For example, we cannot show the list of comments while not being in the ready state since we simply won’t have access to the list of comments.
For our comment section, it is the following:
class _CommentSectionBody extends StatelessWidget {
const _CommentSectionBody();
Widget build(BuildContext context) {
final state = context.watch<CommentSectionCubit>();
return state.map(
initial: (state) => const SizedBox(),
inProgress: (state) => const CircularProgressIndicator(),
failure: (state) =>
Text('Failed to fetch the comment section: ${state.reason}'),
ready: (state) => CommentSectionList(readyState: state),
);
}
}
One last thing to handle are presentation events. We need to subscribe to the presentation stream and react to them appropriately. The act of “subscribing” already implies that we need a stateful approach. Classically this would be done with a StatefulWidget, but these widgets tend to be full of boilerplate noise and be less declarative than one could want. As an alternative, we prefer to use package:flutter_hooks. This package removes the boilerplate of a StatefulWidget and transfers it to a conceptual overhead. Using a hook, we can set up a listener for presentation events:
class _CommentSectionBody extends HookWidget {
const _CommentSectionBody(); // ^^^^^^^^^^
Widget build(BuildContext context) {
// ...
useBlocPresentationListener<CommentSectionCubit>(
listener: (context, event) {
if (event is CommentSectionEvent) {
switch (event) {
case CommentSectionEvent.failedUpvote:
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to upvote!')),
);
}
}
},
);
// ...
}
}
In this article, we explored what are some of the pieces comprising the development process of a feature in an application. We stated the case for the chosen stack and the decisions behind them. This is only a small portion of design decisions that have to be made before creating a robust, scalable, fully-featured mobile application.
Notable things that were not fully discussed:
Some of the above are covered by a great article on choosing Flutter for enterprise apps, written by LeanCode’s Head of Mobile.
But if you still have any questions regarding building Flutter app development or anything else from the list, you can order a free 30-minute consultation call with our experts.