Behind the Pages: Frameworks are Details in Clean Architecture!
Frameworks are tools, not ways of life!
If you like the content, please share and like the post! This helps and encourages me to continue bringing content in text form too!😄
Decoupling: The Subtle Art of Creating Flexible Systems
Have you ever stopped to think about the complexity behind a simple light switch in your home? At the press of a button, darkness turns to light. But what really happens behind that wall? There are wires, circuits and connections that, although interconnected, have different functions. If a wire breaks, you don't have to tear down the entire wall to fix it. This is the power of decoupling.
The term "decoupling", according to dictionaries, refers to the action of separating or unlinking something from something else. See the Oxford dictionary definition:
decouple something (from something) to remove the connection…
Okay, let's explore this definition further. In the world of software engineering, it's like ensuring that every wire behind your wall has its own function but can still work in harmony with the others. It's ensuring that if a component fails, the system as a whole continues to function.
Now, imagine for a moment that all the wires behind your wall were tangled up in one giant knot. Any failure in a single wire would require you to untangle the entire assembly to find the problem. It seems inefficient, doesn't it? And that's exactly how a tightly coupled software system operates. Each component is so interconnected with the others that a single failure can cause a total collapse.
So why is decoupling so crucial? Well, just like you don't want to spend hours untangling wires, you also don't want to spend countless hours debugging a complex system. Decoupling allows each component of a system to be developed, tested, and maintained independently. This not only makes the development process more efficient, but also makes the system more robust and resilient.
Let's talk about the tangible benefits of decoupling:
Firstly, there is maintainability. In a decoupled system, if a technology becomes obsolete or a better tool comes along, you can replace it without having to rewrite all the code. It's like replacing a faulty wire without having to touch all the others. This saves time and, more importantly, money.
Next, we have scalability. And no, I'm not just talking about adding more PODs or servers. I'm talking about scalability in the broadest sense of the word. A decoupled system allows developers to work on different components simultaneously without interfering with each other. This speeds up development and allows the team to respond quickly to changes. And in the fast-paced world of technology, the ability to adapt quickly is invaluable.
Finally, we have the issue of exchanging technologies. In a world where new tools and technologies emerge almost daily, the ability to change and adapt is crucial. A decoupled system ensures that you are never "locked in" to a specific technology. If something better comes along, you can make the switch without major headaches. And that's what we're going to talk about now!
The Relationship between Decoupling and Clean Architecture: The Train Analogy
I really like analogies, they make understanding easier. In essence, an analogy highlights similarities in functions or relationships between two distinct scenarios, helping to make a complex or abstract concept more understandable.
When using an analogy, we take something familiar or more easily understood and compare it to something less familiar, with the aim of illustrating and simplifying the understanding of the latter. That's why we're going to do one with a popular means of transport. I would like to highlight that the analogy does not faithfully portray reality, but we use our imagination to draw this parallel with clean architecture.
Imagine a modern train, gliding smoothly along the tracks, crossing landscapes and connecting cities. Each train car has a specific function: passenger cars, freight cars, restaurant cars and so on. They are independent in their function, but all are essential to the overall operation of the train.
Now, think about the couplers that connect these cars. They allow cars to move together as a cohesive unit, but also offer the flexibility to add or remove cars as needed. If a specific carriage needs maintenance, it can be disengaged and replaced without interrupting the train's journey.
This is the essence of decoupling in clean architecture. Just like the carriages on a train, the components of a software system must be independent in function but capable of working together to achieve a common goal. And just like train couplings, the points of interaction between these components must be flexible, allowing components to be added, removed, or replaced without disrupting the system as a whole.
Clean architecture, in this scenario, is like the engineering behind the train design. It ensures that each car is optimized for its specific function, that couplings are robust and flexible, and that the train as a whole operates efficiently and reliably.
But just like a train needs well-maintained tracks to operate efficiently, a software system needs a solid foundation to function. And this is where we enter the next topic of our reading: Frameworks and Application Domain.
Clothes and Hangers (Domain and Frameworks)
Let's change the analogy to something common in our everyday lives. Imagine for a moment that the clothes we wear are like the domain of an application, representing the essence, functionality and purpose of a piece of software. Hangers, on the other hand, are like the frameworks we use to develop software. They provide support, make organization easier, and help present the clothes (or the software) in a more accessible and usable way.
However, just as a piece of clothing is not defined by the hanger it hangs on, software should not be defined or limited by the framework it uses. Like this? I'll explain better, but I need you to pay attention to the explanation.
When I say that "a piece of clothing is not defined by the hanger it hangs on," I am stating that the identity, style, and purpose of a piece of clothing are not determined by the specific hanger it hangs on. For example, a party shirt designed for special occasions is still a party shirt regardless of whether it hangs on a wooden, plastic, or metal hanger. The hanger provides support, but should not alter the essence or purpose of the garment.
Similarly, when designing software, a system is built to fulfill certain goals and functions (its "domain"). While frameworks (the "hangers") provide the tools and structure to develop and operate that system, they should not determine or limit the central purpose or business logic of the system. The system must be designed in such a way that, if necessary, it can be adapted or transferred to another framework (or "another type of hanger") without losing its identity or core functionality.
Now, you may ask yourself, “What exactly differentiates a framework from the application domain?” Well, while frameworks are sets of practices, tools and libraries that facilitate development, the application domain refers to the core business logic and specific functionalities that a system offers. It is the set of rules, processes and operations that define how software should work. It is the essence of what software is and what it sets out to do. In other words, we can say that the true heart and soul of any system is its domain - the business logic and functionalities it offers.
The Hanger Trap: When Clothes Can't Get Off the Hanger!
Now, imagine that instead of a standard, well-designed hanger, we are using a crooked hanger with unexpected hooks and irregular shapes. In some places, the hanger does not support the clothes correctly, causing them to get wrinkled or even fall. In others, the hanger changes the shape of the clothing, stretching or deforming it. The worst thing is when clothes get caught on a hook or broken part of the hanger, damaging the shirts.
This is what happens when software becomes overly dependent on or poorly aligned with a framework. The framework, which should be a tool to facilitate and accelerate development, becomes a restriction. Instead of supporting the software and allowing it to work effectively, it distorts it, making development more complex and less efficient. In some cases, the person may want to use just that hanger and place other clothes on top, or even tightly attach the clothes to the hanger for fear of them falling to the floor.
And that's what happens in today's engineering teams! Excessive dependence on frameworks, whether in the frontend or backend, can lead to several problems. For example, when business rules are mixed with framework logic, it becomes difficult to isolate and test individual components. This can result in fragile code, where small changes can cause unexpected failures. Furthermore, when the domain is tightly coupled to the framework, migrating or upgrading to a new version of the framework can become a nightmare, requiring a substantial rewrite of the code. There can also be a steep learning curve for new developers joining the project, as they need to understand not only the domain but also the quirks of the framework.
And the real danger arises when the clothing, or our system, becomes so intertwined with that problematic hanger that it becomes nearly impossible to adjust or return it to its original shape. The system becomes hostage to the framework, losing its flexibility and adaptability.
To avoid this trap, it's essential to remember that while frameworks are valuable tools, they are just that: tools.
They exist to serve the application domain, not the other way around. Just as an experienced person knows when to change hangers or adjust the shape of clothes to ensure that they are perfect and without deformities, programmers must always be vigilant to ensure that the framework is truly aligned with the needs and objectives of the domain.
We can even quote Uncle Bob from his book Clean Architecture:
Frameworks are tools, not ways of life! - Chapter 21 of the Book.
Ok, it looks like now it's time to go back to that famous image of clean architecture layers and review some things and better understand how this happens, let's go!
Be Careful with the Details!
Let's use a visual resource again:
Let's focus on the blue circles. See that the image caption already tells us what they represent, frameworks and drivers!
In Clean Architecture, Web refers to any interface or driver that interacts directly with the user or with external systems. It could be a browser-based user interface, a RESTful API, a database driver, or any other mechanism that allows communication between the system and the outside world.
Note that in the image it is completely decoupled from the inner layers, mainly with the green circle (Interface Adapters). But how? Let's understand step by step.
Frameworks & Drivers Layer
This is the outermost layer and is responsible for interacting with the outside world. In other words, it is at this layer that input and output operations occur, such as receiving an HTTP request, interacting with a database or sending a response to the client. This client can also be a User Interface or UI.
Adapters Interface Layer
Located immediately within the "Frameworks & Drivers" layer, the "Interface Adapters" layer serves as a bridge between the external world and the application's business rules. Adapters in this layer are responsible for translating data between the outer layer and the inner layers. They ensure that information is presented in a format that the internal layers can understand and process.
Communication between Layers
Incoming Requests: When a request (for example, an HTTP call) arrives at the "Frameworks & Drivers" layer, it is initially processed by this level. This may involve decoding the request, validating data, etc.
Domain Translation: Once the request is processed, it is passed to the “Interface Adapters” layer. Here, adapters convert the request data into objects or structures that are specific to the application domain. For example, an adapter can convert a received JSON to a domain object.
Interacting with the Domain: With the data converted into a domain-understandable format, the adapters then invoke the relevant operations in the internal layers, such as "Use Cases" and therefore "Entities".
Response to the Outside World: After internal processing, the response is again passed to the "Interface Adapters" layer. Here, adapters convert the domain response into a format suitable for the outside world (for example, converting a domain object to JSON). Finally, the response is sent back to the client through the "Frameworks & Drivers" layer.
Let's see this with the help of a diagram:
I tried to create an abstract image of each Layer of a system that has clean architecture as its foundation. It is clear from the image that the Framework & Drivers layer does not have any access to the Entities layer. And that is essential.
But sometimes it's difficult to visualize this mentally, so let's go into details and understand what this layer really means with an example in NestJS.
Frameworks & Drivers Layer in the NestJS Context
This layer is responsible for interacting directly with external technologies and frameworks, as well as input/output (I/O) devices.
In the context of NestJS, the "Frameworks & Drivers" layer can be understood as follows:
Frameworks
NestJS, itself, is a framework. Therefore, all the functionalities and utilities provided by NestJS, such as modules, decorators, guards, interceptors, pipes, etc., are part of this layer.
Drivers
Drivers refer to libraries or packages that allow the application to interact with external systems, such as databases, messaging systems, third-party services, among others. In the context of NestJS:
ORMs and ODMs: NestJS supports a variety of ORMs (Object-Relational Mappers) and ODMs (Object-Document Mappers), such as TypeORM, Sequelize, and Mongoose. These tools allow the application to interact with relational or NoSQL databases.
Cache Adapters: NestJS provides integration with caching systems like Redis.
HTTP Clients: Libraries like Axios can be used to make HTTP calls to external services.
Websockets: NestJS supports Websockets, enabling real-time communications.
The layer is considered a detail because it is the most volatile and subject to change. Frameworks, libraries and drivers can be replaced or updated frequently. Therefore, it is crucial that the internal layers of the architecture (such as Entities, Use Cases and Interface Adapters) do not directly depend on this layer. This ensures that the application's core business logic remains isolated from technical and external details.
So to be clear, in the context of NestJS, the "Frameworks & Drivers" layer is where the application interacts directly with the external world, whether through the NestJS framework itself, ORMs for data access, or other libraries and drivers for communication with external systems and services.
Let's see a practical example of this, starting with a bad example:
// external-api.service.ts (Bad Example)
import { Injectable } from '@nestjs/common';
import axios from 'axios';
@Injectable()
export class ExternalApiService {
async fetchDataAndFilter(): Promise<any> {
const response = await axios.get('https://api.external-source.com/data');
const data = response.data;
🚨 // Business rule directly in the Frameworks & Drivers layer
return data.filter(item => item.isActive === true);
}
}
I would like to highlight the problems with this approach:
Problem:
The ExternalApiService class is mixing two distinct responsibilities:
Communication with an external API: The class makes an HTTP call to fetch data from an external source using Axios. This is a typical responsibility for a service that interacts with external drivers such as APIs.
Business logic: The class is also filtering the data based on a business rule (filter active items). This is a responsibility that should be at the domain or use case layer, not the Frameworks & Drivers layer.
Problem Implications:
Violation of the Single Responsibility Principle (SRP): The class is doing more than one thing. If there is a change in the way data is filtered or the way the external API is accessed, this class will have to be modified, increasing the risk of introducing errors.
Testing Difficulty: Testing this class becomes more challenging as you would have to simulate the API call and also check the filtering logic. Ideally, you would want to test these two responsibilities separately.
Coupling: The business logic is coupled to a specific implementation detail (Axios). If you decide to change the HTTP library or the way data is fetched, you will also have to adjust the business logic.
Difficult to Reuse: If another part of the system needs to fetch data from the same external API without applying filtering, you would have to duplicate the code or modify the existing service.
So we clearly see that clean architecture was not applied in this example. But let's try to do it differently now, with a good example:
// data-item.entity.ts
export class DataItem {
constructor(public id: number, public content: string, public isActive: boolean) {}
// Entity method to check if the item is active
isItemActive(): boolean {
return this.isActive;
}
}
// Define a type for the raw data
interface RawData {
id: number;
content: string;
isActive: boolean;
}
// Define an interface for DataFetcher
interface DataFetcher {
fetchData(): Promise<RawData[]>;
}
// use-case.interface.ts
export interface UseCase<I, O> {
execute(input: I): Promise<O>;
}
export class FetchAndFilterDataUseCase implements UseCase<void, DataItem[]> {
constructor(private readonly dataFetcher: DataFetcher) {}
async execute(): Promise<DataItem[]> {
const rawData = await this.dataFetcher.fetchData();
const dataItems = rawData.map(item => new DataItem(item.id, item.content, item.isActive));
// Using the entity method to filter
return dataItems.filter(item => item.isItemActive());
}
}
Strong points:
Separation of Responsibilities:
The DataItem
entity is responsible for representing a data item and encapsulating the logic related to it (checking if the item is active).
The FetchAndFilterDataUseCase
use case is responsible for fetching and filtering data, separating business logic from data access logic.
Encapsulation:
The logic for checking whether an item is active is encapsulated within the DataItem
entity, ensuring that any logic related to the item is contained in a single place.
Use of Interfaces:
The UseCase interface defines a clear contract for all use cases, ensuring consistency and making it easier to implement new use cases in the future.
The DataFetcher
dependency is injected via constructor, allowing flexibility and decoupling. This makes it easier to replace or mock the implementation for testing.
Strong Typing:
Using types (such as DataItem[]
for the use case output) helps prevent run-time errors and makes the code more readable.
Reusability:
The DataItem
entity and UseCase interface are reusable and can be used elsewhere in the system as needed.
It makes isolating changes easier, if the rule for determining whether an item is active changes in the future (for example, there may be other criteria besides the isActive property), you only need to make the change in one place - within the entity. This isolates the change and reduces the risk of unwanted side effects.
Obviously there are points that we can improve even further in this class, perhaps the names are not the best, the example is short just to make the code more visual, I also didn't add Exceptions to avoid making the code longer. I want you to clearly understand the role of decoupling between layers and total independence from frameworks.
This question may have arisen: Why does UseCase call DataFetcher? Let's talk about it.
DataFetcher interface:
When UseCase calls an interface like DataFetcher
, it's not actually calling a specific implementation that interacts with a database or an external API. Instead, it is declaring a dependency on a contract - the DataFetcher
interface. This means that UseCase needs some way to fetch data, but doesn't care about the details of how that data is actually fetched.
Dependency Inversion Principle:
This follows the Dependency Inversion Principle, which is one of the fundamental principles of Clean Architecture (I commented a little on another article). Instead of UseCase relying directly on a specific implementation (such as one that uses Axios to fetch data), it relies on an abstraction (the DataFetcher interface). The actual implementation using Axios or any other library will be provided from outside, usually by a dependency injection mechanism.
Isolation and Decoupling:
The fact that UseCase only depends on an interface and not a specific implementation ensures that it is completely isolated and decoupled from any dependencies on the framework and Axios. If in the future we decide to switch from Axios to another library or data fetching method, the UseCase will not need to be changed. We would just need to provide a new implementation of the DataFetcher interface.
Layer that must expose the DataFetcher interface:
The DataFetcher
interface must be exposed through the "Interface Adapters" or "Use Cases" layer, depending on the exact structure of the project. The idea is that this interface defines a contract that external implementations (in the "Frameworks & Drivers" layer) need to follow. The actual implementation that interacts with Axios or any other library would be in the "Frameworks & Drivers" layer, keeping the business logic and implementation details clearly separated.
But let's see this with another example in practice again in code, with NestJS.
Be careful not to contaminate the entities!
How could a programmer couple entities to the framework without realizing it? Well, I've seen this occur in several projects, so I think it's appropriate to explain this clearly. Let's start with an example of how a programmer could inadvertently couple entities to the framework in NestJS:
// user.entity.ts (Bad Example)
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column()
age: number;
isAdult(): boolean {
return this.age >= 18;
}
}
In this example, we are using TypeORM, a popular ORM library for NestJS. The User entity is directly coupled to the TypeORM through the @Entity
, @PrimaryGeneratedColumn
and @Column
decorators. This means that our User entity cannot exist or function without the TypeORM. It has direct dependencies on an external framework. That doesn't look good at all! Now let's talk about the dangers:
Dangers:
Limited Flexibility: If we decide to change ORM or use a different approach to data persistence, we will have to significantly rewrite or adjust our entities.
Testability: Testing this entity in isolation becomes more challenging as it is coupled to the behavior of the ORM.
Mixing of Responsibilities: The entity is not only representing domain logic (like isAdult), but also persistence details.
To correct the previous example, we can separate the domain logic from the logic of any persistence details:
// user.entity.ts (Fixed)
export class User {
constructor(public id: number, public name: string, public age: number) {}
isAdult(): boolean {
return this.age >= 18;
}
}
// user.orm-entity.ts
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity('users')
export class UserOrmEntity {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column()
age: number;
}
Now we have a clear separation. The User entity is purely about domain logic and has no knowledge of the ORM. The UserOrmEntity is an ORM-specific representation, separate from domain logic.
But why does this happen?
Convenience: Many ORM frameworks encourage this approach because it is convenient. By combining domain logic and persistence details into a single class, it can feel like we're writing less code and keeping everything in one place.
Lack of Knowledge: Not all programmers are familiar with software design principles that emphasize separation of concerns. They may not be aware of the benefits of keeping domain logic separate from persistence details.
Deadline Pressure: In fast-paced development environments, there may be a tendency to follow the path of least resistance or the pattern suggested by the framework, even if it is not ideal.
What layer is each one in?
Pure Entities (with behaviors): These reside in the domain layer. They represent the core concepts of your system and contain essential business logic. They are completely independent of any external framework or details.
ORM Entities belong to the Frameworks & Drivers layer in Clean Architecture. This layer is the most external and deals directly with frameworks, libraries and technical details, such as interaction with databases, external systems, user interfaces, etc. ORM Entities fit here because they are shaped and defined based on the specifics of the ORM framework you are using. They are responsible for mapping your domain objects to records in a database and vice versa
I would like to highlight again the fundamental role of the Interface Adapters layer:
This layer serves as a bridge between the business logic (internal layers) and the technical details (Frameworks & Drivers layer). Here, you'll find things like controllers, presenters, and adapters that transform data between the ways the domain expects and the ways external frameworks (like an ORM) use.
Ok, but what are the benefits of ensuring this clear separation of layers?
Importance of Separation
Keeping the software domain decoupled from any framework dependencies is crucial for several reasons:
Flexibility: If we decide to change ORM or even database, our domain is not affected. It remains consistent and intact.
Testability: It is easier to write unit tests for pure entities as they have no external dependencies. This results in faster and more reliable tests.
Clarity and Maintainability: By separating concerns, the code becomes clearer. It's easier for developers to understand and modify business logic without worrying about persistence details.
I would like you to focus on these 3 points, as they are the ones that have the greatest impact on a programmer's productivity, especially when dealing with complex and large systems, which communicate with many services with different contexts.
How does everything we've seen further highlight the importance of not being coupled to frameworks? Let's understand.
Program Oriented to the Domain, Not to Frameworks!
Everything I've written so far and talked to you about has a clear and honest purpose: stop just learning about frameworks or libraries! First of all, it is essential that the fundamentals of programming are clear to you! Furthermore, a good programmer doesn't just code all day or read framework documentation all day. He also needs to know how to communicate, understand problems in a short period of time, among other communication skills that are essential in any work environment.
My point is: Being a framework-oriented programmer will only make you see a small part of programming. Software engineering is broad and full of interesting challenges and coding techniques that seek to facilitate and speed up the work of software developers. When you just focus on diving into a framework for years and forget about the rest, it may seem like you are being productive at first, but over time you will notice some difficulties, especially when more complex architectural problems or concepts arise. And believe me, the corporate world demands a lot from programmers, especially with regard to deadlines, goals and especially the expected financial return.
When you only program framework-oriented, you isolate yourself and stick to just one solution, ignoring others that may or may not be useful. And this is a great danger for any programmer! Instead of molding the technology to fit the genuine needs of the problem, you are molding the problem to fit the capabilities and limitations of the technology. This can lead to compromises that do not serve the best interest of the customer or end user, but rather the convenience of the developer or the quirks of the framework. Ultimately, the solution can become a distorted representation of the original problem, missing the essence of what really needed to be solved.
At the moment this may not seem like a problem, after all the customer is happy, received financial returns and has now even asked for new features. But that's the exact moment you fall into the trap. Why? Let's turn to the book Clean Architecture, and Robert C. Martin himself will answer us, note how interesting:
The author (of the framework) wants you to couple to the framework, because once you are coupled in this way, it is very difficult to decouple… In effect, the author is asking you to marry the framework. - Chapter 32, page 293.
The author's concern is valid, it is difficult to decouple your domain when it is very dependent on a technology. And this irritates, frustrates and discourages! Many corporate systems are complex not only because of a lack of clean code, OOP and basic programming principles. But mainly because of this difficulty in leaving one technology and migrating to another with agility and ease.
So what can we do? We can study and plan before making this important decision.
Avoiding Premature Coupling to a Framework
Coupling prematurely to a framework can lead to unwanted restrictions, migration difficulties, and potentially a weak architecture. To avoid these pitfalls, it is essential to approach adopting a framework with a critical and strategic mindset.
Questions to ask when evaluating a framework:
Does the framework meet the specific needs of my project?
How active and receptive is the community around this framework?
Is the framework regularly updated and maintained?
Are there enough resources (documentation, tutorials, courses) available for this framework?
What is the learning curve like for this framework?
Is the framework flexible and configurable to adapt to changing project needs?
How difficult would it be to migrate to another framework in the future if necessary?
Are there successful use cases similar to my project that utilize this framework?
How does the framework handle security and what security measures are integrated?
Is the framework compatible with other tools and technologies I plan to use?
Are there hidden costs associated with using this framework, such as licenses or premium services?
How does the framework compare to other options in terms of features and capabilities?
Does the framework impose any restrictions or limitations that could be an obstacle to the project?
By asking these questions and reflecting on the answers, we can make informed decisions with greater accuracy.
Conclusion
The main focus of the article was to clarify the importance of not becoming a hostage to a framework. We also understand the relationship between some layers and how important it is that they are clearly segregated. But here's an interesting detail. The concept is not a rigid cake recipe that must be followed exactly in all cases. Instead, it is a set of guidelines and principles that serve as a guide for developers.
One of the main benefits of Clean Architecture is its ability to isolate business rules from external influences, such as frameworks and libraries. This allows the core of the system, where the business logic resides, to remain pure and decoupled. But that doesn't mean you can't or shouldn't adapt the architecture to your specific needs.
In practice, each project has its particularities. Depending on the complexity of the business, the team and the technologies involved, it may be necessary to create additional layers or adjust the communication between them. Clean Architecture offers the flexibility to make these adaptations without compromising the integrity of the system.
And why is this important? Because flexible systems are easier to maintain and evolve. When business rules are well defined and isolated, changes to frameworks, databases or even the user interface have a minimal impact on the core of the system. This translates into savings in time and resources. Less rework, fewer bugs and a greater ability to adapt to changes.
Furthermore, a well-structured system facilitates the integration of new team members. With a clear and well-defined architecture, developer onboarding becomes more efficient, reducing the time it takes for them to become productive.
To conclude, it is the role of developers to ensure the quality and integrity of the software. This means not only following best practices, but also understanding when and how to adapt them. It's a powerful tool in that sense, but like any tool, its real value comes from how it's used.
Use it to create robust and flexible systems, but don't be afraid to adapt it as needed. After all, the ultimate goal is to deliver value, and well-thought-out architecture is a means to that end, not an end in itself.