Firebase 🔥 allows developers to get their apps working quickly. Flutter 💙 does the same thing. But, as it goes in life, trade-offs are everywhere. Sure, you can hack something together in a dash of enthusiasm but this initial excitement will fade away as soon as you get totally lost in your own code.
Flutter apps need structure that is easy to orient yourself in, testable and maintainable. It also wouldn't hurt if the way you architect your Flutter apps allows for adding new features without a headache. Especially with a client-centric service such as Firebase Firestore, it's extremely important to keep your code clean. Let's do it by following the principles of Domain-Driven Design.
The project we will build
We're going to build a fairly complex note taking application featuring things such as
- Real-time Firestore data
- Extensive data validation for a rich user experience
- Google & email + password authentication
- Reorderable todo lists and much more
A video is worth a thousand words, as they say, so before getting into the details of how DDD fits into all of this...
Domain-Driven Design - the big picture
You have surely seen or built Flutter apps using Firebase Auth and Firestore and I'm sure you didn't put the Firebase calls into your widget code. In other words, you used some sort of an architecture.
You may have even watched my Clean Architecture course where we worked with a simple REST API. Over there, the class dependency flow together with the file and folder structure were neatly outlined for you and it's possible to use it with Firebase too. How is Domain-Driven Design (DDD) different then?
Simply said, it's better on every level. We still have a good separation into layers which brings beautiful traits ease of navigation and testability. So no, we still don't put all of our code into the UI or state management.
We will go over everything in more detail later but for now just know that the diagram below outlines the key architectural layers present in a DDD Flutter app. We're going to use BLoC in this series but, as usual, I didn't forget about all of you who have declared BLoC a sworn enemy. If you don't want to use it in your apps, feel free to use a view model of your choice, whether it be
Store, or even the new
StateNotifier. More on that later.
There are a few things that I couldn't fit on the diagram. Namely:
- Arrows represent the flow of data. This can be either uni-directional or bi-directional.
- The domain layer is completely independent of all the other layers. Just pure business logic & data.
If you're familiar with my Clean Architecture course, the above diagram feels somewhat familiar. If you aren't, no worries. It's not a prerequisite for this tutorial series. Note that classes such as
FirebaseAuth are ready-made data sources, so we will write code from repositories upwards.
ValueObjectsalso contain logic. This ranges from data validation and helpers to complex computations.
Exceptions are put into the regular flow of data as
Failures. The only place for
Repositories. This will make it impossible not to handle exceptions, which is a very good thing.
Before we go ahead and take a closer look at what are the roles of all the layers and their classes, let's first tackle the age old question of folder structure.
I'm the first to admit that the folder structure outlined in the Clean Architecture course is a pain to deal with. With DDD, we're going to take a different approach.
Layers will hold features, not the other way around. This will still keep the code readable but, most importantly, it will ensure that adding more features and sub-features is going to be a pure bliss!
Let's take a look at the notes feature. While the main notes folder is present inside every layer (application, domain, infrastructure, presentation), its subfolders are different!
What does this mean? Well, we can have both a good folder structure and a separation into architectural layers at the same time 🎉🥳
It's also worth noting that some features don't even have to necessarily be represented in all layers. Notice that splash folder in the presentation layer? There is no inherent "splash screen logic", so it doesn't make sense to put it into other layers.
All in all, we can mix and match the dependencies in between features as long as we keep true to the dependency flow outlined in the diagram above (domain layer has to have ZERO dependencies on other layers).
Unlike with the spaghetti architecture 🍝, you will always know where to put a certain class when you're following the Domain-Driven Design principles. Each one of the layers has a clear-cut responsibility. As you can see on the folder structure picture above, every architectural layer contains features and possibly a core folder which holds classes common to all the features in that layer (helpers, abstract classes, ...).
This layer is all Widgets 💙 and also the state of the
Widgets. I already mentioned that we're going to use BLoC in this series. If you're not familiar with this state management pattern, I'd recommend you to check out this tutorial. The main difference from something like a
ChangeNotifier is that BLoCs are separated into 3 core components:
- States - Their sole purpose is to deliver values (variables) to the widgets.
- Events - Equivalent to methods inside a
ChangeNotifier. These trigger logic inside the BLoC and can optionally carry some raw data (e.g.
TextField) to the BLoC.
- BLoC - NOT A PART OF THE PRESENTATION LAYER!!! But it executes logic based on the incoming events and then it outputs states.
So, if you're not using BLoC, just replace the
Event classes with a single View Model class of your choice.
With Domain-Driven Design, UI is dumbest part of the app. That's because it's at the boundary of our code and it's totally dependent on the Flutter framework. Its logic is limited to creating "eye candy" for the user. So while animation code does belong into this layer, even things like form validation are NOT done inside the presentation layer.
This layer is away from all of the outside interfaces of an app. You aren't going to find any UI code, network code, or database code here. Application layer has only one job - orchestrating all of the other layers. No matter where the data originates (user input, real-time Firestore
Stream, device location), its first destination is going to be the application layer.
The role of the application layer is to decide "what to do next" with the data. It doesn't perform any complex business logic, instead, it mostly just makes sure that the user input is validated (by calling things in the domain layer) or it manages subscriptions to infrastructure data
Streams (not directly, but by utilizing the dependency inversion principle, more on that later).
UseCaseclasses. Learn more from the mysterious uncle.
The domain layer is the pristine center of an app. It is fully self contained and it doesn't depend on any other layers. Domain is not concerned with anything but doing its own job well.
This is the part of an app which doesn't care if you switch from Firebase to a REST API or if you change your mind and you migrate from the Hive database to Moor. Because domain doesn't depend on anything external, changes to such implementation details don't affect it. On the other hand, all the other layers do depend on domain.
So, what exactly goes on inside the domain layer? This is where your business logic lives. We are going to get to everything in detail in the next parts of this series, but everything which is not Flutter/server/device dependent goes into domain. This includes:
- Validating data and keeping it valid with
ValueObjects. For example, instead of using a plain
Stringfor the body of a
Note, we're going to have a separate class called
NoteBody. It will encapsulate a
Stringvalue and make sure that it's no more than 1000 characters long and that it's not empty.
String, it will be impossible to pass an
EmailAddressto a method expecting a
Passwordargument and vice versa.
- Transforming data (e.g. make any color fully opaque).
- Grouping and uniquely identifying data that belongs together through
- Performing complex business logic - this is not necessarily always the case in client Flutter apps, since you should leave complex logic to the server. Although, if you're building a truly serverless 😉 app, this is where you'd put that logic.
In addition to all this, the domain layer is also the home of
Failures. Handling exceptions is a 💩 experience. They're flying left and right across multiple layers of code. You have to check documentation (even your own one) a million times to know which method throws which exception. Even then, if you come back to your code in a few months, you're going to be again unsure if you handled all exceptional cases.
We want to mitigate this pain with union types! Instead of using the
return keyword for "correct" data and the
throw keyword when something goes wrong, we're going to have
Failure unions. This will also ensure that we'll know about a method's possible failures without checking the documentation. Again, we're going to get to the details in the next parts.
Much like presentation, this layer is also at the boundary of our app. Although, of course, it's at the "opposite end" and instead of dealing with the user input and visual output, it deals with APIs, Firebase libraries, databases and device sensors.
The infrastructure layer is composed of two parts - low-level data sources and high level repositories. Additionally, this layer holds data transfer objects (DTOs). Let's break it down!
DTOs are classes whose sole purpose is to convert data between entities and value objects from the domain layer and the plain data of the outside world. As you know, only dumb data like
int can be stored inside Firestore but we don't want this kind of unvalidated data throughout our app. That's precisely why we'll use
ValueObjects described above everywhere, except for the infractructure layer. DTOs can also be serialized and deserialized.
Data sources operate at the lowest level. Remote data sources fit JSON response strings gotten from a server into DTOs, and also perform server requests with DTOs converted to JSON. Similarly, local data sources fetch data from a local database or from the device sensors.
Repositories perform an important task of being the boundary between the domain and application layers and the ugly outside world. It's their job to take DTOs and unruly
Exceptions from data sources as their input, and return nice
Either<Failure, Entity> as their output.
If you don't use Firebase Firestore, you'll probably need to cache data locally yourself. In that case, it's the job of the repository to perform the caching logic and orchestrate putting data from the remote data source to the local one.
Code is coming
If you made it through this information-packed article, I salute you. Although we'd all like to immediately start coding, this tendency is precisely what causes spaghetti code and spaghetti architecture to rise 🍝. Having at least this preliminary information in mind when we start coding in the next part (after setting up Firebase), I'll then guide you through the intricate details as we go. One may say that AOT learning will be complemented by JIT explanations. Yes, I know I should get some fresh air... See you in the next part!