Flutter: beyond Widgets

Alejandro Ferrero
Flutter España
Published in
14 min readNov 17, 2022

Flutter is Google’s portable framework for crafting cross-platform, natively compiled applications from a single codebase. Furthermore, it is open-sourced and, hence, free to use. Flutter consists of two essential parts:

  • An SDK (Software Development Kit): It is a collection of tools used by developers to create applications. It enables code compilation into native machine code.
  • A widget-based framework (UI library): It is a set of reusable UI elements. They allow developers to design applications according to their specific needs.

Ultimately, as Tim Sneath said during the Flutter 1.0 Keynote, “Flutter is a powerful, general-purpose, open UI toolkit for building stunning experiences on any device, embedded, mobile, desktop, or beyond.”

However, Flutter App development goes far beyond simply understanding and leveraging widgets. Therefore, this blog post provides an overview of the main concepts and elements having a crucial weight in building a maintainable and scalable Flutter application.

Architecture

Software architecture involves numerous decisions based on various factors, representing the highest abstraction level at which we construct and design software systems. In other words, the software architecture sets the boundaries for the quality levels resulting systems can achieve and represents an early opportunity to design for software quality requirements such as reusability, performance, safety, and reliability. Nonetheless, the architecture of a software system is rarely limited to a single architectural pattern. Thus, I propose to employ a hybrid architecture combining layered and feature modularization to implement your Flutter App.

Layered

A layered architecture relies on the fragmentation of software programs into responsibility-based hierarchical levels, called layers, and is a common standard in large-scale applications. More specifically, I recommend leveraging a four-tier layered architecture including the following distinct levels:

  • Data Layer — It is the lowest layer of our four-tier architecture. It is responsible for communicating with external sources (databases or APIs) to retrieve raw data. Moreover, this layer exposes clients free of any UI-specific dependencies. Lastly, we can consider this layer the engineering layer since it serves the purpose of efficiently processing and transforming data.
  • Repository Layer — Decouples the business logic and data layers and composes one or more clients from the data layer to apply domain-specific business rules to the retrieved data. Domain-based repositories compose this layer whose principal role is centralizing shared data access functionality, acting as a middleman between the business logic and data layers. Furthermore, there should only be one repository per domain, which must be free of any Flutter dependencies and can only interact with the data layer. Lastly, we can consider this layer the product layer since it brings value to the user through the exposure of refined data.
  • Business Logic Layer — Vessels the business logic of the application. Blocs and Cubits compose one or more repositories and include the logic to surface the business rules via a specific feature or use case. Moreover, this layer employs the bloc library to manage the logic associated with each feature. Hence, we can consider this layer the feature layer as it determines the correct functioning of any given feature. The following state management section provides more comprehensive information about this layer and its implementation.
  • Presentation Layer — It is the topmost layer of our four-tier architecture. It serves as the UI of the application, allowing users to interact directly with it. These interactions generate events, which are then forwarded to the business logic layer. Moreover, it reacts to state changes from the business logic layer, prompting the UI to trigger rendering modifications via Flutter Widgets. Furthermore, this layer should only interact with the business logic layer. Lastly, we can consider this layer the design layer since it aims to provide the best possible experience for users through visual components and effects.

Feature-Oriented

The number of notions and interpretations of a feature is as broad and abstract as definitions may have. Therefore, I propose the following broad and fundamental-concept-encompassing definition: A feature is a prominent or distinctive user-visible aspect, quality, or characteristic of a software system or systems representing a functionality entity that satisfies a requirement, serves a design decision, and provides a potential configuration option. Moreover, Feature-Oriented Software Development (FOSD) is a paradigm for the construction, customization, and synthesis of large-scale software systems aiming at decomposing such a system based on the features it provides. Decomposition enables the creation of well-structured and user-needs-tailored software systems while facilitating the reuse of shared features to generate different software systems. Lastly, it is worth addressing the concept of feature by layer:

  • Infrastructure Feature — It is a feature found in the data layer and presented as a client module that adheres to the role of this layer by communicating with external sources and fetching data. It adds functionality at a low level within the application structure, and hence, it does not provide direct value to the application users as they perceive it as a black box.
  • Domain Feature — It is a feature found in the domain layer and presented as a domain-based repository that adheres to the role of this layer by applying domain-specific business rules to the retrieved data. It adds functionality at a middle level within the application structure, and hence, it does not provide direct value to the application users as they still perceive it as a black box.
  • Application Feature — It is a feature found within the business logic layer (logic component), the presentation layer (design component), or both layers (combined component) that adheres to the role of this layer by providing either functionality or visual value, or both. Notice that this feature sits at the highest level within the application structure, and hence, it exhibits direct value to the application users as they can interact with this feature.

Packaging

In addition to the advantages provided by combining the layered and feature-oriented architectural patterns, I consider fundamental complementing our architecture with an effective modularization approach. This approach leverages the full power of dart, enabling the compartmentalization of features by layer and feature. The proposed project structure should also adhere to the Multimodule Monorepo approach, which allows for maintaining a project as a single repository with multiple submodules for each of the features included in each layer.

Hybrid Architecture with multimodule monorepo

In the end, following these architectural patterns and decisions enables developers to easily add, modify, remove, and test features and address bugs and fixes without affecting other project mates’ work, enhancing the overall maintainability and scalability of a given software application.

State Management

State management is arguably one of the most controversial, debated-about, and critical decisions software architects and engineers must make when implementing any software App. It is also one of the earliest decisions, thoroughly influencing the implementation, particularly the application features found at the higher architectural layers. Thereby, choosing the right state management solution is a decision that incurs weighing the advantages and disadvantages provided by the analyzed options while accounting for the non-trivial problems that may arise should the chosen solution be changed during the development process. Thus, this section introduces the selection criteria employed to select BLoC and flutter_bloc as the proposed state management solution.

Notice this selection criterion does not intend to become the ultimate model to select a state management solution in any given circumstances but instead allows any developer seeking to build a maintainable, scalable, and testable Flutter application to find the most suitable option. Hence, the chosen state management solution must be predictable, simple, and highly testable and increase the developers’ comfortability and confidence towards building and maintaining a robust product. Ultimately, the best state management solution is the one that works best for you. Thus, I propose the BLoC pattern and its Flutter implementation through the flutter_bloc package.

Predictability

Developers often face significant challenges when accurately determining the state of the developed application at any given point in time. Flutter introduces the Widget Tree to address this challenging ambiguity, allowing developers to modify the state of the Widgets, and hence, the Widget Tree’s structure. However, managing widget states vertically and horizontally across a complex Widget Tree is a tortuous and intricate endeavor. Therefore, flutter_bloc allows developers to decompose the application’s state into smaller, well-defined, and deterministic state machines to address this complexity and ambiguity. Ultimately, these state machines transform events into zero, one, or multiple predictable states.

Simplicity

Notice Apps are reactive by nature, and thus, developers must program them to be reactive. However, this natural reactiveness is the cause of non-deterministic user interactions that may occur at any point in time, if at all. Therefore, developers traditionally relied on powerful yet complex APIs to manage said reactiveness and produce interactive and engaging applications. On the other hand, flutter_bloc proposes a simplified API that abstracts the complexity of streams while still honoring the natural reactiveness of applications. Thereby, developers need not maintain non-trivial stream subscriptions and lifecycles, allowing them to focus on predictable interactions by handling incoming events and outputting new states.

High testability

Testability is a crucial feature of any high-quality software application. Furthermore, we should seek and deliver one hundred percent coverage through unit testing to boost developers’ confidence in delivering reliable and quality products. Moreover, the flutter_library makes code testing one of its principal values and provides a dedicated package for such a purpose, bloc_test. This package is a utility library that eliminates the complexity of testing reactive code while enabling developers to unit test their code and validate the product behavior at any point in time with barely any setup required.

Testing

We must consider testing a key area essential to delivering high-quality, maintainable, and scalable applications. Moreover, verifying all code behaves as intended allows for reducing risk, increasing confidence in a given codebase, and keeping current expectations and assumptions aligned. Ultimately, a well-structured test will always output the same result for any given input, ensuring the long-term functionality of code regardless of functionality and features added in the future. We should always strive for one hundred percent code coverage for our entire codebase as a standard for code quality and test adequacy, enforcing the exercise of every line of code at least once. Additionally, integrating these standards into a work methodology will require developers to build features and corresponding tests as part of the same engineering effort. This approach encourages code ownership and responsibility while potentially boosting productivity and predictable behaviors across an engineering team. Lastly, a comprehensive test suite requires extra time to write and maintain, which may hinder the initial progress of pure development tasks. Nonetheless, as a codebase grows, tests serve to avoid requirements ambiguity, communicate intended behavior, and identify and fix unwanted functionality or bugs. Thus, investing time in writing tests alongside feature implementation saves time in the long run by avoiding code rewrites and helps deliver more stable and reliable products. Ultimately, Flutter facilitates the following kinds of tests:

Flutter test classification

Dependency Injection

Dependency Injection (DI) is a design practice that enforces the supply of service classes to another class that depends on them. Furthermore, it claims to make code more loosely coupled, hence, making code extensible, maintainable and testable. This pattern has specific practices for injecting said services summarized below:

  • Constructor injection — It is the simplest form of the DI pattern. Developers supply a class as a dependency to the constructor of a class that requires services or functionalities from the injected class. However, this approach may lead to a typical code smell where developers inject multiple dependencies into a given class. Thus, property injection arises as a potential trade-off solution when injecting more than three or four dependencies.
  • Property injection — It is similar to constructor injection. However, this approach injects dependencies via the setter property, rather than through the constructor. The dependency is optional as the class needing its services has a local default, allowing users to inject different dependencies. This approach also presents some weaknesses, such as obscuring the internal structure of the classes implementing this DI variant or repeatedly injecting the same dependencies in different parts of the program, complicating debugging tasks.
  • Method and Interface injection — Both forms perform DI by passing a dependency as the argument to a method. These approaches provide dynamic dependencies during execution time, and in the case of interface injection, it enforces the class implementing its interface to supply the dependency.

Overall, proponents of DI patterns defend that they enable developers to produce more loosely-coupled code, which increases the controllability and observability of test cases, and enhances software extensibility and reusability, ultimately affecting positively software quality attributes such as maintainability and testability.

Mocks

Mocks allow programmers to use mock objects as fake components or services instead of real ones to ensure the correct functioning of unit tests. This practice reinforces the fundamental principle of unit tests, which is testing the smallest unit in isolation. However, real software applications exhibit classes that communicate with other components and services, precluding isolated method testing and breaking the original goal of unit testing. Thus, mocking said components and services allows developers to decouple external dependencies and execute unit tests in isolation, facilitating the construction and execution of a system’s test suite.

Moreover, mock is a general term encompassing a family of similar implementations to replace real external resources during unit testing. There are other similar terms, such as dummy, stub, fake, and mock, causing confusion for developers and readers due to their vague differences. Therefore, we discern them into the stub group, including dummy, fake, and stub, and the mock group, including itself. To clarify this separation, notice that stubs are stand-in resources providing only the necessary data, while mocks extend this concept with object behavior. Hence, developers use stubs to create state verification tests and mocks to build tests that verify method calls, calling frequency, and calling order. Thereby, the mock pattern includes the following five steps:

  1. Create mock object
  2. Set up mock object state
  3. Set up mock object expectation
  4. Supply mock object to domain code
  5. Verify behavior on the mock object

Notice that this pattern highlights how steps one through four are common to both stubs and mocks, while step five only applies to mocks as it includes behavior verification.

Ultimately, mocks provide developers with numerous valuable benefits, such as fast execution of unit tests due to external resource decoupling, error reproduction, unit test localization, ensured controllability and observability, and consistent predictability.

Dart

Last but not least, becoming fully proficient in the Dart language will have a tremendously positive effect on how you code Flutter applications, as this programming language forms Flutter’s foundation by providing the language and runtimes to power this framework’s Apps. Its technical envelope’s24 design is particularly well-suited for client development, prioritizing both development and high-quality production experiences across a wide variety of compilation targets (web, mobile, desktop, and embedded). Moreover, Dart uses static type checking to ensure that a variable’s value always matches the variable’s static type, making it type-safe. It also supports dynamic types combined with runtime checks, offering further flexibility to the language’s typing system. Overall, Dart is a client-optimized language for developing fast apps on any platform. Thereby, Dart allows code compiling into these different platforms:

  • Native — For applications targeting mobile and desktop devices, Dart includes both a Dart VM with just-in-time (JIT) compilation and an ahead-of-time (AOT) compiler for producing machine code
  • Web — For apps targeting the web, Dart includes a development time compiler (dartdevc) and a production time compiler (dart2js). Both compilers translate Dart into JavaScript.

Sound Null Safety

Dart’s null safety support deserves special attention as it forces variables to be non-nullable by default unless developers define it otherwise in their code. Additionally, null safety turns runtime null-deference errors into edit-time analysis errors enhancing the development experience while minimizing runtime exceptions and bugs. Ultimately, Dart’s null safety support builds upon the following three core design principles:

  • Non-nullable by default — Variables are non-nullable unless explicitly declared otherwise
  • Incrementally adoptable — Developers decide what and when to migrate to null safety, allowing for incremental migrations and mixing null-safe with non-null-safe code
  • Fully sound — Dart’s sound null safety allows for compiler optimizations as non-nullable types can never become nullable

“Null safety is the largest change we’ve made to Dart since we replaced the original unsound optional type system with a sound static type system in Dart 2.0.”

Asynchronous Programming

Asynchronous programming is a notably relevant subject in Dart programming and Flutter. Leveraging its powerful features allows one to architect an App and its code in a more organized and efficient manner. Furthermore, the Future and Stream classes characterize asynchronous programming in Dart.

  • Future — It is the result of an asynchronous computation, which is a computation that cannot return an immediate result after being executed. Thereby, this result may have two states, uncompleted or completed. The former refers to a future waiting for a function’s asynchronous operation to finish or throw an error, while the latter refers to a successful or failed completed computation
  • Stream — A stream is a sequence of asynchronous events, distinguished as data or error events. Check out these links to familiarize yourself with this powerful tool
    - Asynchronous programming: Streams
    - Reactive Programming — Streams — BLoC

Packages

Understanding the parts that compose more complex and larger entities enable software engineers to architect applications leveraging functional and behavioral modularity principles. Functional modularity refers to the composition of smaller independent components with clear boundaries and functions. On the other hand, behavioral modularity addresses traits and attributes that can evolve independently. Thus, complex software applications may be broken into functional parts called modules, which can be created, changed, tested, used, and replaced separately. Software modularity brings the following benefits to software systems:

  • More lightweight modules with less code
  • Introduction of new features or changes to modules in isolation, separate from the other modules
  • Easy identification and fixing of module-specific errors
  • Modules can be built and tested independently
  • Enhanced collaboration for developers working on different modules for the same application
  • Reusability of modules across various applications
  • Modules kept in a versioning system can be easily maintained and tested
  • Module fixes and noninfrastructural changes do not affect other modules

Dart favors modularity and provides an ecosystem based on packages to manage shared software such as libraries and tools. At a minimum, a Dart package is a directory containing a pubspec file, a yaml-based file containing metadata about the package. Notice packages may contain dependencies, libraries, applications, resources, tests, images, and examples. Moreover, the concept of separation of concerns between objects in object-oriented programming (OOP) resembles a Dart library, which exposes functionality as a set of interfaces and hides the implementation from the rest of the world. Libraries allow for a better application structure, tight coupling minimization, and more maintainable code. Ultimately, a Dart application is a library, as well.

Remarks

This blog post provided a standardized approach to building Flutter applications. Firstly, it proposed a hybrid architecture combining the strengths and advantages of layered and feature-oriented architectural design patterns. Furthermore, we enhanced this hybrid architecture by complementing it with a modularization approach based on Dart packages. Regarding the selected state management solution, it provided coherent criteria to support the choice of the BLoC pattern and its Flutter implementation with flutter_bloc. Moreover, it emphasized the importance of testing as a core activity of the software development lifecycle while enforcing one hundred percent code coverage for the entire application’s codebase, defending its positive effects on the long-term maintainability and scalability of any given Flutter application. Lastly, it highlighted the unavoidable need to become fluent in Dart programming to take your Flutter skills to the next level when building applications.

I would love to hear your feedback about this blog or discuss any related topics, so feel free to drop a comment or reach out to me on Twitter.

--

--

Alejandro Ferrero
Flutter España

🤤 Founder of Foodium 💙 Lead Mobile Developer at iVisa 💻 Former Google DSC Lead at PoliMi 🔗 Blockchain enthusiast 🇪🇸 🇺🇸 🇮🇹 🇦🇩 Been there, done that