-
Notifications
You must be signed in to change notification settings - Fork 4
Description
Thoughts about Clean Architecture™
The clean architecture is an application architecture popularized by Robert C. Martin. You may be already familiar with it or its name. If not, that's ok. If you know about architectures such as MVVM, you're mostly there. I'll try to organize my thoughts according to the following:
- Quick overview of the architecture and what is promises to deliver.
- Promised wins and my personal opinions on how much each of them is achieved.
💡 Note that I'll be discussing a flavor of it that got popular among mobile developers, iOS and Android alike. That form of the clean architecture may not be 100% in line with what Robert C. Martin originally intended or what he would personally do.
<rant>
Before we begin, I must mention that I have a pet peeve with the architecture's name. I really think it wouldn't have been that popular if it was named otherwise. This is why I stylized it as Clean Architecture™ in the title. I believe intuition is king. The word clean works wonders especially with new developers. It can also cause those who don't adopt it to look down on their code because it's not "clean".
</rant>
Quick overview of the clean architecture
I'll try to tackle this point from the developer's point of view. I think most of us progressed in a similar way with respect to architecture. It was something like this:
- Apple's MVC.
- AppDelegate was a big deal, and contained so much important logic.
- Network calls casually happened in view controllers.
- Singletons were convenient and abundant.
- No tests.
- A better MVC.
- Things started to move out from the app delegate and view controllers to models (the M in MVC)
- Singletons were probably still there, but used in places that made sense.
- Tests started to appear. But since a good deal of interaction logic was still in view controllers, tests didn't cover that area.
- MVVM (or MVP)
- Interaction logic also moved out from view controllers to view models (or presenters).
- View models and presenters made it easier to test interaction logic.
While at this point things started to look much better than before, to some, there was still some imperfections to say the least. To some they saw it as flaws. Namely, what's known as business logic, or what the clean architecture like to call, domain logic. To be fair, depending on the problem at hand, it's hard to draw clear lines where does domain logic begins or end. It maybe be easy to draw the lines for a shopping cart app, but what about a drawing app, or a JSON processor app? In such apps, domain logic can easily get mixed with UI and data logic respectively. Anyway, based on that reasoning, the clean architecture builds upon that notion of boundaries, and breaks down the app to the following layers (typically represented as modules, but we won't discuss this now):
- Domain. This is the centerpiece of the architecture. This layer doesn't not depend on any other layer. It also shouldn't expose any 3rd party dependency in its APIs (and ideally nor system APIs as well). It contains business rules represented as models and use cases.
-
Domain models. A domain model is a plain "POJO" that contains just enough data that delivers to the user a specific value. For example, in a shopping cart app,
CartProductsounds like a valid domain model. ThatCartProductcan look like this:data class CartProduct( val productId: String, val productName: String, val isAvailableInLimitedAmount: Boolean )
In UI, products that are available in limited amounts may be required to be displayed differently, with a different text color for example, to urge the user to checkout quickly (hello dark patterns 👋). The takeaway here is that a domain model shouldn't include something like text color. Instead, it should include what piece of information valuable to the user it's trying to communicate, and presentation (or UI. That's a different debate 😅) should translate that to a text color.
-
Use cases. You can imagine them as functions in suits 🕴️A use case is a function-like object whose a single method (e.g.
executeor whatever syntactic sugar the language offers like Swift'scallAsFunction). Each use case returns (or accepts, or both) a domain model. So, for the domain model given an example above, the use case returning it would be something likeGetCart.
-
- Presentation. Here live view models. They delegate to use cases to pull/push data to/from the UI. Their sole responsibility now is managing temporary state and managing interaction. No "business logic" here anymore.
- UI.
- Data. It depends on domain, and shields it from dealing with the intricacies of managing data, be it remote or local. This layer itself is broken down into other layers:
- Repository (some call it Gateway). Use cases define their interfaces. They cater the needed domain models to use cases. As a result, it's not unusual for business logic to actually end up in the repositories, rendering use case as pass-throughs. Repositories depend on local and remote data sources to properly construct the required domain models.
- Data Sources. A data source can be thought of as a shell over an actual data source like a web API or a data base. They majorly expose a CRUD kind of an API. A repository is the one that knows who to make meaningful domain models out of data models returned by data sources. A data source can be local or remote.
- Local. It can wrap an in-memory structure, a locally persisted file of some popular format (json, xml, ...etc), shared preferences, user defaults, a sqlite database, Core Data, Realm, ..etc. Most of the time it's considered as a cache, but for sure in some cases they are the primary source of truth.
- Remote. It can wrap any kind of network-based data sources like REST, RPC, sockets, ...etc.
I think this was enough of an overview. Let's see what the architecture promises.
Promised wins
There are multiple goals developers usually have in mind when adopting this architecture. In no particular order and no claim of being exhaustive:
- Domain-driven design.
- Development parallelism.
- Testability.
- Common language (screaming architecture)
- Decoupling.
Domain-driven design
As mentioned earlier, the architecture advocates a domain-driven design. Use cases and domain models shouldn't be affected by what kind of UI is being used. The same domain layer can be used in a mobile app and a command line app for example.
While being the hallmark of the architecture, I think this gets broken quite often. The issue is not with developers doing something wrong I believe as it's with how tricky the concept really is. Take a dashboard feature for example. The concept is naturally heavily UI-driven. Having a use case with the word "dashboard" in its name triggers some clean architecture zealots/connoisseurs. The quick remedy is often just a name replacement with a word like "statistics". In my opinion, that's just a hack. Why should a particular set of statistics be grouped like that in a single use case unless it's expected to be displayed in a single hard-to-decompose UI component? To raise the argument in a different way, imagine the user interface is a voice-based assistant like Siri. Usually the user asks a question and expects a brief answer, not a dashboard-sized bucket of information. Does it still make sense to reuse the same use case for such interaction?
Development parallelism
The layer/boundary decomposition works from a task organizing point of view. Since each layer defines their own models and interfaces to interact with other layers, working on implementing multiple layers at once is achievable. For example, once domain models and use cases are defined for a given feature, work on presentation and data can start in parallel.
Testability
Since logic is now spread across multiple layers, the responsibility of each class becomes smaller. Dependencies represented as interfaces (dependency inversion protocol in action) makes it easier to mock them in unit tests.
However, there's something inconvenient about writing unit tests for heavily broken down code like this. At some point, especially in the data layer, tests become brainless and the amount of mocking vastly outweighs the actual logic being tested, to the extent you sometimes feel you're testing the mocks. Mocking code can outgrow actual code that it might exceed it in bugginess. This may not be a problem with the architecture per se, but maybe of how it become popular to view each class as a unit that must have its own tests.
Common language
The template nature of the architecture makes it easier for new developers to expect where they can find a particular type of logic. This is usually referred to as a screaming architecture. My only problem with this is that I believe this backfires in terms of creativity. Shoe-horning any application to fit a ui→viewModel→useCase→repository interaction feels absurd and bureaucratic sometimes or most of the time depending on the problem at hand.
Decoupling
One of the goals of the architecture is to decouple components from each other, and more importantly decouple domain from any framework or library dependency, be it 3rd party or system-provided. This sounds good to everyone most of the time, but it comes with shortcomings. The architecture puts much faith in programming languages. Recall its requirement that domain shouldn't expose a library dependency in its API. This prohibits using something like Rx's Observable types in the use case signature for example. However, developers usually solve this problem by simply breaking that rule for Rx. This is one example from a popular clean architecture sample on Github:
import Foundation
import RxSwift
public protocol PostsUseCase {
func posts() -> Observable<[Post]>
func save(post: Post) -> Observable<Void>
func delete(post: Post) -> Observable<Void>
}Domain models being POJOs suffer a similar problem. Take a library like Realm for example. One of its selling points is what they call zero-copy, or what basically means, the object in hand is just a holder of pointers for fast access to data saved on disk. So, a model like this (taken from their docs):
import io.realm.RealmObject
open class Frog(
var name: String,
var age: Int = 0,
var species: String? = null,
var owner: String? = null
): RealmObject()won't actually store any property of those in memory when fetched, but each property will be a proxy/getter that knows where exactly the needed data in the persisted file and will return it each time it's called. This is cool for some performance-wise. However, having to copy those into plain POJO domain models you simply lose that feature completely. Some may see that's violating the separation of concerns principle and mixing business and data/persistence specificities. That's fair point, but I wouldn't judge this as wrong. I believe the architecture being lacking in supporting such features is better admitted than doubling-down and mocking other approaches. That stance of looking down on to conflicting approaches is sadly abundant among this architecture's adherents.
And to expand more on copying, to construct a domain model, a quite good deal of copying is usually done across layers. First the deserialized object fetched from the API for example is copied to a model that is returned by the remote data source to the repository for further processing. The repository in turn copies that model again to the domain model. Most of the time all these versions look exactly the same, with the exception of the deserialized object having an annotation or is a subclass of a reusable deserializer library object. I haven't seen the architecture applied in a performance-sensitive contexts. I don't know really how this problem can be solved without breaking the architecture's principle.
Conclusion
The architecture works, but I don't like it. However I can live with it since it's became an industry standard, at least in mobile development.
I think the architecture has some non-trivial negative impact and shortcomings as demonstrated, but unfortunately its adherents exacerbate its dislikability by alienating the criticizers. The culture around it really triggers some gut alarms. Maybe because it's marketed at developers how feel insecure about their ad-hoc architectures? The populist nature (if you allow me) of its terminology also works well marketing-wise; starting from claiming the word "clean" to the liberal use of strong words like right, wrong, good, bad, etc. by its proponents.
I would expand more on the negative cultural impact of it in this article, but I tried to avoid ranting as possible. So, maybe twitter is a more fitting place for such noise :)