At its highest level, clean architecture is the blueprint for a modular system, which strictly follows the design principle called separation of concerns. More specifically, this style of architecture focuses on dividing software into layers, with the intention of simplifying development and maintenance of the system itself. When layers are well-separated, individual pieces can be reused, as well as developed and updated independently.
Declaring Rules of Separated Layers:
In order to take value from these layers of separation, each and every layer will conform to a small set of rules:
- They should be testable.
- They should be independent of a user interface.
- They should be independent of a database.
- They should be independent of interfaces to third-party dependencies.
- They should be respectful of boundaries.
Following Rules of Separated Layers:
Keep in mind that conforming to these architectural rules is not difficult (but does require some overhead). The adherence to these standards will set the system up with the ability to swap out tools as well as plug-and-play pieces with ease. In essence, this approach to engineering software can be summarized as such:
- Coding is relatively easy; the real challenge is managing our code when new features are added.
- Maintaining cleanliness is better than reactively cleaning up problems after they’ve spread throughout the system; code quality should be taken seriously.
- Quickly rewriting problem code is usually not the answer; you may have solved the minor issue, but have ignored the architectural problem of the system.
As many great programmers and storytellers have quipped before us, slow and steady does win the race. This is a mindset that can easily be lost in the chaos of sprints and release weeks; in hindsight, the pressure to get things done quickly over getting things done correctly should strongly be reconsidered.
“The only way to go fast, is to go well.“
— Robert C. Martin
We have been tip-toeing through the cruft of a product that has worn many hats. There are few or no reasons to continue development in this manner—it is not a sustainable way of maintaining an active codebase. But there are a few habits we can start today that will help us reach our goal of migrating to a clean architecture:
- We should take pride in our legacy code.
- We should refactor our code constantly.
- We should maintain a clean codebase.
- We should focus on the product goals.
Taking Pride in our Legacy Code:
The term “legacy code” evokes negative connotations. In part because the process of becoming productive within a legacy codebase can be a nightmare. Legacy code is often characterized as obscure, undocumented, poorly structured, difficult to maintain, and overly-complicated.
Another definition of legacy code is logic that increases the technical debt of the system—this can be code from a decade ago, or code which was merged in a hurry yesterday due to time constraints. The evidence of working with a legacy codebase is highly visible—it can be identified by looking at the myriad of programming styles introduced by every developer who has ever worked on the project.
Note: The implementations used throughout the project aren’t bad, but were guided by a chain of ad-hoc decisions made prior to the project's current state. They may ~work~ but they may not ~work well together~.
For reference, here is a list of a handful of problems we’ve inherited from our legacy code:
- Business logic coupled to enterprise entities, UI, persistence and database access.
- Duplicated code, which may or may not provide identical functionality.
- Lack of design consistency has become a melting pot of programming styles.
- Many features and bug fixes have resulted from a reactionary approach.
- Fractured data models allow for modifications to occur in one version of an entity, but not at a system level.
- Spaghetti code, which makes maintenance, testing, and refactoring difficult for the team.
We should carefully analyze the troubled code in our codebase before making any crass decisions. Understanding why the design of the legacy code is failing our current needs is essential to fixing the root cause. Most problems associated with legacy code stem from the lack of a defined structure, architecture or API design.
“…[C]ode is never perfect… Neither architecture nor clean code insist on perfection, only on honesty and doing the best we can. To err is human; to forgive, divine.”
— Robert C. Martin
Refactor our Code Constantly:
We should not try to solve every problem at once by refactoring the legacy code entirely. Without proper planning and design, this method will ensure a more fragile environment. We should tread lightly and think carefully before making drastic changes to the existing logic. We must try our best to adhere to the attributes of software quality:
We should preserve the functionality our legacy code by choosing to add support before removing a line of code. This is key in any refactoring process, and we should be diligent in keeping the working behavior of the legacy code intact. Consider the following scenarios:
The code works, and you know it works, but you need to reorganize it so you can can understand it in order to extend its functionality.
The code works, and you know it works, but you want to refactor it although you have no allocated work associated with this code.
The code doesn’t work, and its defects are known, however, you must touch the code because your allocated work requires some changes.
The code doesn’t work, and its defects are known, and you want to refactor it although you have no allocated work associated with this code.
In scenario #1, you write tests against the code. If the tests fail, you have a bug in your test. Don’t change the code until you are confident you have enough tests to catch any regressions you may have introduced.
In scenario #2, just leave the code alone. Wait until the logic falls into scenario #1 before making any modifications.
In scenario #3, this is the most likely of scenarios. Try understanding the existing code as best you can, write tests which validate the functionality you’re certain of (based on what’s expected from your user stories), then iteratively fill in the knowledge gaps by reorganization.
At some point, you’ll have a handful of test cases (some correct, some incorrect), but they should all pass. Now, take the time to go through your tests and figure out which ones are actually incorrect—those will uncover the defects in the code that needs to be refactored. Once the bugs are discovered, correct the tests, then proceed to change the code until the test pass again.
In scenario #4, it’s difficult to suppress your urges, but it’s better off for the health of the system to leave unallocated work alone. The chances of you introducing a regression is high, since it’s a low focal point for the business. It seems like it’s helpful, but it’s moving in the wrong direction.
Again, the point is to achieve a common goal. If we’re randomly addressing and refactoring logic, we’ve deviated from the course. We must stay focused on reaching the goal line, but do so with a viable plan of attack.
Maintaining a Clean Codebase:
A clean codebase has many benefits for the company (and team), including:
- Reduced complexity of the solutions required to address business needs.
- Less development time required to add, remove, or modify features.
- Debugging becomes easier for the team.
- Lowered costs for development and quality assurance (QA).
- Better support for an agile environment.
“Clean code always looks like it was written by someone who cares.”
— Michael Feathers
Focusing on the Product Goals:
The architecture is about the structures that support the use cases of the application.
Remember, the architecture is not a framework, nor is it provided by a framework. Frameworks are tools, they are not your application; they should not dictate your application architecture. If external dependencies are abstracted away, or encapsulated, any newcomer to the team should be able to quickly identify the purpose of our system.
That being said, instead of focusing on the fundamentals of Clean Architecture alone, consider Clean Architecture as an example of fundamentals for each iOS application your company has to offer.
“You know you are working on clean code when each routine you read turns out to be pretty much what you expected. You can call it beautiful code when the code also makes it look like the language was made for the problem.”
— Ward Cunningham
Creating these habits of a clean architecture will lead to a system that is:
- Easier to understand.
- Easier to write.
- Easier to change.
In the introduction, we discussed the four rules of separated layers, which are the key to making this architecture successful. We’ll take some time now to explore those rules in depth:
They should be testable.
The business logic layer(s) can be tested without the UI, database, network, or any other external element.
They should be independent of a user interface.
The UI can be easily modified, without changing the rest of the application(s).
They should be independent of a database.
You can swap out Core Data or SQLite, for Realm, Firebase or something else entirely. Your business logic is not bound to the database or persistence layer(s).
They should be independent of interfaces to third-party dependencies.
The architecture does not depend on external libraries. This allows the team to use our own modules as tools, rather than having to adapt your application(s) to their limited constraints and rigid conventions.
They should be respectful of boundaries.
Each layer of software should be created to isolate concepts and/or technologies. They should isolate at the conceptual level (i.e., business logic should know nothing about network requests, transactions, or database) and/or at the technical level (i.e., handling failures, errors, and exceptions raised by the entity management code so they don’t find their way into the business logic).
This layer isolation should be bidirectional (i.e., the business logic should have no knowledge of how the database or UI is implemented, and vice versa).
Typically, data crossing the boundaries of another layer consist of simple data structures. We should prevent ourselves from passing entity objects across boundaries as this violates the rules of dependency. We can prevent exposing our entity objects by using view models and/or data transfer objects (DTOs). Both of these objects serve a similar purpose—encapsulating data for another layer of the application.This means that our view models and/or DTOs needed to cross these boundaries should belong within the inner layer (i.e., provide a view model for displaying UI metadata without exposing the underlying entity, provide a DTO to wrap auxiliary database operations like create, which may lack an identifier, or update, which requires an identifier).
“You will have to separate UI code from algorithmic code, so both will probably be easier to understand and maintain, maintaining algorithms won't break the UI, and vice versa.”
— Matt Rickard
Prevent Code Smells
Furthermore, we should strive to prevent implementation details of types within a layer impacting any other system layers—this particular crossing of layer boundaries is considered a code smell.
“A code smell is a surface indication that usually corresponds to a deeper problem in the system.”
— Martin Fowler
Advice for Maintaining a Clean Architecture
- Depend on abstractions versus concrete types.
- Use design patterns only to simplify logic.
- Avoid tightly coupled code.
- Design for loosely coupled interactions .
- Maintain a clean and consistent code style.
- Design with SOLID principles in mind.
- Favor composition over inheritance.
- Refactoring is your friend.
- Testing is always your responsibility.
“Indeed, the ratio of time spent reading versus writing is well over 10 to 1. We are constantly reading old code as part of the effort to write new code. ...[Therefore,] making it easy to read makes it easier to write.”
— Robert C. Martin
The architecture will be composed of four fundamental layers. At the time of writing, these layers will be identified as:
- Data models and entities live here.
- The entities should be an abstract representation of our essential models of the business (i.e., user, document, annotation, etc.).
- This layer contains critical business rules, but the rules are at the most generalized (platform agnostic) level.
- This layer can be shared between many applications and should not be impacted by anything other than a modification to the critical business rules themselves.
- It should not be impacted by the presentation logic or infrastructures such as the database.
- Business logic and use cases live here.
- The data flow is managed in/out of the entities and depend on their critical business rules to achieve the use case (e.g., user manager, document manager, etc.).
- This layer contains application specific business rules and domain specific constraints.
- It should not impact the entities.
- It should not be impacted by infrastructures (i.e., database, operating system, hardware).
- Presentation design patterns live here.
- The data flow is converted from the application and/or domain specific to the UI.
- This layer converts data from the data layers into use cases and presentation flows (e.g., MVC, MVVM, VIPER, MVP, Rx).
- External dependencies live here.
- Most of this code was written elsewhere (e.g, Core Data, AsyncDisplayKit, Realm, ReactNative).
- This layer is the infrastructure that supports the entire application.
Clean Architecture Model Diagrams:
The clean architecture model can be visualized in multiple ways. I’ve decided to provide two diagrams—the 2D pyramid, and the 3D sphere: