iTranslated by AI
[Flutter] Rethinking and Rebuilding App Architecture from Scratch
For about the past half year, I've been rebuilding a Flutter app from scratch for work.
Just as I had set this year's personal theme to "Architecture"[1], and as part of that, I was reading "Clean Architecture: A Craftsman's Guide to Software Structure and Design" (hereinafter: the Clean Architecture book), I decided to tackle this rebuild while thinking deeply about "Architecture" with my own head.
After about six months of implementation while agonizing over architecture, the app is finally taking shape and I've accumulated a certain amount of knowledge. I'd like to summarize that generalized knowledge in this article.
Warning
This article is not a "this is the best practice for Flutter app architecture!" kind of post. It's just an introduction to an example where I judged this to be optimal for the requirements right in front of me.
What I write here isn't something that will work for every app as-is. As I understand is emphasized in the Clean Architecture book, I believe it's important for each software developer to consider their own requirements and situation and continue thinking about the optimal architecture at that time.
Instead of thinking "Let's make the next app by fitting it into the patterns written here," I hope you read this article while thinking "Then for my app, would doing it like this be good?" I've consciously thought about the architecture introduced here so as not to be pulled more than necessary by any specific patterns commonly introduced.[2]
Main App Requirements
Before getting into the specifics of the architecture, I'll explain the requirements of the app I'm developing. Since I can't write the specific app name or content, I'll list the elements that strongly influenced the architectural considerations.
Many patterns of fetching data saved in the backend (Firestore this time) and displaying it on the screen
Functions that are sufficient with so-called "coloring JSON" account for about 60%. However, some functions require pulling and merging data from multiple collections, or processing the fetched data according to in-app logic before displaying it in the UI. Conversely, there are functions where data to be saved must be generated through some processing within the app.
In other words, while it can't be said that it "only" inputs and outputs data as-is, it's also not at a level where a complicated system is needed inside the app. To put it another way, if the Widget class and the Firestore operation class were connected directly, the logic in between would become a mess.
Keep in mind the development and release of "another app with similar functions"
One of the important requirements is that the business strategy considers developing another version of the app with different targets or design concepts based on the app developed this time, or an app specialized by extracting only some of the functions. Therefore, the code is required to be designed in a form that can be used in common as much as possible.
Hard to develop/debug at a desk because it uses GPS
This app centers on functions using GPS. Therefore, development at a desk requires a function to emulate GPS, but using the emulation functions standardly provided by Android / iOS platforms requires some effort or has constraints for each.[3]
Therefore, a mechanism to simulate dummy location information according to arbitrary logic and a mechanism to easily switch it with build settings, etc., is necessary.
Please read on with the understanding that the app is like that.
Dividing the Whole into 3 Layers + α and Organizing "Boundaries" and "Dependencies"
First, let's look at the big picture. What I first learned after reading the Clean Architecture book was to define the "boundaries" of layers and organize the "dependencies" of layers.
The figure below is the broad architecture of this app. Please take a look, focusing on where the boundaries are and how the dependencies of each layer are structured.

It's very simple when put into a diagram. You might feel like it's a division you've seen somewhere before. The important thing is to properly decide and follow the rules set for each layer and the rules for dependencies.
Setting the Rules
Let's take a look at what these rules are. After that, I will organize the benefits that these rules bring.
Dependency Rules
First, regarding dependencies. Rather than explaining "dependencies" at length with words, I will proceed using concrete code examples.
For example, when imagining a feature like "fetching a list of group members and displaying them on a screen," the following code shows that the MembersState class in the View layer calls (references) the MembersLogic class in the BusinessLogic layer. Therefore, we can say that "MembersState depends on MembersLogic."
import 'package:myapp/businesslogic/member_logic.dart';
class MemberState extends State<MemberScreen> {
final logic = MemberLogic();
// List of members used for screen display
late List<Member> _members;
@override
void initState() {
// Fetch data through MemberLogic
_members = logic.getMembers();
}
}
Looking at the first diagram, the dependency between the View layer and the BusinessLogic layer is defined as View -> BusinessLogic, so the above code is OK.
On the other hand, since there is no arrow between the View layer and the Repository layer, the following code would be NG (No Good).
import 'package:myapp/repository/firestore_member_repository.dart';
class MemberState extends State<MemberScreen> {
final repository = FirestoreMemberRepository();
// List of members used for screen display
late List<Member> _members;
@override
void initState() {
// Fetch data through MemberLogic (Wait, this is calling repository directly!)
_members = repository.fetchMembers();
}
}
In this way, we define "which layer is allowed to depend on (reference) which layer" as an architectural rule.
By the way, you can tell whether the code follows this rule instantly just by looking at the import statements.
In the case of View -> BusinessLogic, the files listed in the imports must always be businesslogic/xxxx.dart. If repository/xxx.dart is included in the imports, it means a View -> Repository dependency has been created, so it can be mechanically judged as NG.
To analyze this statically, I have introduced the import_lint package this time.
Dependency Inversion
Now, while things are simple when the call path is the same as the direction of dependency, like in View -> BusinessLogic, if we look at the relationship between BusinessLogic and Repository, the dependency is Repository -> BusinessLogic even though BusinessLogic wants to call Repository.
I will explain the reasons for establishing rules that invert the relationship between "calls" and "dependencies" later, but here, I'll first explain the method of organizing the direction of dependencies using the concept of "Dependency Inversion."
First, as you can see from the previous code example, if you were to implement "BusinessLogic calls Repository" directly, the dependency would become BusinessLogic -> Repository.
To "invert" this, you define an interface within the BusinessLogic layer and code the MemberLogic class to depend only on that interface.
// Interface for accessing member data
abstract class MemberRepository {
Future<List<Member>> fetchMembers;
}
import 'package:myapp/businesslogic/interface/member_repository.dart';
class MemberState extends State<MemberScreen> {
MemberState(this.repository);
final MemberRepository repository;
Future<List<Member>> getMembers() async {
return await repository.fetchMembers();
}
}
Next, implement the FirestoreMemberRepository class in the Repository layer, which inherits from MemberRepository.
import 'package:myapp/businesslogic/interface/member_repository.dart';
// Class that accesses Firestore to input/output member data
class FirestoreMemberRepository extends MemberRepository {
@override
Future<List<Member>> fetchMembers() async {
// Process to fetch member data from a specific Firestore collection.
};
}
By doing this, if you focus on the import statements, you'll see that the MemberLogic class in the BusinessLogic layer indeed depends only on files within the same layer. Meanwhile, the FirestoreMemberRepository class in the Repository layer depends on files in the BusinessLogic layer (Repository -> BusinessLogic), showing an "inversion" between the direction of calls and the direction of dependencies.
You might think, "Wait, isn't that just forcedly placing a file named Repository in the businesslogic folder even if it's an interface? Isn't it just wordplay?"[4] However, organizing dependencies using this technique brings clear benefits. Let's look at those next.
Benefits of Organizing Dependencies
One of the benefits of strictly organizing dependencies in this architecture is "exchangeability."
A prime example of this is the BusinessLogic layer. Looking at the diagram, the BusinessLogic layer (excluding Data) has no arrows pointing toward any other layer. In other words, regardless of what changes occur in other layers—even in the extreme case where you delete the view and repository folders entirely—no compilation errors will occur within the BusinessLogic layer.
This helps satisfy the following two requirements:
- Developing "another app with similar functions"
- Emulating data sources (especially GPS)
Let's look at each in detail.
1. Developing "another app with similar functions"
For instance, if a business discussion arises about wanting to "develop another app with a completely different UI and narrowed-down functions," the developer doesn't need to think about things like, "Okay, I need to share this class and this one, and this one, but decouple that one... oh, and I have to refactor this class along with it..." All you have to do is mechanically copy and paste the businesslogic folder[5] and create a new View layer that calls only the necessary classes within it.
Also, in that case, if the data destination and format are the same, you can copy and paste the Repository layer as well. Conversely, if the requirement is "For this app, we'll use AWS instead of Firebase, so please call that," you just need to re-implement the Repository layer, and the BusinessLogic layer can still be reused without any changes.
2. Emulating data sources (especially GPS)
Even when not making a separate app, it becomes easier to handle requirements such as "changing the data storage/source depending on the execution mode."
For example, let's consider a case where you want to switch between a "mode that connects properly to Firestore to verify operation from UI to database" and a "simple mode that inputs/outputs data only in memory because you want to develop the UI without being affected by Firestore's circumstances." In terms of code, you would create two classes in the Repository layer: FirestoreMemberRepository and InMemoryMemberRepository.
When considering switching which of those classes to use via runtime arguments (e.g., --dart-define), as you can see from the architecture diagram, there are no arrows pointing toward the Repository layer. Thus, there is absolutely no need to consider other layers like View or BusinessLogic. You don't have to worry about things like "In this mode, it affects this part of the logic, so I need to branch here with an if... oh, maybe I need a refactor after all..."
In the same way, GPS can also be emulated. Once you prepare "a class that fetches location information from the device's GPS" and "a class that returns random location information according to logic" or "a class that fetches past data and reproduces movement" in the Repository layer, you just need to switch them according to the runtime arguments. View and BusinessLogic don't need to care where those latitudes and longitudes originated.[6]
In this way, by organizing dependencies, it becomes easier to predict which parts can be decoupled and where the impact will occur (especially in the form of "compilation errors"). As a result, I feel it's a huge benefit that it becomes easier to visualize how to develop separate apps or create simple emulation features.
Rules for Each Layer
Now that we've divided the layers and organized their dependencies, let's look at the rules for what kind of code should be implemented in each layer.
Before getting into individual explanations, let's add relationships for things other than the code we write, such as Flutter and Firebase, to the previous diagram.

As you can see here, only View is allowed to depend on Flutter, and only Repository is allowed to depend on Firebase. Please keep this in mind as you read further.
BusinessLogic
In BusinessLogic, as the name suggests, we represent "what the user wants to do and its procedures" in code.[7] To be more specific, I think of this layer as doing "what a user wants to do regardless of the platform the system runs on, or even if it weren't a system at all."
For example, if there's a "thing to do" like "I want to list ramen shops within a 300m radius of my current location in order of proximity," the procedure can be described in words as follows:
- List all ramen shops in Japan.
- Check your current location.
- Calculate how many meters each shop is from your current location.
- If the calculation result is within 300m, make a note of that ramen shop somewhere.
- When finished checking all of them, look back at the notes.
- Once you've created a note sorted by proximity, it's complete.
This task can be done via an app or a PC, with a GUI or CUI, by a computer or manually (setting aside time and ability constraints). The BusinessLogic layer is where you write such procedures in code.
In other words, you must not write "using Flutter" or "fetching data from Firebase" in this layer's code. It's no exaggeration to say that this is a layer where you aim to implement using only Dart's standard classes without using any packages at all.
For example, the code for the RamenListLogic class that realizes the above example would look something like this (this is just an image):
import 'package:myapp/businesslogic/interface/ramen_repository.dart';
import 'package:myapp/businesslogic/interface/location_repository.dart';
import 'package:myapp/data/restaurant.dart';
class RamenListLogic {
RamenListLogic(this.ramenRepository, this.locationRepository);
final RamenRepository ramenRepository;
final LocationRepository locationRepository;
Future<List<Restaurant>> getVisitableRestaurants() async {
final allRestaurants = await ramenRepository.fetchAll();
final currentLocation = await locationRepository.detectCurrentLocation();
final visitableRestaurants = allRestaurants.where((restaurant) {
return _calcDistance(restaurant.location, currentLocation) <= 300;
}).toList();
return visitableRestaurants..sort((r1, r2) => r1.distance - r2.distance);
}
}
The key points of the above code are as follows:
- Do not import files from the
vieworrepositoryfolders. - Do not import files provided by Flutter or Firebase.
- Receive concrete classes for
RamenRepositoryandLocationRepositoryfrom the outside (via the constructor in this case).
This results in a class that is "completely independent of other layers (with Data being a slight exception)" and "implemented only with pure Dart code."
At this time, the Repository interface defines methods in a form that is most straightforward to use for expressing the BusinessLogic. Personally, my image of the BusinessLogic layer is that it's the layer that can be implemented most selfishly without worrying about anything else. I make it a point to start from here whenever implementing an app feature. You must not think about things like the data format on Firestore or how the screen transitions are structured.
By doing this, unit testing and operation verification become very easy. Since code in this layer doesn't depend on Flutter, you can quickly run it with the dart command without running the app with flutter run, and for unit tests, you can verify operation with dart test.[8]
The Repository classes that should be passed to the constructor can just be appropriate mocks that are easy to test.
import 'package:myapp/businesslogic/interface/ramen_repository.dart';
class MockRamenRepository extends RamenRepository {
/// Returns fixed data as appropriate
@override
Future<List<Restaurant>> fetchAll() async {
return const [
Restaurant(...),
Restaurant(...),
Restaurant(...),
Restaurant(...),
Restaurant(...),
Restaurant(...),
]
}
}
Repository
The Repository layer's role is to absorb all the various circumstances that occur in the database.
By "circumstances," I mean things like:
- Using Firestore for the database.
- But partially fetching data by calling Functions.
- Needing to pull data from multiple collections to construct the target data.
- Changing data structures or names because parts of the structure broke down due to feature additions.
- Using AWS instead for certain derivative apps due to business reasons.
You can think of it as writing code here to handle data fetching and conversion so that data can be returned according to the interface specified by the "selfish" BusinessLogic.
The ideal state is being able to resolve whatever issues occur in the backend without changing any code in the View or BusinessLogic layers.
Therefore, for example, you cannot return data to the BusinessLogic layer in the form of types provided by the Firestore package, such as DocumentSnapshot. You must always convert it to a type you defined in the Data layer before returning it. The moment a Firestore import statement appears in a View or BusinessLogic file, I judge that something is wrong.
UI
The UI layer is probably the easiest to imagine.
This is the layer where you define Widget classes according to Flutter's way while looking at Figma, and write code to control rebuilding with state management packages like provider or riverpod.
How you use the classes prepared in the BusinessLogic layer depends on the design. In the app I'm developing, there are cases where the State class of a StatefulWidget uses a BusinessLogic layer object, and for data to be shared across various parts of the app, we use provider to share a single state object. It's best to use what's appropriate for the time and situation.[9]
Note that in the CLI applications likely envisioned by the Clean Architecture book, input and output are handled by separate components, but in GUI applications like mobile apps, the input component and output component are the same. This is easy to imagine when you consider that both the screen display content and user operations must be written in the build() method of the Widget class.
main.dart
Finally, main.dart is crucial. As written in the Clean Architecture book, Main handles all the "dirty work."
In this app, "dirty work" refers to things like switching the concrete class of the Repository layer depending on the value of --dart-define.
I used the get_it package this time to switch which concrete class is received when calling GetIt.I<RamenRepository>() from the UI based on the --dart-define value.
void prepare() {
final mode = String.fromEnvironment('REPOSITORY_MODE');
if (mode == 'server') {
GetIt.I.registerSingleton<RamenRepository>(
FirestoreRamenRepository(),
);
} else {
GetIt.I.registerSingleton<RamenRepository>(
MockRamenRepository(),
);
}
}
Since I also use the Firebase emulator, settings for using the emulator (such as calling FirebaseFirestore.instance.useFirestoreEmulator('localhost', 8080)) are also done here.
The idea is to gather processes that cannot be neatly encapsulated within each layer (mainly preparation tasks) here. Concepts like layers or dependencies do not exist in main.dart.
Summary
There are still many things I want to write about, such as more detailed designs within each layer, and things you are likely curious about, but since this article has already become long, I'll stop here for now.
I've only just started studying architecture (until recently, I didn't even know what "DDD" stood for), but I want to continue always thinking about the "optimal architecture according to the requirements at hand and the project situation," following the teachings of the Clean Architecture book.
Follow-up
I have written detailed explanations for each of the divided layers in another article. I hope you will continue reading it.
-
Last year's was "Flutter's internal implementation." ↩︎
-
Therefore, please refrain from criticisms such as "This design violates this principle of XX pattern" or "This term differs from the definition in the △△ pattern." I do not intend to conform to such patterns in the first place. ↩︎
-
Such as not being able to use it on real devices or not being able to simulate arbitrary location information. ↩︎
-
I thought so too. ↩︎
-
If doing it properly, I would also consider making it a package. ↩︎
-
Before thinking about architecture, I vaguely thought that "fetching GPS" might be part of the View, but on second thought, fetching GPS is also "fetching data," so I realized it's natural to put it in the Repository layer. ↩︎
-
"Business logic" is often translated as "gyomu (business/operations) logic," but I personally feel this is a bit too literal. If you take the word "gyomu" too literally, it can cause confusion like, "Wait, then does 'gyomu' logic not exist for a hobby app?" If you think about how the word "business" is used in phrases like "It's my business" or "It's not my business," I think it's easier to imagine it as "the procedure for achieving what you want to do." ↩︎
-
Verifying operations by running the app with
flutter runis a hassle, no matter how convenient Flutter's hot reload is. Build errors can occur at runtime, and depending on your PC's specs, the build can take several minutes. Even if it runs, you have to navigate to the screen where the code you want to test is active. Other errors might occur before reaching that screen. Logs are also hard to read because many unrelated things appear. I've deeply realized that code that can be run with Dart alone is much easier to handle than I imagined. ↩︎ -
I will summarize the specific usage of state management and detailed design in a separate article, as it would be too long to cover here. ↩︎
Discussion
神記事ありがとうございます!Logicまわりの実装方法を悩んでいたのでためになりました!
ありがとうございます!書いてよかったです!
大変考えさせられる記事でありがとうございます🙏🏻
Dependency inversion (依存性逆転)でかねがね気になっていた事をここで質問させてください。BusinessLogic 側で Repository のインターフェイスを持つのは良いのですが、もしもっとモジュール構造が複雑になって、例えばログインモジュールを独立モジュールとして切り離したくなったとします(ログイン機能は同じ会社の別アプリでも同様に使えるし、ビジネスロジックとも別)。ログインモジュールでもMemberRepository に対してインターフェイスを定義したくなる事があり得ます。この場合はどうすればいいのでしょうか?
そもそもログインモジュールがなくても、テストのdependency injection (依存性注入) のためにメインアプリでもMemberInterface は欲しくて、ビジネスロジック内のmemberinterfaceを使っても良いのですが、セマンティック的に気持ちが悪い気もします🤔
こちらこそコメントありがとうございます!
こちらですが、原則的にはログインモジュール側にも同じようなインターフェースを作ることになるのかなあ、と思います。とはいえおそらくこの場合、ログインモジュールと BusinessLogic では Repository に求めるインターフェースが変わってくるのではないでしょうか(ログインモジュールでは create / get のみ必要で、 update は不要?など)。そのため、別の依存される側の必要に応じたインターフェースをそれぞれ切るというのは自然なようにも思います。
その上で、もし具体的な処理が重複するようであれば、「インターフェースを実装するクラス(仮に
LoginMemberFacadeBusinessLogicMemberFacadeと名付けます)」と「具体的な取得処理を行なうクラス(FirebaseMemberRepositoryとします)」を別にし、XxxFacadeは単純にFirebaseMemberRepositoryの必要なメソッドを呼び出すだけ、とすればコードの重複も抑えられるのではないかと思います。ただし、ここまでやるのは当然それだけのメリットがある場合のみと思います。書いていただいた通りログインモジュールを別プロジェクトで使い回す、専属チームが開発できる、など、モジュールを分割して関連クラスを増やす手間よりも大きいメリットが得られる場合はここまで頑張ってもよさそうです。
こちらについては、記事に書いた「
main.dartは全ての汚れ役を担当する」の話になるかと思います。DI を含むアプリ全体に影響を及ぼす処理は一切の依存関係のルールを無視してmain.dart(もしくはそこから直接呼び出す別クラス)にまとめて書いてしまっています。ついでにファイル自体もlib/直下に置くことで、「このファイルは特別扱いでアーキテクチャのルール対象外である」ことをアピールしたりしています。返信ありがとうございます。
確かに Facade とかを利用して重複処理を振り分ける事は出来そうですね。ただだいぶ構造としては複雑になる感じですね(このコードベースでFacade はどういう役割をする、とか初見の人の為には色々注意書きが必要になってくる感じ)🤔
実は返信待ちの間に自分でも色々調べながら考えていたのですが、"interface専用のモジュールを用意する"と言う方法もあるなあと思い至りました。図にすると以下の様になります。(汚くてすみません)
MemberRepositoryInterface を BusinessLogic 内に用意するのではなく、専用の RepositoryInterface に用意して、BL(BusinessLogic) もRepository もみんな RepositoryInterface に依存する形にすれば、やはり BL が Repository に依存しないようにできます。
DIP の wikipedia (https://en.wikipedia.org/wiki/Dependency_inversion_principle?wprov=sfti1) でも
"High-level modules should not import anything from low-level modules. Both should depend on abstractions (e.g., interfaces).
高位のモジュールは低位のモジュールからインポートしてはいけない。双方が抽象に依存するべきである。"
と書いてあるので、その方がむしろ理念に近い気もしました。
ただもう完全にマイクロアーキテクチャみたいになって、これはこれでやり過ぎ感もありますね😅
どう思われるでしょうか?
たしかに、自分も今日クリーンアーキテクチャ本を読み返していて、似たようなことを考えたりしていました。今回のアプリで言うと、
Dataを各モジュールから共通利用する専用のモジュールとして切り出したのと発想は似ていますね。とはいえこれも、ログイン以外のインターフェースは「BusinessLogic からしか使わないのに別モジュール(RepositoryInterface)に定義する」ことになるので、そのあたりが「やりすぎ」かどうかは判断ですね、、あとは共通にしちゃってあとあと問題にならないかどうかは
Dataの共通利用と同じことが言えそうです。