Behind the Pages: The Essence of Architecture According to Uncle Bob.
The architecture is about making decisions. And Clean Architecture is about delaying decisions until they can be made based on facts and not assumptions - Martin Fowler.
The Purpose of Architecture
What is the goal of software architecture? To comprehensively answer this question, we first need to delve into the fundamental definitions of the words "aim" and "architecture". By understanding these terms, we can construct a clear vision of the true purpose of software architecture and its impact on system development.
Goals: Refers to a desired outcome that a person or system intends to achieve. It's a clear intent, a target that guides actions and decisions.
Architecture (in the context of software): It pertains to the overall structure and design of a system, establishing how its parts interact and how the system as a whole relates to external entities.
With these definitions in mind, we move forward to the profound statement made by Robert C. Martin, more commonly known as Uncle Bob, who asserted:
The goal of software architecture is to minimize the human resources required to build and maintain the required system.
Let's break this statement into two main parts and analyze them:
"The aim of software architecture is to minimize the human resources required to build..."
"...and maintain a particular system."
"The aim of software architecture is to minimize the human resources required to build..."
This first part speaks about the initial process of creating and developing software. If we consider architecture not just as a technical drawing but as the strategic backbone of a system, the importance of this phase becomes clear.
Minimizing human resources means optimizing the process, making it more efficient. In this context, "human resources" doesn't only refer to the number of people involved but also to the time, skills, and efforts expended by them. A well-planned architecture can reduce complexity, eliminate redundancies, and provide clear guidelines for developers, allowing them to focus on what truly matters. This means that with a solid foundation, one can build faster, with fewer errors, and with a clear understanding of the end goal.
"...and maintain a particular system."
Maintaining software is an ongoing task and often more challenging than initially building it. As technology evolves, software needs to be updated, adapted, and often refactored to meet new requirements or standards. Good software architecture ensures that these updates and maintenance can be done with minimal effort, reducing the risk of defects and ensuring the system's sustainability in the long run.
This part of the statement recognizes that software isn't a static product; it lives and breathes throughout its lifecycle. Therefore, it's vital that the architecture allows for this evolution, ensuring that the necessary changes can be effectively and efficiently implemented.
Piecing It Together: The Essence of Architecture According to Uncle Bob
In our deep dive into Uncle Bob's statement, we've dissected each fragment of the phrase to grasp the core of what software architecture means and its primary purpose. Now, let's merge these fragments, connect the dots, and see how it all fits into a broader picture, bringing to light the concept of "Clean Architecture" and the criticality of software testability.
The Holistic View of Software Architecture
To genuinely understand the nature of software architecture, it's vital to realize it's not just about crafting a structure for software or a system. It's also about laying a foundation that can accommodate changes, innovations, and evolutions with minimal strain. This is especially crucial in a world where technology is ever-evolving, and user (business) needs shift swiftly. Software isn't a static product; it's a living organism that needs to adapt, grow, and evolve. And it's the software architecture that dictates how flexible, adaptable, and resilient this software can be.
Let's delve into an analogy. Picture a kitchen as a complex system, where the ultimate goal is to deliver delicious, hot dishes timely to customers. To achieve this, the kitchen must be organized and efficient.
Architecture: In the kitchen, there are different workstations, each dedicated to a specific purpose: one for frying, another for salad preparations, and a distinct one for cutting chicken due to contamination risks. Similarly, in software architecture, we have layers or components designated for specific functions, like databases, business logic, or user interface. The placement and relationship between these stations or layers are pivotal. Just as we wouldn't fry where we prepare a salad, we don't mix business logic directly with user presentation in software.
Methodology: In cooking, there's the "mise en place," preparing ingredients before actually starting to cook. This ensures everything is ready and within reach when needed, optimizing the cooking process. In the software realm, this can be likened to the design and planning phase, where everything gets organized, dependencies are understood, and work is set up for the execution phase.
Process: In a kitchen, when an order comes in, there's limited time to prepare all dishes to be served hot and simultaneously. This process is honed to be as efficient as possible, ensuring each dish is made at the right time and in the correct order. In software, this process can be likened to the development cycle, where certain tasks or functions must be completed in a specific sequence to ensure the software operates correctly.
Organization: Just as in a kitchen, where ingredients and tools need to be neatly organized for easy retrieval, in software, code and resources need to be well-organized. If a chef can't quickly find an ingredient or tool, it delays the entire process. Similarly, if the code isn't organized and well-documented, developers waste time searching and understanding it.
What we aim to highlight when discussing software architecture is the significance of a well-structured design, where planning and concerns revolve around building a quality product. Just as in a kitchen where everything has its place and a specific process to ensure dishes are prepared as efficiently and tastefully as possible, in software development, how we organize our systems - from layer division to meticulous planning - directly impacts the quality, maintainability, and efficiency of the final software. And now, we touch upon a topic familiar to many developers, Clean Architecture.
Interconnection with Clean Architecture
When we talk about Clean Architecture, we refer to a design approach that prioritizes organization, separation of responsibilities, and flexibility. Uncle Bob, when discussing Clean Architecture, emphasizes the idea that system details (like frameworks, UI, databases) should depend on business rules, not the other way around. By doing so, he highlights the importance of creating an independent business core, decoupled and, therefore, easier to manage and modify.
What does this have to do with minimizing human resources? Well, when designing systems with Clean Architecture, programmers can focus on the software's core, the business rules, without overly worrying about external details that might change. When you have an architecture that clearly separates system components and clearly defines their interactions, it becomes much easier to make changes, test, add new features, or fix bugs. Fewer hours are spent trying to decipher the code, and more time is dedicated to creating real value.
When an application doesn't follow these clean architecture principles, several problems can arise:
Maintenance Difficulty: Software without a clear architecture resembles a tangle, where everything is interconnected in a confusing manner. This makes it extremely challenging to identify and isolate parts of the code for maintenance. Instead of quickly finding the source of a problem, developers spend hours, or even days, trying to understand how different parts of the code relate.
Compromised Testability: A poorly defined architecture makes the task of testing the software almost impossible. Highly coupled components mean you can't test one part without affecting another, increasing the likelihood of unexpected breaks in unrelated areas.
Limited Scalability: As the application grows, adding new features becomes a daunting task. What should be a simple addition might require changes in multiple areas of the code due to the lack of clear separation between components.
Inconsistent Performance: With the absence of a well-defined architecture, it can be challenging to optimize software performance. Inefficient components might be deeply integrated into the system, making refinement an extensive and challenging task.
Increased Risk: Without clear architecture, the likelihood of introducing new bugs when trying to fix existing problems is significantly higher. A change that seems harmless in one part of the code can trigger a series of failures elsewhere.
Tying it all together, the essence of what we're highlighting is that a well-planned and executed architecture is a safeguard against many of the common problems faced in software development. In contrast, the absence of such a structure not only makes development more challenging and time-consuming but can also compromise the quality and reliability of the final software.
Use Cases
Use Cases are descriptions of the actions a system can perform in response to one or several requests from an external agent, typically a user. They provide a structured way of representing the system's functional requirements, detailing the interactions between the system and its users. A use case describes not only the expected behavior but also the errors and exceptions that might arise during execution. When well-crafted, they can serve as a bridge between non-technical users and developers, allowing both groups to understand and agree on the system's functional requirements.
Continuing, when we look at UseCases within Clean Architecture, we notice that they act as a kind of "contract" that the system must fulfill. They are not concerned with concrete implementation or specific technologies. Instead, they focus on what the system should do and not on how to do it. This distinction is vital for maintaining flexibility and responsiveness in the face of changes.
In a real development environment, technologies, frameworks, and libraries evolve rapidly. If a system is designed with a deep dependency on these tools, it becomes vulnerable to obsolescence and might require extensive rewrites when a technology becomes outdated. However, by centering the core logic around UseCases and decoupling this logic from external tools, we are ensuring that the system remains relevant and functional, regardless of technological changes.
UseCases also promote a user-oriented mindset. As they are based on user interactions with the system, they ensure that the software is developed with the end-user in mind, rather than being based solely on technical considerations. This user-centered approach can result in more intuitive and useful software, enhancing user satisfaction and, ultimately, the project's success.
Another benefit is the enhanced communication between teams. As UseCases are expressed in terms that both non-technical stakeholders and developers can understand, they become a valuable tool for clarifying requirements, setting expectations, and ensuring everyone is aligned regarding the project's final goal. In agile environments, where communication and collaboration are key, UseCases can serve as a focal point in discussions, reviews, and sprint planning.
So, why can UseCases be likened to "orchestrators" in Clean Architecture?
Much like a conductor who doesn't play any instrument but coordinates the entire orchestra to create harmony, UseCases coordinate how different parts of the system work together to fulfill a specific requirement or functionality. They ensure that each component or entity in the system performs its part correctly and in the right sequence. And, just as a conductor has a deep understanding of each instrument and how they fit into the overall composition, UseCases have a clear grasp of the overall flow of the application without delving too deeply into the details of any individual component.
To extend this analogy further: if you consider a complex system as an orchestra, then the entities would be the musicians with their individual instruments, and the UseCases would be the conductors. The entities (or musicians) have their own responsibilities and functionalities (behaviors), but it's the UseCase (or conductor) that ensures they work together cohesively to produce the desired outcome. Without this coordination, you'd have entities acting independently, which could result in chaos or an application that doesn't meet user requirements.
This view of UseCases as orchestrators helps illustrate their importance and their central role in Clean Architecture. They ensure the system operates harmoniously, that user requirements are met, and that business logic remains pure and decoupled from external concerns. This separation of responsibilities, where UseCases coordinate business logic without delving into implementation details, is one of the keys to creating flexible, maintainable, and sustainable systems.
What a Use Case Should Not Do
Within Clean Architecture, it's crucial to understand not just what UseCases should do, but also what they shouldn't concern themselves with. This understanding helps maintain clarity and keeps responsibility correctly distributed within the system. So, when designing UseCases, we should be aware of the following guidelines:
They Shouldn't Contain Specific Business Rules: UseCases describe the general interaction between the user and the system, but they shouldn't be burdened with intricate business rules. These rules belong to the domain core. For instance, while a UseCase might describe the process of a user placing an order, it shouldn't specify applicable discount rules or conditions under which an order can be canceled. This should be handled by the domain layer.
Technology Independence: UseCases shouldn't be written with any reference or dependency on a specific technology, framework, or platform. Their nature should be technology-agnostic. If UseCases start mentioning specific details like databases, servers, or frameworks, it might be a sign of improper coupling.
They Shouldn't Detail User Interfaces: While a UseCase describes user interaction with the system, it shouldn't specify how this interaction is visually presented or how the user interface should be structured. Instead, the focus should be on the flow and general rules of interaction.
Avoid Ambiguities: A UseCase should be clear and straightforward. It shouldn't be written so abstractly that it leaves room for multiple interpretations. While it shouldn't contain technical details or specific business rules, it should still be specific enough to be understood and implemented correctly.
Don't Worry About Performance: While efficiency is vital in software development, the responsibility to ensure performance doesn't fall on the UseCase. They should focus on functionality and interaction flow. Optimization for performance is usually addressed in other aspects of design and implementation.
Avoid References to External Entities: UseCases should focus on the actions the system performs in response to a user. They shouldn't refer to or depend on external systems or services. If integration with external services is needed, this is typically handled in other parts of the architecture, like adapters or interfaces.
By keeping these guidelines in mind when designing UseCases, we can ensure that the software's use cases remain focused on their core purpose, while the rest of the architecture addresses more specific and technical issues. This contributes to a clearer separation of responsibilities!
Exploring One of the Pillars of Clean Architecture: IoC
The Inversion of Control (IoC) Principle is a foundational concept in software design and architecture that aims to invert the traditional dependencies between software components. Instead of high-level components relying on low-level components, the dependency is inverted, and low-level components come to rely on abstractions defined by high-level components.
Let's visualize this better:
Before IoC:
The "High-Level Component" directly depends on the "Low-Level Component". This is represented by the arrow pointing from the high-level component to the low-level component.
After IoC:
The "High-Level Component (Revised)" defines an "Abstraction" (interface or contract).
The "Low-Level Component (Revised)" implements this abstraction.
The dependency between the components is now through the abstraction, not directly. This is represented by the arrow pointing from the revised high-level component to the abstraction and by the implementation arrow from the revised low-level component to the abstraction.
This visual representation clearly illustrates the inversion of dependencies, which is the essence of the Inversion of Control (IoC) Principle.
What's important to highlight and what I want you to understand is how this occurs within clean architecture, see the image below:
The red arrow shows what we shouldn't do, high-level depending on low-level! Now, see the adjustment we can make using interfaces (abstractions):
Note that in the image, the dashed blue arrow uses the word "Impl," which means to implement. We will clearly understand this shortly. But how can we visualize this in code? See the example below:
export class RegisterUserController {
private readonly registerUser: RegisterUser
private readonly sendEmailToUser: SendEmail
constructor (registerUser: RegisterUser, sendEmailToUser: SendEmail) {
this.registerUser = registerUser
this.sendEmailToUser = sendEmailToUser
}
async handle (httpRequest: HttpRequest): Promise<HttpResponse> {
try {
if (!httpRequest.body.name || !httpRequest.body.email) {
const field = !httpRequest.body.name ? 'name' : 'email'
return badRequest(new MissingParamError(field))
}
const userData = { name: httpRequest.body.name, email: httpRequest.body.email }
const registerUserResponse: RegisterUserResponse = await this.registerUser.registerUserOnMailingList(userData)
if (registerUserResponse.isLeft()) {
return badRequest(registerUserResponse.value)
}
const sendEmailResponse: SendEmailResponse = await this.sendEmailToUser.sendEmailToUserWithBonus(userData)
if (sendEmailResponse.isLeft()) {
return serverError(sendEmailResponse.value.message)
}
return ok(userData)
} catch (error) {
return serverError('internal')
}
}
}
A controller implements the Inversion of Control (IoC) principle through dependency injection. I would like to highlight some points:
Dependency Injection: The constructor of the
RegisterUserController
class accepts two parameters:registerUser
andsendEmailToUser
. These are injected when an instance of the class is created. Instead of theRegisterUserController
class creating its own instances ofRegisterUser
andSendEmail
, it receives these dependencies from outside.
constructor (registerUser: RegisterUser, sendEmailToUser: SendEmail) {
this.registerUser = registerUser;
this.sendEmailToUser = sendEmailToUser;
}
Abstraction: The class doesn't know the implementation details of
RegisterUser
andSendEmail
. It only knows that it can callregisterUserOnMailingList
onregisterUser
andsendEmailToUserWithBonus
onsendEmailToUser
. This means that the actual logic behind these methods can be changed without affectingRegisterUserController
.Flexibility: Due to dependency injection, it's easy to replace the implementation of
RegisterUser
orSendEmail
in the future if needed. For instance, if we decide to change how we send emails or register users, we can simply create new classes that implement the same methods and inject them intoRegisterUserController
.
Some essential points I want to highlight. First, it would be great if you understand the differences between the words inject and implement:
Inject:
General definition: We always think of it as introducing (a drug or vaccine) into the body with a syringe. This representation and definition are clearer to all of us, so I will stick to it.
In the programming context: "Inject" refers to the practice of providing a dependency to an object. Instead of an object (class) creating its own dependencies, they are "injected" into it, usually through the constructor, but also through methods or properties. Dependency Injection is a specific technique within the Inversion of Control (IoC) principle that allows for greater modularity and testability in code.
Implement:
General definition: Put into action, accomplish or complete something previously planned or designed.
In the programming context: "Implement" refers to the act of providing a concrete definition for a method, function, or class that was previously defined but not fully specified, i.e., something was established or outlined in general terms, but the specific details or concrete implementation have not yet been provided. In object-oriented languages, when a class provides concrete logic for the methods of an interface, it is said that this class "implements" that interface.
It would be great to return to our example and understand better after these explanations.
Inject: In the provided example, the RegisterUserController class is using Dependency Injection. It is not "implementing" the RegisterUser or SendEmail interfaces. Instead, it "injects" or accepts implementations of these interfaces through its constructor. This allows RegisterUserController to be agnostic about the details of how users are registered or how emails are sent. It just knows that it can call the registerUserOnMailingList and sendEmailToUserWithBonus methods on the provided instances.
Implement: The RegisterUserController class is not directly implementing the RegisterUser or SendEmail interfaces. Instead, it relies on other classes (which will be provided/injected) to implement these interfaces.
Great, now I want you to understand the side of whoever implements RegisterUser
👇🏼:
export class RegisterUserOnMailingList implements RegisterUser
When a class "implements" an interface, it is committing to providing concrete logic for all the methods or functions that the interface defines. It's as if the interface is a contract, and the class that implements this interface is signing that contract, promising that it will follow all its clauses (methods).
In the example, we can see:
The
RegisterUser
interface is the contract. It defines what a "registered user" should be able to do, but it doesn't specify how it should be done.The
RegisterUserOnMailingList
class is the concrete implementation of that contract. It "implements" theRegisterUser
interface, meaning it provides specific logic for theregisterUserOnMailingList
method that theRegisterUser
interface defines.
Moreover, within the class, we have another inversion. The userRepository
dependency is injected into the class through the constructor. Instead of the RegisterUserOnMailingList
class creating or knowing how to create a UserRepository
instance, it simply expects that dependency to be provided (or "injected") when an instance of the class is created.
And the coolest thing is that this means the class is not "tied" to a specific implementation of UserRepository. Instead, any UserRepository implementation can be provided, as long as it meets the expected interface or contract. This is useful for testing (where you can provide a mock or "mock" version of UserRepository) and for flexibility (if you decide to change how UserRepository works in the future).
This is an example of the Inversion of Control (IoC) principle in action. Remember that we are depending on abstractions (interfaces or abstract classes) and not on concrete implementations. The controller is correctly separated from business rules and implementation details.
In Clean Architecture, this is very important: "Depend on abstractions, not concretizations." This phrase encapsulates the essence of the Dependency Inversion Principle, which states: "Instead of high-level components depending on low-level components, the dependency is inverted."
Testability: The Crown Jewel
Lastly, but certainly not least, is testability. The ability to effectively test software is a key indicator of its quality and robustness. However, without a solid architecture, testability can become a daunting task. When software is built with Clean Architecture, each component, module, or function can be tested in isolation. Especially the entities that must keep business rules protected become easier to test. This is because the separation of responsibilities and decoupling means that each part of the system has a single responsibility and interacts predictably with other parts of the system. By maximizing testability, we can identify and fix issues more quickly, prevent regressions, ensure that new features don't introduce bugs, and, most crucially, ensure that the software works as expected in all situations.
And how does all this save human resources?
I think it's important to touch on this point. I see that one of the main advantages of Clean Architecture is the clear separation of responsibilities. Each component, class, or module has a well-defined purpose and interacts with other components predictably. This reduces the coupling between different parts of the system, making it easier for developers to understand how a change in one part of the code will affect the rest of the system. When engineers have this clarity, they can make changes with more confidence, knowing they are not inadvertently introducing new bugs or issues.
Furthermore, a clean design promotes abstraction and dependency inversion. Instead of high-level components relying on low-level implementation details, they depend on abstractions. This means that changes in one component don't necessarily force changes in other components that depend on it. For example, if we decide to change how data is stored or retrieved, we wouldn't need to change the business logic that uses that data. This not only saves time but also reduces the risk of errors.
Testability is another significant advantage. With decoupled components and dependencies on abstractions, we can test each code unit in isolation. This makes it easier to write unit tests that verify a component's behavior under various conditions. When tests are easier to write, they are more likely to be written, leading to more robust, reliable code, as we are ensuring that rules are adhered to and not violated.
And this introduces more readable and understandable code. This means that new team members can familiarize themselves more quickly with existing code. Additionally, it reduces the time spent debugging and fixing errors, as errors are less likely to occur in the first place and, when they do, are easier to locate and fix.
By combining all the pieces we just described, we achieve human resource savings. Complexity obviously exists, but it's manageable. And although complexity can't be controlled, as it depends on the business context, we can predict behaviors with a bit more accuracy and less headache. A flow orchestrated by well-defined and centralized use cases makes it clear to any programmer its purpose. This greatly facilitates understanding the system and how each component of your software behaves.
In summary, Clean Architecture is not just a way to organize code. It's a strategic approach to software design that places longevity and maintainability at the center of all decisions. By adopting this approach, software engineering teams can deliver high-quality products more efficiently and effectively, ensuring that the software continues to deliver value over time.
Conclusion
The journey through the principles and practices of Clean Architecture leads us to a fundamental revelation: the true essence of software architecture is not solely rooted in structures, patterns, or programming languages. Instead, it is deeply anchored in the ongoing pursuit of efficiency and sustainability. As so eloquently put in R. Martin's book, the primary goal of software architecture is to "minimize the human resources required to build and maintain a given system."
In a world where technology is constantly evolving and business requirements are ever-changing, this perspective offers us a compass. It guides us to design systems that not only meet current needs but are also flexible, scalable, and above all, maintainable in the long run. By embracing the principles of Clean Architecture, we are not just optimizing code, segregating responsibilities, creating layers, thinking about abstractions, concerning ourselves with testability or infrastructure; we are optimizing the most valuable resource of all - human time and effort.
Therefore, as we progress on our journey as programmers, we must always remember that our work goes beyond code. It lies in creating systems that stand the test of time, in empowering our teams to face future challenges with confidence, and above all, in the continuous pursuit of solutions that elevate human efficiency to its utmost potential. And it is in this spirit of continuous learning and improvement that Clean Architecture shines as a beacon, guiding us towards a brighter and more sustainable future in the ever-evolving landscape of technology.