Business Logic Component [1/4]
Introduction
We are starting a series of articles about Business Logic Components, aka BLoC.
These articles are a kind of checklists, tips, and lists of common pitfalls on how to and not to design a flutter application using BLoC. All of the above is based on the experience of the authors, other approaches have the right to life. But we hope the above material will give you fresh ideas and maybe even make you think and question the design approaches already used. The article is aimed at middle-level developers and higher up who are familiar with or want to get acquainted with the BLoC approach.
The article will be an overview and is not trying to be an all-in-one tutorial or a retelling of the documentation. Rather, it is a chip on the cake and an attempt to share real engineering experience after four years of using the approach.
You won't see ads for the approach or package here. This is an objective article highlighting the problems, not the obvious points. We will try to prompt various thoughts, give food for thought, and show interesting points.
Also, there will be no lies like: "PERFORMANCE", "PRODUCTIVITY", "ORGANIZATION", "LIGHTWEIGHT", "BLAZING FAST", "SAVE SOME RAM", "SIMPLE" – we are sure we're all tired enough of such statements, and for a change, sometimes you need to be engineer, not air trader.
Consider this an attempt to convey the experience we have gained over years of practice and errors.
This article is not strictly tied to a specific package or solution and is universal.
Nevertheless, in the head, you can keep a reference to the packages stream_bloc (0.5.2) and bloc (8.1.1).
And we will also try to point out the package's shortcomings and the authors’ fault.
The current stable versions of flutter 3.3.4 and dart 2.18.2.
What do you need to get started with BLoC
If you are new to Dart and Flutter, it's best to start with the basic concepts.
This article is not for you.
• Tutorials
• Get started
• A tour of the Dart language
• A tour of the core libraries
Also, to begin to deal with the BLoC, you must be familiar with Future and understand Stream, so if you haven’t forced yourself to think “reactively” yet, this is the time. Without this, the following reading is meaningless.
To catch up, you can try "Async & Await" and "Streams", and daily honing your asynchronous kata in dartpad will help you.
Also good idea to read "Simple app state management".
Learn how to use ChangeNotifier, ValueListenable, AnimatedBuilder, and ValueListenableBuilder.
If you are already familiar with the above, you are ready, and you should not have any problems.
Patterns
State pattern
A state is a behavioral design pattern that lets an object alter its behavior when its internal state changes. It appears as if the object changed its class.
As you can see in the picture, the same Mario character can have different states.
And the possibility of transition from one state to another is realized through the state machine, but more on that below.
Read more about state:
Observer pattern
An observer is a behavioral design pattern that lets you define a subscription mechanism to notify multiple objects about any events that happen to the object they’re observing.
For this in a flutter, you can use:
- Listenable (e.g. ChangeNotifier, ValueNotifier) with AnimatedBuilder or ValueListenableBuilder widgets
- Stream with StreamBuilder widget
The basic implementation is pretty simple.
You create an object with a list of callback functions.
When the observable object changes, you iterate over the list and call the callbacks one by one.
Those who want to monitor and respond to changes in the observed object add callbacks. This is how ChangeNotifier works.
For example: Imagine that you are making a game "hot-cold" in the area where the user, using the phone, must find the place where the "treasure" is buried.
Every time the distance to the treasure is shortened, the screen becomes redder. When moving away, on the contrary, it turns blue.
It turns out that you need to associate the geo-position with the color of the widget on the screen.
And each change in geolocation should cause a response from the interface.
Read more about observer:
Finite-state machine
A state machine is a behavior model. It consists of a finite number of states and is called a finite-state machine (FSM). The machine performs state transitions and produces outputs based on the current state and given input.
For example: Imagine that the user has to go through a certain flow or stepper in a strictly defined order.
When certain states must be followed by strictly defined events.
Wake up -> Get dressed -> Go outside -> Buy milk -> Come back -> Make a latte
or
Wake up -> Call and order milk -> Wait for the courier -> Pick up milk -> Make a latte
How would you design such a sequence of events and states?
Read more about finite-state machine:
Publish–subscribe pattern
Publisher-Subscriber is a pattern that separates and loses coupling between two layers and scalability improvements.
This is one of the best patterns for scaling software and systems.
Read more about publish–subscribe pattern:
Problems to be solved - race condition
One of the most typical problems in design is race condition and inconsistency. This behavior is quite difficult to detect, trace, and predict. It turns into floating bugs.
To avoid such problems, you will need to update your controller by adding queue management to it.
But a controller with such functionality already exists, and it's name is a BLoC.
The mostly used processing strategy is one by one, strictly in the chronological order of the call. This is the most commonly used case, which is recommended by default.
We can throw out subsequent events when processing is in progress. For example, if a character in a computer game jumped, he cannot jump again while in the air until he lands. Also, this method is good for authentication.
We can ignore the results of previous events when adding an event. For example, if we implement input hints by querying the server, we are always only interested in the actual data.
Example 1
Suppose you have an updatable ListView.
And for it, you have created a controller (ChangeNotifier, mobx, or any other "state manager") with a "request" method.
By calling this method, you do the following:
a) Set the state Loading - to show the shimmer
b) Take the current state of the cursor and request N elements from the backend.
c) Set the status to Done
d) Expand the current list and set a new cursor.
a) You can make a lot of extra requests to the backend. How many times you press the button, so many requests will be.
E.g. Initial --> Loading --> Loading --> Loading --> Loading --> ...
b) The "Done" state will be set by the first completed (not chronologically first!) method while the second one is still in progress. That is, your list will display the processed state while processing is still in progress. And then, suddenly, the state will change again.
E.g. Initial --> Loading --> Loading --> Done -[Still loading, but state is already Done]-> Done
c) The final "Done" state will be set not by the last one called but by the one that will be processed the longest. For example, even if one of the first requests hangs and then falls off on a timeout, an error will be displayed simply because the request lasted longer than the others. Even though the current state has already been received and emitted.
E.g. Initial --> Loading('app') --> Loading('apple') --> Done('apple') --> Error('app')
Example 2
You have an input form with multiple fields (such as a user profile) that can be updated. And there is a button when clicked, the data is updated.
Requests to the backend may take longer than usual or fail altogether.
Try to imagine for yourself what can go wrong in data update transactions.
E.g. asyncController..update('John')..update('Ann')..update('Elon');
Can you predict exactly what value will be set on the server?
Maybe it is worth considering the result of only the last call or even performing strictly in the add queue for such actions.
Example 3
You have an authentication controller.
Is it possible to simultaneously log in as user #1 and log in as user #2 and log out?
Or even so, do you need to ignore the rest when performing one of these actions?
Problems to be solved - coupling
In software engineering, coupling is the degree of interdependence between software modules, a measure of how closely connected two routines or modules are, and the strength of the relationships between modules.
Coupling is usually contrasted with cohesion. Low coupling often correlates with high cohesion and vice versa. Low coupling is often thought to be a sign of a well-structured computer system and a good design, and when combined with high cohesion, supports the general goals of high readability and maintainability.
To reduce coupling and increase code maintainability, a multitier architecture can help us.
For example, in Flutter SDK, we can observe such an approach when separating the Widget layer from the Presentation layer of building an interface using a WidgetsBinding.
Coupling between widgets and elements also happens with the help of BuildOwner.
How does a bloc help us organize our code and split by layers?
We can layer our application like Napoleon cake. For example, we can select the following layers:
- Widgets layer - declarative description of the configuration of our application (e.g. HomeScreen, InheritedUser, LogoutButton)
- Business Logic Component - an intermediary between widgets and data, also managing the processing order (e.g. AuthenticationBLoC, SettingsBLoC, CartBLoC)
- Data - databases, clients, repositories, data providers (e.g. HttpClient, SQLiteDatabase, AuthenticationRepository, SettingsLocalDataProvider)
Thus, we separate the Widgets layer from the Data layer by using: State, Stream of states, and a method that Adds events.
Also, no logic errors get caught in widgets.
BLoC as a concept
The main idea of using BLoC (Business Logic Component) is separate Widget and Data layers. Separation takes place with Pub/Sub pattern. It also serves the purpose of a predictable sequence of transformation of events into states, getting rid of the race condition, and isolating logic errors from Widgets.
This makes the architecture more convenient, clearer, and scalable. It also reduces module cohesion by separating the code into abstraction levels. BLoC is a typical pattern for object-oriented reactive programming.
The idea of the concept is that the UI can generate events (the user clicked on the button) and respond to state changes (the request was generated, the request was sent, a response was received from the cache, a response was received from the back). An attentive reader will notice an important point: many states can (and should) correspond to one event. And also, there may be no state changes for the event at all, for example, an attempt to log out with an unauthorized user. This approach will make your interface more responsive.
Because Widgets can interact only with the `add` method, `stream`, and `state` getters, it helps loose coupling between layers. And since the order of events is manageable, it is impossible to get a race condition or an inconsistent state if used correctly. In any other state manager or solution, such problems are commonplace and very difficult to solve.
Think of the business logic layer as the bridge between the widgets (application layer) and the data layer. The business logic layer is notified of events/actions from the widget layer and then communicates with a repository to build a new state for the presentation layer to consume.
BLoC as a successful state machine
The initial implementation of the BLoC pattern presented by Google did not contain any events and event mappers that worked essentially as reducers. It could be essentially condensed into a phrase – ”sinks in, streams out”. The very first implementation can be seen in this presentation which took place in 2018.
After that, an article was written by a fantastic fellow Flutterist – Didier Boelens. That article expanded on the concept by adding another layer of indirection and creating a state machine with a centralized processor of events.
It is not the first implementation of a mass-usage state machine that associated states with events using a reducer. Firstly, there was Elm. Elm is a functional language for Web apps with syntax very close to one of Haskell, that initially used more of an FRP (functional-reactive programming) approach but later adopted another unidirectional architecture that got the name The Alm Architecture, TEA, or MVU – Model View Update. Later, on top of that work, Redux was created, which falls near the MVU category, and openly states that it is heavily inspired by Elm.
Given all differences, another one that BLoC displays are its declarativity. Given that BLoC losses purity and totality of its “reducer”, it gives back declarativity, management of side effects, and management errors – things that previous implementations of reactive state machines struggle with, especially Redux. Below are examples of imaginary article requests, expressed in MVU translated to Dart, Redux with thunks, and BLoC with generators (stream_bloc). One can spot a difference in declarativity.
BLoC not only allows to declaratively state what is needed, thus not creating imperative events that only change the state but also manages errors, thus allowing to rethrow in the reducer – declaring that something went wrong. Based on that, the BLoC-style reducer is not only the most declarative but also the most concise.
BLoC as a package
If we are talking about early versions, then Felix Angelov bloc was made on rxdart but now has no third-party dependencies (We do not consider dependence on “meta” because it can be considered part of the SDK).
The flutter_bloc package exports the main bloc package and contains just widgets responding to state changes. The package is relatively stable, with recently acquired interfaces for the main classes, satisfying LSP, ISP & DIP.
Despite being a very popular package, it has a few fundamental problems that one should be aware of.
- Instead of manually mapping events into a stream of states, a declarative asynchronous sequence of changes, the bloc package uses pattern-matching emulation to pick a variant of the event to react to and uses higher-order functions to imperatively emit states. The pattern-matching part plays nicely if freezed is not used, but imperative emitting of the states makes API more verbose, more imperative and messes with the sequence of states. The problem with the sequence is discussed further down.
- bloc package is unable to emit identical states. This behavior can be compared to a ValueNotifier or a .distinct() call at the start of the stream. Sounds good at first look, but in reality, it is pointless and harmful at the same time. Since bloc states are usually consumed through mapping in the widget layer either through BlocSelector or Provider’s context.select, distinction at the start of the stream is pointless. As about harmful – reacting to even identical states through BlocObserver can be desired – but is impossible – in that particular implementation. Moreover, such filtering does not guarantee that identical states will not get into the builder, since before the builder's callback, the states are filtered again with buildWhen at the output of which identical states can be obtained.
- Transformation of events into states is also achieved through higher-order functions and transformation of transitions is non-existent. This creates a similar set of problems as with the first point – striving away from native means of the language restricts.
BLoC as a logical choice for Dart
Every language is special and has some tricks up its sleeves. Similarly, not all solutions fit great in all languages.
MVU is a logical solution for Elm because Elm IS MVU. All of its work with side effects (including HTTP requests) is constructed using Cmd abstraction, which its reducer uses, it has tuples, and everything in it fits nicely in this paradigm.
JS/TS is a very dynamic language, and Redux with its greatly polymorphic and dynamic types and plugs that fit in all sorts of slots, JS works with it great. The most obvious example of that is Thunk middleware – with it, the reducer can accept not only actions but also thunks of them.
Dart has generators, which can be used exactly to express what BLoCs reducer it trying to be – an asynchronous, continuous sequence of values that can result in a failure and contain side effects. Dart allows writing BLoCs very close to the source language, leveraging its power to the maximum degree.
Unfortunately, the latest update of the bloc package replaced generators with higher-order functions in event handlers, but luckily freshly added interfaces allowed to creation of a custom implementation that uses generators, just like the original version – stream_bloc.
What BLoC is not
- BLoC is not a function from event to state. Any given added event can produce zero, one, or infinite state changes and result in the same one or totally different state after processing the event.
- BLoC is not a ViewModel or a Presenter. BLoC is a Business Logic Component that is stateful – it does not know anything about the view. Things happen the other way around – the view or widgets knows about the BLoC and adapts to consume its values, possibly deriving states.
- BLoC is not data storage for a single screen that contains all the states. It is incorrect to create a “single BLoC per screen”
- BLoC is not centralized storage of state. Unlike Redux, TEA, and some other unidirectional solutions, BLoC is decentralized, and there should be multiple BLoCs for different purposes. It is a Component.
- BLoC is not implemented at its core. It’s an abstraction. Thus, BLoC should not know anything about the underlying implementation that it is working with and not expose any Database models or DTOs in its states.
In subsequent articles, we will explore:
- Layers and project structure
- Examples
- Helpful hints, design tips, and tricks
- Pitfalls