DeComp is an architecture approach that allows developers to build highly scalable applications that
consist of many independent Components
, each separated into a standalone package.
There are quite a few approaches to writing great apps in Flutter. However, none of them can be truly called an architecture; they sure do handle business logic, but that's about it.
And architecture should answer more questions than just that. To be sufficient, we should have uniformed answers for many questions, some of which are:
- How do we organize our code?
- How do we layer our code and communicate between them?
- How do we transform data between different layers?
- How do we handle errors?
- How do we handle localization?
There is no doubt that many Flutter developers and teams have gathered enough expertise over the years to answer some of those questions. However, there definitely is a need to have an actual architecture to have defined answers for everyone.
So this is exactly what this project is all about.
By having "features" separated into Component
-s, we have a foundation to answer all the questions mentioned above.
This approach allows several great benefits:
- 🎸
Component
is self-sufficient and standalone; it encapsulates business logic inside and allows to reduce code duplication dramatically - đź§©
Component
-s are reusable not only between different parts of your app but also between multiple applications - ⏱️ Each individual
Component
can be developed independently with almost zero integration hassle - 🔥 To embed a
Component
into our UI you need just one line of code
As we have discussed previously, a Component
is a made-up word by which we mean a **self-sufficient piece of the app
**, which
has some internal business logic.
Following this definition, a Component
consist of at least two parts (actually more, but for simplicity let's say so):
- Something to describe UI
- Something to describe Business Logic
For the first part it's simple, we already have Widget
-s after all.
For the second part there are many options of implementations. de_comp
is based on
a bloc
package, so we also have an answer for that part.
To rephrase:
A
Component
, in its simplest form, is aWidget
paired with aBloc
.
Component
can consist of many Widget
-s, but should have at least one.
The latter part of our original definition, has some internal business logic, is important and is exactly what
differentiates between a Widget
and a Component
.
You can absolutely have some parts of your app still written as plain Widget
-s in de_comp
, but those
Widget
-s should be "dumb", meaning there's no real Business Logic involved.
Absolutely. Just like with plain Widget
-s, you can have unlimited number of nested Components
inside any
Component
. The beauty of using bloc
is that all Bloc
-s for those Components
would be passed down
via Provider
-s. de_comp
adds another layer of abstraction to allow you to have a defined contract of what Bloc
-s
inner components require to operate inside a given "parent" Component
.
Almost every screen of your app should be a Component
, every part of a screen, which has some internal logic,
should be a Component
.
If a piece of UI has another
Component
-s inside of it, it always has to be aComponent
.
Here are some real-life example of what other features that should be a Component
:
It consists of a TextField
widget and some logic of how to validate entered value. This logic can be simple, lets say
a Regex
for validation, or it can be more complex: we might want to send this value to our backend and perform some
sophisticated validation there.
If there are 5 places in our app where we should have a text field of this type, we would benefit greatly to have this
logic encapsulated inside our Component
and not written multiple times across different screens.
It consists of a ListView
(or SliverList
if you want to be fancy) widget and some logic of how to get, maybe
even how to sort and filter, that data (that's a little over-simplification, but is ok for now).
Here we obviously would benefit from having this piece of our app as a Component
because we can have different places
in which we might need to display this list of messages; let's say we might have a dedicated "chat screen" and also have
those messages be displayed as a pop-up dialog upon some conditions. We still would write this Component
only once
without
any code duplication.
You know, that bubble that appears when you activate Siri.
It consists of some graphics elements (maybe different SVG images, or maybe a Lottie animation) as UI and has some business logic: know current assistant's status (awaiting or processing a command), handle presses to execute a new command and so on.
We most definitely might have this thing appear on multitude of different places throughout our app, and having all of this logic encapsulated inside has an immense benefit.
As we said previously, you absolutely can have plain Widget
-s inside your app. The only requirement is that those
widgets
host no business logic, or very little of it with this logic having no data operations.
Pretty much all simple Button
-s, Text
-s and other Widget
-s without internal Business Logic still should be just a
Widget
.
Your UI should be split into smaller Widget
-s. If something is not a screen, has no internal Business Logic,
and just takes some data and displays it, most likely it should not be a Component
.
A self-sufficient Component
is great, but we still need to have our Component
be displayed inside our app.
Thus, we need to "embed" it somewhere inside our layout.
Embedding can be split into two logical parts:
Let's imagine that we have an abstract Component
called PhoneNumberTextField
; remembering our definition, it should
have a: Widget
- PhoneNumberTextFieldWidget
and a Bloc
- PhoneNumberTextFieldBloc
.
Now, let's say we want to add this Component
to some part of our app, for example to our sing up form called
SignUpWidget
.
We simply add Component
's Widget
just like
you would with a plain Widget
, which may look something like this:
@override
Widget build(BuildContext context) {
return const Column(
children: [
...
PhoneNumberTextFieldWidget(),
...
],
);
}
And that's the first part done! Notice, that we have zero dependencies here, nothing was passed to Widget
's
constructor.
That's the beauty of splitting embedding into two parts.
This approach shines even more if we were doing this embedding inside another Component
, because it would not have to
know about any details of the Component
we are embedding; just the name of that Component
's Widget
is all it
needs!
Now that we have our Component
's Widget
somewhere inside our layout, we need to utilize Provider
mechanism to pass
down the layout required Bloc
for our Component
.
We discuss how and where exactly we do this later on in this documentation (in View part), but for now it's
more
important just to differentiate between using a Component
's Widget
somewhere inside your layout without any
dependencies
and knowing that we would pass those "dependencies" as this Component
's Bloc
somewhere above later on.
Most importantly, we will do this not inside our Component
, but inside our Application
. There is a good reason
for that: to build an instance of our Bloc
, we might need some external dependencies. And most likely those
dependencies are going to be inside your DI of choose. There's zero need for any of your Component
to know anything
about it; decoupling Bloc
initialization from Component
's visibility level allows you to change your DI approach
with zero changes made to your Component
-s.
To recap:
Component
does not create an instance of itsBloc
. InitializedBloc
is passed down to theComponent
viaProvider
and is initialized inside application level. This allows us to decoupleComponents
from any concrete implementations of DI.
As an abstract example, let's imagine that somewhere inside our app we write:
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => PhoneNumberTextFieldBloc(),
child: const SignUpWidget(),
);
}
SignUpWidget
is aWidget
which was used in the previous example as a place where we have embedded ourPhoneNumberTextFieldWidget
, so we have it as a child of ourBlocProvider
.
And that's it! We have successfully embedded our Component
! Key points to summarize how we embed a Component
:
- First we add our target
Component
'sWidget
somewhere we need it to be inside the layout of the anotherComponent
- Second we create an instance of our
Component
'sBloc
and pass it down viaBlocProvier
inside ourApp
In the previous chapter we have discussed basic concepts that allow Component
to be versatile, independent and easy to
use.
This is all possible because of the most important characteristics of a Component
: is its self-sufficiency; meaning
that it has zero clue about the environment it is intended to be used. In order to do that, it has to rely on several
contracts, which will provide our Component
will all the data it needs to operate.
The absolute basic contract of our Component
is an Event
. It frankly has almost zero difference with Bloc
's
definition.
An
Event
specifies what aComponent
can do.
We can say that an Event
is a public contract of our Compoent
which describes its functionality.
From internal perspective,
An
Event
is a way to notify ourComponent
'sBloc
that something that ourComponent
can do has happened.
Handling an Event
usually means updating our State
, which will trigger updating our UI.
Upon some Event
-s we can change our State
right away, let's say that ConfirmPrivacyPolicyAgreement
should enable "
continue" button inside our UI after the user has read our privacy policy.
Upon some Event
-s we might want to get some data, let's say that UpdateWeatherForecastEvent
seems like a good
candidate to actually get up-to-date data about current weather and only then update our State
with that new
information.
This is usual usage of Events
, nothing new compared to how Bloc
prescribes to use them. However, for de_comp
we
need to pass extra responsibility to our Event
-s for us to be possible to embed our Component
-s with just one line
of code: we must pass initialization data via some Event
as well.
Most often this event may be called
InitializeEvent
.
What do we mean by "initialization data"? Here are some examples for our demo app:
- Some basic data:
ID
of a city to show weather forecast for - Some flags:
showCompactForcast
,hidePreviousHours
,forceReload
, and so on
Initialization data most definitely does not mean passing dependencies, as we already know that this is done upon
Bloc
initialization.
This is extremely useful, because allows us to create Bloc
only once upon loading specific screen containing our
Bloc
but allows us to initialize it with different data multiple times.
For example, if we have a screen where one Component
is responsible for choosing a city from a list and another
Component
is responsible for showing weather for selected city, we can just call InitializeEvent
every time our
selected city changes.
An action is a new concept introduced in de_comp
which extends Bloc
functionality and allows Component
to notify
"outside world" about that something happened inside of it:
An
Action
notifies external listeners that something has happened inside aComponent
.
In order to produce an Action
, we still have to handle an Event
. So an Action
can be produces with a State
update
or instead of State
update inside our Bloc
.
Let's see how Action
-s work by imagining a WeatherSettingsComponent
, which allows user to set preferred temperature
unit (enum of TemperatureUnit
with possible values of celsius
and fahrenheit
).
When we change selected unit inside our UI, an Event
, describing selected unit, will be produced. We will handle this
Event
inside of our Bloc
, maybe somehow update State
and produce an Action
, let's say
TemperatureUnitSelectedAction
, with selected TemperatureUnit
. This would allow external observers to know about this
change.
By using Actions
we can have multiple Components
communicate between each other in an efficient way without even
knowing about one another. We will discuss how exactly this is done in later chapter.
Pretty much every app needs to be localized to different languages. Even if your app supports only one language (for now of for good), it's still a wise decision to decouple actual strings from your code.
There are different approaches of how exactly to store those strings: built-in intl, 3-rd party services like Lokalise / Localizely and others.
It's a good idea to have your application abstracted from specific chosen approach, because it can quite possibly change
as time goes by. Thus de_comp
introduces localization contract, which comes shipped with every Component
.
The interesting part is that this contract is bundled with our BaseBloc
. This allows us several things:
- Provide contract implementations upon
Bloc
creation inApplication
domain, thus having freedom of choosing and changing any approach for localization outside ourComponent
domain - Having access to localization inside our
Bloc
, making it possible to handle advances niche cases of localization - Accessing our defined string inside our
Component
'sWidget
asbloc.string_name()
, uniforming this approach with usualBloc
access inside theWidget
.
Such a contract is a pure Dart
file, which defines all strings that a Component
needs. For example:
abstract class MyComponentLocalizationContract extends BaseLocalization {
String title(BuildContext context);
String numberOfSelectedItems(BuildContext context, {
required int itemsNumber,
});
String okButton(BuildContext context);
String cancelButton(BuildContext context);
}
And that's it from the Component
's perspective. We don't care how exactly those strings are going to be implemented,
it's none of our Component
's concern.
Later on, inside our app, we can have multiple implementations of this contract, which implement different approaches; let's say that we are cool with having just using plain strings for our debug builds, but want to use intl for release builds to improve our localization flow using a dedicated team of translators.
For example, our string implementation would be:
class MyComponentLocalizationSimple extends MyComponentLocalizationContract {
@override
String title(BuildContext context) => 'My title';
@override
String numberOfSelectedItems(BuildContext context, {
required int itemsNumber,
}) =>
'Selected items: $itemsNumber';
@override
String okButton(BuildContext context) => 'Ok';
@override
String cancelButton(BuildContext context) => 'Cancel';
}
For our intl implementation with such .arb
file:
{
"title": "My cool title",
"numberOfSelectedItems": "Selected items: {number}",
"@numberOfSelectedItems": {
"placeholders": {
"number": {
"type": "int"
}
}
},
"okButton": "Ok",
"cancelButton": "Cancel"
}
Our contract implementation would be:
class MyComponentLocalizationIntl extends MyComponentLocalizationContract {
@override
String title(BuildContext context) => AppLocalizations.of(context).title;
@override
String numberOfSelectedItems(
BuildContext context, {
required int itemsNumber,
}) =>
AppLocalizations.of(context).numberOfSelectedItems(itemsNumber);
@override
String okButton(BuildContext context) => AppLocalizations.of(context).okButton;
@override
String cancelButton(BuildContext context) => AppLocalizations.of(context).cancelButton;
}
We have two different approaches of localizing applications under the same contract defined by our Component
, and can
use them interchangeably without our Component
knowing anything about it.
Using this contract inside our Component
's Widget
is quite straightforward as well:
@override
Widget build(BuildContext context) {
return Text(
bloc.localization.numberOfSelectedItems(
context,
itemsNumber: 10,
),
);
}
As stated previously, localization contract is bundled with our
Bloc
, so we access our localization viabloc
instance.
A component has its own UI and business logic based on bloc package.
A Component specifies what it can do (via accepting Events
), what data or/and events it can produce during its
operation (via Actions
) and what data
it needs to operate (via ComponentRepository
contracts).
A component has zero clue about its environment: it does not depend on DI, navigation or localization approach, even from where and how to get data. Each of those tasks is forwarded to a dedicated abstraction level.
A component may contain other components inside of it, while still keeping up to the promise above.
As stated above, a Component
does not know much about how it's going to be used, nor how to get required data to
operate.
It solely relies on its contracts to describe what it needs.
When we embed such Component
into our application, we need to provide implementations of those contracts. Here comes
the neat part:
we do that via Coordinator
and a View
.
A Coordinator
is a class that describes how component (or even components inside that component) communicate with an "
outside word" and each other.
A View
sits above coordinator and has single purpose: to construct Bloc
needed for our Component
using your
chosen approach: Singleton, DI or anything really.
This makes sense as we operate in a domain of the specific app you are building and not inside the Component
,
hence not hard-wiring any external dependencies and keeping our components standalone.
There's a more in depth explanation of how all the concepts mentioned above ties up together in Documentation.
Also, it's highly recommended to take a look at /example
project to see this approach in action.