App Architecture: Faster, Better… Simpler
As a Staff Mobile Engineer at Mews, I’ve come to realize the title “Staff Engineer” is quite vague, with responsibilities varying from company to company and even between departments. In my role, I don’t belong to any specific team; instead, I assist other engineers and teams with code reviews, architectural decisions, and ad-hoc issues. I also consult for other companies and startups, reviewing their code and mentoring their teams. This exposure to different approaches in app architecture has given me insights into what works and what doesn’t. Today, I’d like to discuss how to make your architecture simpler, more reliable, and more manageable.
This is the text version of my talk at Fluttercon Berlin 2024. You can find the video here.
What is (and isn’t) app architecture
Before we define what app architecture is, let’s clarify what it’s not. When I’m introduced to a new app and ask the team to describe their current architecture, I often hear responses like:
- “It’s BLoC.”
- “We use Riverpod.”
- “It’s an MVVM architecture.”
The issue is that these are not descriptions of an app’s architecture. BLoC and Riverpod are state management tools, and MVVM is an architectural pattern for the presentation layer. While important, they don’t define your app’s overall architecture.
Sometimes, the response is, “We have a SOLID-based architecture.” Again, this doesn’t tell me much. SOLID is a set of principles that can be useful (though not always, as we’ll discuss later), but it’s not a representation of your app’s architecture.
Another popular answer is, “We use Clean Architecture.” You might think this is spot-on—it even has “architecture” in the name. However, there are several problems with this:
- Lack of specificity: Even if applied correctly, Clean Architecture doesn’t say much about your specific app. It’s a set of principles to build your app’s architecture, resulting in countless possible architectures.
- Misapplication: I often see implementations of Clean Architecture that don’t follow its core principles.
- Unsuitability for Flutter apps: The strict approach to Clean Architecture doesn’t work well with Flutter apps. I discussed this in detail in my article Clean Architecture vs. Pragmatic Architecture. In short, Clean Architecture speaks to entire software systems where a mobile app is just a part. It focuses too much on separate deployments and is overly defensive about business logic—which most mobile apps lack.
The goal of software architecture
Before defining app architecture, let’s discuss its goal. I appreciate Robert Martin’s definition from his book Clean Architecture:
“The goal of software architecture is to minimize the human resources required to build and maintain the required system.”
The key word here is “maintain“. We seldom create apps that aren’t intended for future maintenance. Maintenance—which includes fixing bugs and adding new features—is the most significant part of an app’s lifecycle. Therefore, making this phase as straightforward as possible is crucial.
Defining app architecture
So, what is app architecture? According to Wikipedia:
“Software architecture is the set of structures needed to reason about a software system and the discipline of creating such structures and systems. Each structure comprises software elements, relations among them, and properties of both elements and relations.”
Many tutorials focus on components and their properties but often overlook the crucial aspect of relationships between components.
Understanding components in your app
Let’s delve into components, which I’ll use interchangeably with features and modules. Simon Brown defines a component as:
“A grouping of related functionality behind a nice clean interface, which resides inside an execution environment like an application.”
In the context of app components:
- They are not screens, although a screen can be part of a component.
- They are not necessarily user flows. While something like a “User Registration Flow” can be a component, components shouldn’t be divided exclusively by user flows.
- They are not just global responsibilities. For example, “auth” doesn’t have to be a separate component encompassing all authentication-related elements.
Instead, think of components from the loose coupling, high cohesion perspective:
- High cohesion: Everything inside the module should have closely related functionality.
- Loose coupling: Connections to the outside should be minimal.
- Encapsulation: Other components shouldn’t concern themselves with a module’s implementation details; they should treat it as a black box.
An example component structure
What can be inside a component? As long as you adhere to encapsulation and loose coupling/high cohesion, the specifics are flexible. Here’s a structure I often use:
- Screens: The top-level layer and the first candidate to be used by other components. If you can use a whole screen from another module, that’s excellent—you don’t need to care about its implementation.
- Widgets: Screens consist of widgets. Contrary to some practices, widgets aren’t just dumb view components (more on this shortly).
- Services layer: Some call this the Business Logic layer, but I prefer not to, as most apps lack true business logic. Instead, this layer handles application logic—abstracted from the UI but specific to the app. State management patterns like BLoC typically belong here.
- Data layer: This includes implementations of repositories and API clients. Keep them as simple as possible; you usually mock these in tests, so you don’t want complex logic here.
- Models layer: Only models shared between different parts of the module or between different modules go here (though I usually discourage cross-module sharing). If a model is only used by a repository or service, place it close to the corresponding class in the same file. These aren’t rich domain models; they’re typically just data holders.
Widgets are not just dumb views
Some developers split widgets into ModelViews and Views, but I advise against this. I find MVVM in Flutter to be an anti-pattern, as I discussed in this video.
Do we need all these layers?
Definitely not. Avoid adding abstractions if they don’t encapsulate any logic. If your service merely proxies calls to the repository, you don’t need it. Let the widget directly call the repository. Layers can communicate with any lower layers, not just their immediate child layer. In practice, you’ll often have just a couple of layers: widgets and services or widgets and data. However, dependency direction should always be one-way.
You can set up the DCM static analyzer to enforce this direction, preventing, for example, importing a file from the data folder into a file in the widgets folder.
About dependency inversion
You might notice that there’s no dependency inversion in this structure—the services layer calls the data layer directly. This might seem terrible, but let’s discuss why we need this principle: it allows us to depend on interfaces, not implementations. In Dart, every class defines an implicit interface. With implicit interfaces, you can have another implementation of the class for testing without needing additional abstractions that can complicate thinking.
This doesn’t mean you should never create separate interfaces. They can be useful, especially when combined with the Interface Segregation Principle. However, avoid creating a separate class with only one implementation in the main code, particularly when that implementation is just the interface name with an Impl suffix.
Where does BLoC belong?
Does BLoC always belong to the services layer? It depends. BLoC, as a library, is an implementation of the Finite State Machine pattern. If you use it to handle application logic and state, it sits in the services folder. If it’s managing a complex UI state, it’s part of the widgets layer. Asking whether BLoC is UI or services is like asking whether a Singleton is part of the data layer or if a Factory should only be used in views.
Re-examining SOLID principles
Since we’ve touched on the Dependency Inversion Principle, let’s discuss the other SOLID principles.
Single Responsibility Principle (SRP)
This is perhaps the most misinterpreted principle, partly due to its confusing name. Many believe it’s about a class or function doing only one thing. In Clean Architecture, Robert Martin clarifies that SRP is about one reason to change, meaning responsibility to one actor. It’s more about grouping related functionality together, aligning with the loose coupling and high cohesion principle. In this sense, it’s invaluable for structuring your app’s modules.
Open/Closed Principle (OCP)
This principle states that software entities should be open for extension but closed for modification. On a module level, it suggests having a stable and extensible API. However, not all components need to be stable—some change frequently due to product requirements or dependencies.
Liskov Substitution Principle (LSP)
LSP is crucial when dealing with class hierarchies, but inheritance can be complex and fragile. In general, it’s often better to avoid deep inheritance structures.
Interface Segregation Principle (ISP)
This is the one principle where I have few reservations. It’s beneficial because if your code doesn’t depend on methods it doesn’t use, the system becomes easier to understand, test, and maintain. However, it can lead to more boilerplate code. For example, any class with more than one client and more than one method (like a Repository or ApiClient) might violate this principle. Again, be reasonable in its application.
Dependency Inversion Principle (DIP)
As previously discussed, I advise against applying DIP rigidly within component layers. It can be highly useful when dealing with the stability and flexibility of components, but it shouldn’t be applied everywhere just because it’s possible.
General thoughts on SOLID
SOLID principles are somewhat vague initially. Authors, including Robert Martin, present them as universal, promoting their use at class, module, and architecture levels. While they often make sense, they can sometimes feel artificial. I suggest being skeptical—know the principles but avoid following them blindly. They are not laws. You can build bad software that follows these principles and good software that violates them (in some places, not everywhere).
Violating any of these principles isn’t inherently good or bad—it’s about why you violate them or apply them. If you can justify your decisions, adherence to the principles becomes less critical.
The misunderstood DRY principle
I chose to discuss SOLID and DRY because they frequently appear in job postings, almost becoming an idée fixe.
The DRY (Don’t Repeat Yourself) principle, along with SRP, is one of the most misinterpreted. The confusion often arises from its name. It’s not about code repetition per se; it’s about maintaining a single source of truth for your logic.
People often describe WET (Write Everything Twice) as the opposite of DRY, which highlights the misunderstanding. The number of repetitions isn’t the issue; it’s the repetition of logic or knowledge that should be avoided. If you’re repeatedly creating a widget with the same parameters, you don’t necessarily need to introduce a new wrapping widget.
Focusing on component relations
Now that we’ve discussed components and their internal relationships, let’s switch to a more intriguing and less discussed topic: relations between components.
When I ask about your app architecture, this is usually what I want to hear—how your components interact. This is the main characteristic that determines whether your app is a “big ball of mud” or a well-structured, maintainable piece of software. It also allows someone to understand what your app does without delving into the code.
Understanding through component graphs
Consider these two images:
The left one describes a general architecture or principles that can be used to create an app architecture—it doesn’t tell me anything specific about your app. The right one depicts the architecture of a specific app. It’s much more informative when we want to understand what your app is about and how it’s built.
By examining the components graph, we can deduce that the app involves movies, favorites, search, and profiles—it appears to be a movie search app that allows users to create a list of favorite movies.
Assessing component dependencies
Looking at the “content” module, we see it doesn’t depend on any other component:
This means ease of testing: Since it doesn’t depend on anything else, we can test it in isolation, regardless of the state of the rest of the app.
At the same time, many other components depend on it, so changes here can cascade through the app:
This makes it a “stable” component—you don’t want its API to change frequently.
Conversely, the “dashboard” component has no other components depending on it:
Such a component is considered “irresponsible.” It’s safe to change because you won’t break other components, making it very unstable.
However, since it depends on every other component (directly or transitively), testing it could be challenging without proper abstractions:
Most likely, this component serves as an entry point to the rest of the app, making it a good candidate for end-to-end or integration tests.
Without even looking at the code, we’ve gained insights into:
- The app’s functionality.
- Which parts are easier to test.
- Which components might be hard to change.
Avoiding cycles in dependencies
One crucial aspect to monitor is the absence of cycles in your dependencies—your dependencies should form a Directed Acyclic Graph (DAG). Cycles turn modules within the cycle into one big module. For example, if the “profile” module depends on “auth,” “auth” depends on “dashboard,” and “dashboard” depends back on “profile,” you effectively have one large module. You can’t use or test these modules separately, and changes in one will likely necessitate changes in the others.
Ensuring loose coupling and high cohesion
Check for loose coupling and high cohesion within your modules. If you notice few internal connections but many external ones, you might be violating this principle. To fix this, consider rearranging files within modules. Remember, modules aren’t necessarily grouped by use case or screens—they’re about keeping highly cohesive functionality together.
Tools for analyzing component relations
Using Lakos for dependency graphs
For a total graph of all file relationships, I use the Lakos library. Even for a small project, the graph can look intimidating:
The good news is you don’t need to check it manually; Lakos can report if there are cycles in the graph.
In most cases, you’re only interested in the outcome: isAcyclic: true. You can set up Lakos to return an error if cycles are detected, integrating it into your CI/CD pipeline. It works quickly because it doesn’t perform static analysis of Dart code —it simply parses import statements.
Dart code metrics for component analysis
For more complex tasks like grouping files into modules, the Dart Code Metrics tool with its new analyze-structure command is helpful. Note that this command is in an early preview, so some functionality might be missing or subject to change.
This tool allows you to build a components graph, grouping dependencies within components:
While it doesn’t automatically check for cycles yet, the components graph is simpler, making manual checks feasible.
You can also run a script on the Graphviz file it produces to detect cycles.
Remember, it’s important to check for cycles in both the full graph and the components graph, as one might be acyclic while the other isn’t.
Benefits of this approach
Flexibility
With more or less independent modules, it’s easier to split work among developers and make independent changes.
Transparency
Having an overview of stable and unstable components allows you to plan refactorings and understand which components are easier to test and which are risky to change.
Maintainability
As the second law of thermodynamics suggests, entropy (or chaos) in any isolated system tends to increase over time. Without constant maintenance, disorder grows. However, with this approach, chaos can be localized—no matter how messy one module gets, it shouldn’t significantly affect others.
Simplicity
Avoid unnecessary abstractions. Don’t focus on multiple layers just for the sake of it. Keep things simple and concentrate on the relationships between your modules. A litmus test to check if an abstraction is needed: ask yourself why you added it. If your answer starts with “If in the future…,” stop—you probably don’t need it. Saying “it’s cleaner” isn’t a sufficient reason.
Scalability
With a decomposed system, you can strategically target weak points and evolve the system. If your system grows further, you can easily switch to a modular architecture—groups of components can be extracted as separate modules.
Is this Clean Architecture?
When rehearsing my presentation, someone commented that this approach resembles Clean Architecture. So, is it an alternative or just a rebranded idea?
Well, both yes and no. Throughout this discussion, I’ve referred to “Clean Architecture” as it’s commonly implemented in tutorials and real-life projects, where developers take sample implementations from original articles and directly apply them to Flutter apps. I advocate for implementing the ideas of Clean Architecture—clear boundaries, well-defined dependencies, and module isolation—while remaining skeptical and pragmatic. In that sense, this approach is closer to the original intent of Clean Architecture than blindly replicating UseCase, Presenter, Repository, and Datasource classes across projects.
Key differences
- Simplification: Streamlining the architecture to focus on what’s essential.
- Inversion of dependency inversion: Sometimes, just having direct dependencies is more practical.
- Component structuring: Organizing by components defined through cohesion.
- Small components: Keeping components as small and manageable as possible.
- Focus on relationships: Concentrating on how components interact rather than adhering strictly to predefined layers.
Conclusion
Simplifying your app architecture doesn’t mean sacrificing robustness or scalability. By focusing on component relationships, embracing pragmatism over dogmatism, and utilizing tools to monitor dependencies, you can create an architecture that’s both manageable and adaptable.
If you have any questions or thoughts, feel free to reach out. Let’s continue the conversation on building better, more maintainable apps.