As the digital landscape evolves, so does the complexity of modern websites. With an increasing demand for better user experience and advanced features, frontend developers face the challenge of creating scalable, maintainable, and efficient architectures.
Among the plethora of articles and resources available on frontend architecture, a significant number are focused on Clean Architecture and its adaptation. In fact, more than 50% of the nearly 70 articles surveyed discuss Clean Architecture in the context of front-end development.
Despite the wealth of information, a glaring issue persists: many of the proposed architectural ideas may never have been implemented in real-world production environments. This raises doubts about their effectiveness and applicability in practical scenarios.
Driven by this concern, I embarked on a six-month journey to implement Clean Architecture on the frontend, allowing me to confront the realities of these ideas and separate the wheat from the chaff.
In this article, I will share my experiences and insights from this journey, offering a comprehensive guide on how to successfully implement Clean Architecture on the frontend.
By shedding light on the challenges, best practices, and real-world solutions, this article aims to provide frontend developers with the tools they need to navigate the ever-evolving world of website development.
In today's rapidly evolving digital ecosystem, developers are spoilt for choice when it comes to frontend frameworks. This abundance of options addresses numerous problems and simplifies the development process.
However, it also leads to endless debates among developers, each claiming that their preferred framework is superior to others. The truth is, in our fast-paced world, new JavaScript libraries emerge daily, and frameworks are introduced almost monthly.
To maintain flexibility and adaptability in such a dynamic environment, we need an architecture that transcends specific frameworks and technologies.
This is particularly crucial for product companies or long-term contracts that involve maintenance, where changing trends and technological advancements must be accommodated.
Being independent of the details, such as frameworks, allows us to focus on the product we are working on and prepare for changes that may arise during its lifecycle.
Fear not; this article aims to provide an answer to this dilemma.
In my quest to implement the Clean Architecture on the frontend, I worked closely with several fullstack and backend developers to ensure that the architecture would be comprehensible and maintainable, even for those with minimal frontend experience.
So, one of the primary requirements of our architecture is its accessibility for backend developers who may not be well-versed in frontend intricacies, as well as fullstack developers who might not have extensive frontend expertise.
By fostering seamless cooperation between frontend and backend teams, the architecture aims to bridge the gap and create a unified development experience.
Unfortunately, to build some awesome stuff, we need to get some background know-how. A clear understanding of the underlying principles will not only facilitate the implementation process but also ensure that the architecture adheres to the best practices in software development.
In this section, we will introduce three key concepts that form the basis of our architectural approach: S.O.L.I.D. principles, Clean Architecture (that actually comes from S.O.L.I.D. principles), and Atomic Design. If you feel strongly about these areas you can skip this section.
S.O.L.I.D. is an acronym representing five design principles that guide developers in creating scalable, maintainable, and modular software:
If you would like to explore this topic in more depth, which I strongly encourage you to do, then no problem. However, for now, what I have presented is enough to go further.
And what does S.O.L.I.D. give us in terms of this article?
Robert C. Martin, based on the S.O.L.I.D. principles and his extensive experience in developing various applications, proposed the concept of Clean Architecture. When discussing this concept, the below diagram is often referenced to visually represent its structure:
So, Clean Architecture is not a new concept; it has been widely used in various programming paradigms, including functional programming and backend development.
Libraries like Lodash and numerous backend frameworks have adopted this architectural approach, which is rooted in the S.O.L.I.D. principles.
Clean Architecture emphasizes the separation of concerns and the creation of independent, testable layers within an application, with the primary goal of making the system easy to understand, maintain, and modify.
The architecture is organized into concentric circles or layers; each having clear boundaries, dependencies, and responsibilities:
Clean Architecture promotes the flow of dependencies from the outer layers to the inner layers, ensuring that the core business logic remains independent of the specific technologies or frameworks used.
This results in a flexible, maintainable, and testable codebase that can easily adapt to changing requirements or technology stacks.
Atomic Design is a methodology that organizes UI components by breaking down interfaces into their most basic elements and then reassembling them into more complex structures. Brad Frost first introduced the concept in 2008 in an article titled "Atomic Design Methodology."
Here is a graphic showing the concept of Atomic Design:
It consists of five distinct levels:
By embracing Atomic Design, developers can reap several benefits, such as modularity, reusability, and a clear structure for UI components, because it requires us to follow the Design System approach, but this is not the topic of this article, so move on.
In order to develop a well-informed perspective on Clean Architecture for frontend development, I embarked on a journey to create an application. Over a period of six months, I gained valuable insights and experience while working on this project.
Consequently, the examples provided throughout this article draw from my hands-on experience with the application. To maintain transparency, all examples are derived from publicly accessible code.
You can explore the final result by visiting the repository at
As mentioned earlier, there are numerous implementations of Clean Architecture available online. However, a few common elements can be identified across these implementations:
By understanding these commonalities, we can appreciate the fundamental structure of Clean Architecture and adapt it to our specific needs.
The core part of our application contains:
Use cases: Use cases describe the business rules for various operations, such as saving, updating, and fetching data. For example, a use case might involve fetching a list of words from Notion or increasing the user's daily streak for learned words.
Essentially, use cases handle the tasks and processes of the application from a business perspective, ensuring that the system functions in accordance with the desired objectives.
Models: Models represent the business entities within the application. These can be defined using TypeScript interfaces, ensuring that they align with the needs and business requirements.
For example, if a use case involves fetching a list of words from Notion, you would need a model to accurately describe the data structure for that list, adhering to the appropriate business rules and constraints.
Operations: At times, defining certain tasks as use cases might not be feasible, or you may want to create reusable functions that can be employed across multiple parts of your domain. For instance, if you need to write a function to search for a Notion word by name, this is where such operations should reside.
Operations are useful for encapsulating domain-specific logic that can be shared and utilized in various contexts within the application.
Repository interfaces: Use cases require a means to access data. In accordance with the Dependency Inversion Principle, the domain layer should not depend on any other layer (while the other layers depend on it); therefore, this layer defines the interfaces for the repositories.
It's important to note that it specifies the interfaces, not the implementation details. The repositories themselves utilize the Repository Pattern which is agnostic to the actual data source and emphasizes the logic for fetching or sending data to and from those sources.
It's crucial to mention that a single repository can implement multiple APIs, and a single Use Case can utilize multiple repositories.
This layer is responsible for data access and can communicate with various sources as needed. Considering that we are developing a frontend application, this layer will primarily serve as a wrapper for browser APIs.
This includes APIs for REST, local storage, IndexedDB, speech synthesis, and more.
It's important to note that if you want to generate OpenAPI types and HTTP clients, the API layer is the ideal place to put them. Within this layer, we have:
API adapter: The API Adapter is a specialized adapter for browser APIs utilized in our application. This component manages REST calls and communication with the app's memory or any other data source you wish to use.
You can even create and implement your own object storage system if desired. By having a dedicated API Adapter, you can maintain a consistent interface for interacting with various data sources, making it easier to update or change them as needed.
The repository layer plays a crucial role in the application's architecture by managing the integration of multiple APIs, mapping API-specific types to domain types, and incorporating operations for transforming data.
If you want to combine the speech synthesis API with local storage, for example, this is the perfect place to do so. This layer contains:
The adapter layer is responsible for orchestrating the interactions between these layers and tying them together. This layer only contains modules responsible for:
The presentation layer is in charge of rendering the user interface (UI) and handling user interactions with the application. It leverages the adapter, domain, and shared layers to create a functional and interactive UI.
The presentation layer employs the Atomic Design methodology to organize its components, resulting in a scalable and maintainable application. However, this layer will not be the primary focus of this article, as it is not the main subject in terms of Clean Architecture implementation.
A designated place is necessary for all common elements, such as centralized utilities, configurations, and shared logic. However, we won't delve too deeply into this layer in this article.
It's worth mentioning just to provide an understanding of how common components are managed and shared throughout the application.
Now, before diving into coding, it's essential to discuss testing. Ensuring the reliability and correctness of your application is vital, and it's crucial to implement a robust testing strategy for each layer of the architecture.
By implementing a comprehensive testing strategy for each layer of the architecture, you can ensure the reliability, correctness, and maintainability of your application while reducing the likelihood of introducing bugs during development.
However, if you're building a small application, integration tests on the adapter layer should suffice.
Alright, now that you have a solid understanding of Clean Architecture and perhaps even have formed your own opinion on it, let's dive a bit deeper and explore some actual code.
Keep in mind that I'll only be presenting a simple example here; however, if you're interested in more detailed examples, feel free to explore my GitHub repository mentioned at the beginning of this article.
In "real life," Clean Architecture truly shines in large, enterprise-level applications, whereas it might be overkill for smaller projects. With that said, let's get to the point.
Using my application as an example, I'll demonstrate how to perform an API call to fetch dictionary suggestions for a given word. This particular API endpoint retrieves a list of meanings and examples by web scraping two websites.
From a business perspective, this endpoint is crucial for the "Find Word" view, which allows users to search for a specific word. Once the user finds the word and logs in, they can add the web-scraped information to their Notion Database.
To begin, we must establish a folder structure that accurately reflects the layers we previously discussed. The structure should resemble the following:
client
├── adapter
├── api
├── domain
├── presentation
├── repository
└── shared
The client directory serves a similar purpose to the "src" folder in many projects. In this specific Next.js project, I've adopted the convention of naming the frontend folder as "client" and the backend folder as "server."
This approach allows for a clear distinction between the two main components of the application.
Choosing the right folder structure for your project is indeed a crucial decision that should be made early in the development process. Different developers have their own preferences and approaches when it comes to organizing resources.
Some may group resources by page names, others may follow the subdirectory naming conventions generated by OpenAPI, and still, others may believe their application is too small to warrant either of those solutions.
The key is to choose a structure that best suits the specific needs and scale of your project while maintaining a clear and maintainable organization of resources.
I’m in the third group, so my structure looks like this:
client
├── adapter
│ ├── local-storage
│ ├── rest
│ ├── speech-synthesis
│ └── supabase
├── api
│ ├── local-storage
│ ├── rest
│ ├── speech-synthesis
│ └── supabase
├── domain
│ ├── local-storage
│ ├── rest
│ ├── speech-synthesis
│ ├── supabase
└── repository
├── local-storage
├── rest
├── speech-synthesis
└── supabase
I've decided to omit the shared and presentation layers in this article, as I believe that those who want to delve deeper can refer to my repository for more information. Now, let's proceed with some code examples to illustrate how Clean Architecture can be applied in a frontend application.
Let's consider our requirements. As a user, I would like to receive a list of suggestions, including their meanings and examples. Therefore, a single dictionary suggestion can be modeled as follows:
interface DictionarySuggestion {
example: string;
meaning: string;
}
Now that we've described a single dictionary suggestion, it's important to mention that sometimes the word obtained through web scraping differs or is corrected compared to what the user typed. To accommodate this, we'll use the corrected version later in our app.
Consequently, we need to define an interface that includes a list of dictionary suggestions and word corrections. The final interface looks like this:
export interface DictionarySuggestions {
suggestions: DictionarySuggestion[];
word: string;
}
We're exporting this interface, which is why the export
keyword is included.
We have our model, and now it's time to put it to use.
import { DictionarySuggestions } from './rest.models';
export interface RestRepository {
getDictionarySuggestions: (word: string) => Promise<DictionarySuggestions | null>;
}
At this point, everything should be clear. It's important to note that we're not discussing the API at all here! The structure of the repository itself is quite simple: just an object with some methods, where each method returns data of a specific type asynchronously.
Please keep in mind that the repository always returns data in the domain model format.
Now, let's define our business rule as a use case. The code looks like this:
export type GetDictionarySuggestionsUseCaseUseCase = UseCaseWithSingleParamAndPromiseResult<
string,
DictionarySuggestions | null
>;
export const getDictionarySuggestionsUseCase = (
restRepository: RestRepository,
): GetDictionarySuggestionsUseCaseUseCase => ({
execute: (word) => restRepository.getDictionarySuggestions(word),
});
The first thing to note is the list of common types used to define use cases. To achieve this, I created a use-cases.types.ts
file in the domain directory:
domain
├── local-storage
├── rest
├── speech-synthesis
├── supabase
└── use-cases.types.ts
This allows me to easily share types for use cases between my subdirectories. The definition of UseCaseWithSingleParamAndPromiseResult
looks like this:
export interface UseCaseWithSingleParamAndPromiseResult<TParam, TResult> {
execute: (param: TParam) => Promise<TResult>;
}
This approach helps maintain consistency and reusability of use case types across the domain layer.
You might be wondering why we need the execute
function. Here, we have a factory that returns the actual use case.
This design choice is due to the fact that we don't want to reference the repository implementation directly in the use case code, nor do we want the repo to be used by an import. This approach allows us to easily apply dependency injection later on.
By using the factory pattern and the execute
function, we can keep the implementation details of the repository separate from the use case code, which improves the modularity and maintainability of the application.
This approach follows the Dependency Inversion Principle, where the domain layer doesn't depend on any other layer, and it enables greater flexibility when it comes to swapping out different repository implementations or modifying the application's architecture.
First, let's define our interface:
export interface RestApi {
getDictionarySuggestions: (word: string) => Promise<AxiosResponse<DictionarySuggestions>>;
}
As you can see, the definition of this function in the interface closely resembles that in the repository. Since the domain type already describes the response, there's no need to recreate the same type.
It's important to note that our API returns raw data, which is why we return the full AxiosResponse<DictionarySuggestions>
. By doing so, we maintain a clear separation between the API and domain layers, allowing for more flexibility in data handling and transformation.
The implementation of this API looks like this:
export const getRestApi = (axiosInstance: AxiosInstance): RestApi => ({
getDictionarySuggestions: async (word: string) => {
const encodedCurrentDate = encodeURIComponent(word);
const response = await axiosInstance.get(
`${RestEndpoints.GET_DICTIONARY_SUGGESTIONS}?word=${encodedCurrentDate}`,
);
return response;
}
});
At this point, things get more interesting. The first important aspect to discuss is the injection of our axiosInstance
. This makes our code very flexible and enables us to build solid tests easily. This is also the place where we handle encoding or parsing of query parameters.
However, you can also perform other actions here, such as trimming the input string. By injecting the axiosInstance
, we maintain a clear separation of concerns and ensure that the API implementation is adaptable to different scenarios or changes in the external services.
As our interface is already defined by the domain, all we have to do is implement our repository. So, the final implementation looks like this:
export const getRestRepository = (restApi: RestApi): RestRepository => ({
getDictionarySuggestions: async (word) => {
const { data } = await restApi.getDictionarySuggestions(word);
if (!data?.suggestions?.length) {
return null;
}
return formatDictionarySuggestions(data);
}
});
An important aspect to mention is related to APIs. Our getRestRepository
allows us to pass a previously defined restApi
. This is advantageous because, as mentioned earlier, it allows for easier testing. We can briefly examine formatDictionarySuggestions
:
export const formatDictionarySuggestions = ({
suggestions,
word,
}: DictionarySuggestions): DictionarySuggestions => {
const cleanedWord = cleanUpString(word);
const cleanedSuggestions = suggestions.map((_suggestion) => {
const cleanedMeaning = cleanUpString(_suggestion.meaning);
const cleanedExample = cleanUpString(_suggestion.example);
return {
meaning: cleanedMeaning,
example: cleanedExample,
};
});
return {
word: cleanedWord,
suggestions: cleanedSuggestions,
};
};
This operation takes our domain DictionarySuggestions
model as an argument and performs a string cleanup, which means removing unnecessary spaces, line breaks, tabs, and capitalizations. It's pretty straightforward, with no hidden complexities.
An important thing to note is that at this point is that you don't need to worry about your API implementation. As a reminder, the repository always returns data in the domain model! It cannot be otherwise because doing so would break the principle of dependency inversion.
And for now, our domain layer doesn't depend on anything defined outside it.
At this point, everything should be implemented and ready for dependency injection. Here's the final implementation of the rest module:
import { getRestRepository } from '@repository/rest/rest.repository';
import { getRestApi } from '@api/rest/rest.api';
import { getDictionarySuggestionsUseCase } from '@domain/rest/rest.use-cases';
import { axiosInstance } from '@shared/axios.instance';
const restApi = getRestApi(axiosInstance);
const restRepository = getRestRepository(restApi);
export const restModule = {
getDictionarySuggestions: getDictionarySuggestionsUseCase(restRepository).execute,
};
That's right! We've gone through the process of implementing Clean Architecture principles without being tied to a specific framework. This approach ensures that our code is adaptable, making it easy to switch frameworks or libraries if needed.
When it comes to testing, checking out the repository is a great way to understand how tests are implemented and organized in this architecture.
With a solid foundation in Clean Architecture, you can write comprehensive tests that cover various scenarios, making your application more robust and reliable.
As demonstrated, following Clean Architecture principles and separating concerns leads to a maintainable, scalable, and testable application structure.
This approach ultimately makes it easier to add new features, refactor code, and work with a team on a project, ensuring the long-term success of your application.
In the example application, React is used for the presentation layer. In the adapter directory, there's an additional file called hooks.ts
that handles the interaction with the rest module. The contents of this file are as follows:
import { restModule } from '@adapter/rest/rest.module';
import { useAxios } from '@shared/hooks';
export const useDictionarySuggestions = () => {
const { data, error, isLoading, mutate } = useAxios(restModule.getDictionarySuggestions);
return {
dictionarySuggestions: data,
getDictionarySuggestions: mutate,
dictionarySuggestionsError: error,
isDictionarySuggestionsLoading: isLoading,
};
};
This implementation makes it incredibly easy to work with the presentation layer. By using the useDictionarySuggestions
hook, the presentation layer doesn't have to worry about managing data mappings or other responsibilities that are unrelated to its primary function.
This separation of concerns helps maintain the principles of Clean Architecture, leading to more manageable and maintainable code.
First and foremost, I encourage you to dive into the code from the provided GitHub repo and explore its structure.
What else can you do? The sky's the limit! It all depends on your specific design needs. For instance, you might consider implementing the data layer by incorporating a data store (Redux, MobX, or even something custom - it doesn't matter).
Alternatively, you could experiment with different communication methods between the layers, like using RxJS to handle asynchronous communication with the backend, which could involve polling, push notifications, or sockets (essentially, being prepared for any data source).
In essence, feel free to explore and experiment as you please, as long as you maintain the layered architecture and adhere to the principle of inverse dependency. Always ensure the domain is at the core of your design.
By doing so, you'll create a flexible and maintainable application structure that can adapt to various scenarios and requirements.
In this article, we've delved into the concept of Clean Architecture within the context of a language-learning application built using React.
We highlighted the importance of maintaining a layered architecture and adhering to the principle of inverse dependency, as well as the benefits of separating concerns.
A significant advantage of Clean Architecture is its ability to let you focus on the engineering aspect of your application without being tied to a specific framework. This flexibility allows you to adapt your application to various scenarios and requirements.
However, there are some drawbacks to this approach. In some cases, following a strict architectural pattern might lead to increased boilerplate code or added complexity in the project structure.
Additionally, relying less on documentation can be both a pro and a con - while it allows for more freedom and creativity, it may also result in confusion or miscommunication among team members.
Despite these potential challenges, implementing Clean Architecture can be highly beneficial, particularly in the context of React, where there isn't a universally accepted architectural pattern.
It's essential to consider your architecture at the beginning of a project rather than addressing it after years of struggling.
To explore a real-life example of Clean Architecture in action, feel free to check out my repository at
Wow, this is probably the longest article I've ever written. It feels incredible!