In this post, you’ll learn more about Layering the Flutter Application Architecture.
Structuring code is crucial for building large-scale applications that are maintainable and extendable. Poorly structured code can make it difficult to add new features or make changes to existing functionality, leading to bugs and technical debt that can accumulate over time.
The architecture of an application determines how its components interact with each other, how data is stored and retrieved, and how the application responds to user input. A well-structured architecture should be modular, with clear separation of concerns between different parts of the codebase. This allows developers to work on different parts of the codebase independently, without fear of breaking other parts of the application.
There are several popular architectural patterns that can be used to structure code, such as Model-View-Controller (MVC), Model-View-ViewModel (MVVM), and Clean Architecture. Each pattern has its own strengths and weaknesses, and the choice of pattern will depend on the specific needs of the application.
Some common components of an application architecture include:
Layers refer to different levels of abstraction or responsibility in a system. Each layer has a specific function and interacts with the layers above and below it to provide a cohesive and functional system.
The layers typically include:
By separating an application into layers, each layer can be developed and tested independently, making the application more maintainable and scalable. It also allows for more flexibility in the development process, as changes can be made to one layer without affecting the others.
The layered architecture pattern is just one example of how layers can be used in software development. Other examples include the OSI model for networking and the protocol stack used in communications.
The Single Responsibility Principle (SRP) is a software development principle when each class or module should have only one responsibility or job to perform.
The SRP is one of the SOLID principles;it helps to ensure that code is easy to understand, modify, and test.
When a class or module has only one responsibility, it is easier to understand its purpose and to modify it without affecting other parts of the codebase. This also helps to reduce the likelihood of introducing bugs or errors when making changes.
For example, a class that handles user authentication should not also be responsible for managing user profiles. Instead, these responsibilities should be separated into two separate classes, each with its own responsibility.
Here are some examples of responsibilities that software components may have:
Some common architectural choices include:
When making architectural choices, consider a variety of factors, including the requirements of the system, the available technology stack, the skills and experience of the development team, and the expected future growth and evolution of the system.
You can create different layers in your application to perform the three main duties. Layers need to be defined carefully so that they guarantee convenient operation and without compromising work.
At TBR Group, we follow an architecture pattern that has four layers.
Each layer contains multiple neurons, or nodes, which perform mathematical operations on the input data and pass the output to the next layer. The first layer, the input layer, receives the raw input data and passes it to the first hidden layer. The output layer produces the final output of the network, which could be a prediction or classification based on the input.
The layer responsible for domain-specific data modeling is known as the domain layer, which transforms unprocessed data into models specific to the domain that are then used by the business logic layer. The business logic layer maintains an unalterable state of the domain models supplied by the repository. Furthermore, this layer responds to user inputs from the UI and interacts with the repository when modifications are required based on the current state.
Very often, mixed responsibilities cause chaos in various projects. Without a clear division of duties and priorities, it is very easy to get confused and make a lot of mistakes that are then difficult to find and correct.
import 'package:firebase_auth/firebase_auth.dart';
class StartPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return StreamBuilder<User?>(
stream: FirebaseAuth.instance.userChanges(),
builder: (context, snapshot) {
if (snapshot.hasData) {
return const ProfilePage();
}
return const SignInPage();
},
);
}
}
When working according to a specific template for projects, we advise you not to use labels with layers. Each layer should have a chain of relationships that will prevent interaction between layers that are not directly related. For example, the presentation layer should not call or interact in any way directly with the data-tier APIs.
Establish a consistent naming convention and adhere to it throughout the development process. In the case of a project with a data layer that is a dependency of the domain layer, it would be beneficial to name the data layer accordingly, such as “data” or “repository”.
├── lib
| ├── posts
│ │ ├── bloc
│ │ │ └── post_bloc.dart
| | | └── post_event.dart
| | | └── post_state.dart
| | └── models
| | | └── models.dart
| | | └── post.dart
│ │ └── view
│ │ | ├── posts_page.dart
│ │ | └── posts_list.dart
| | | └── view.dart
| | └── widgets
| | | └── bottom_loader.dart
| | | └── post_list_item.dart
| | | └── widgets.dart
│ │ ├── posts.dart
│ ├── app.dart
│ ├── simple_bloc_observer.dart
│ └── main.dart
├── pubspec.lock
├── pubspec.yaml
The abstract declaration and concrete implementation approach used in the flutter_todos example from the bloc library is a good way to implement this type of solution. The abstract declaration provides a blueprint for the functionality needed, while the concrete implementation provides the specific details for how it is implemented. This allows for flexibility in adapting the functionality to different use cases while maintaining a consistent API interface.
Choosing between creating a repository with a direct dependency or creating an API wrapper for an external package will depend on various factors, including the complexity of the external package’s API, the number of dependencies it requires, and how well it meets the specific needs of the project.
If the external package has a clean and simple API that meets the needs of the project without requiring too many dependencies or configuration, then creating a repository with the package as a direct dependency may be the best approach. This can simplify the codebase and make it easier to maintain, as well as potentially provide better performance.
The chat_location application provides a good example of how to use this package with a repository, demonstrating how the package can be integrated into the app’s architecture while keeping the repository code clean and maintainable.
In cases where an external package’s API is more complex, creating an API package with a class that wraps that dependency can be a better approach. This can provide a more streamlined interface for the specific needs of the project and abstract away some of the implementation details.
The spacex_demo application is a good example of how to create a custom API package, providing a wrapper for the SpaceX API. This approach allows for a more tailored interface for the project’s specific needs, while also providing flexibility and isolation from any potential issues with the external API.
Over-abstracting can increase complexity and development time without providing any real benefit.
If the project primarily uses JSON as its data format, then abstracting the base properties of the model to accommodate other formats may not be necessary. In this case, it may be more efficient to simply create the serialization/deserialization functionality for JSON and avoid over-abstracting the model.
However, if there is a possibility of the data format changing in the future, or if there are other reasons for needing a more abstracted model, then it may be worthwhile to invest the time and effort into creating a more flexible model architecture.
The decision to use packages should be based on the specific needs and constraints of the project. For a small project with a small team, the added complexity of using packages may not be worth the benefits. In this case, it may be more efficient to handle migrations and updates manually.
However, as the project grows and the team expands, the benefits of using packages become more apparent. Component isolation becomes more important as the project becomes more complex, and having different team members working on different parts of the project simultaneously becomes more common.
You can have one teammate migrating the accounting repository to Very Good Analysis 2.4.0 and another migrating the retail API to dio 4.0.0 at the same time, so you can tackle fixing lints and null safety refactors without stepping on each other’s toes.
When choosing packages or tools, it’s important to have a set of criteria to evaluate them against:
Studying other developers’ repositories is an excellent way to learn and improve. Many open-source projects have an active community of contributors who can help you with any questions or issues you may encounter.
Refactoring is a natural part of the development process, and it’s important to recognize when something needs to be improved and not be afraid to make changes. Additionally, it’s important to maintain a positive attitude towards mistakes and failures, as they provide valuable learning experiences that can lead to growth and improvement. As the saying goes, “fail fast, fail often, fail forward.”
If you are reading this article, then you are interested in the topic of structuring code. If you are ambitious, not afraid of difficulties, you have a lot of ideas, but a little lack of confidence and experience in your business – come to our team!