A Definitive Guide to Front-end Clean Architecture
In the world of front-end development, frameworks have often received more attention than they deserve. Many newcomers dive headfirst into the world of frameworks without first mastering the foundational concepts. This tendency hinders their ability to consider critical aspects beyond frameworks that contribute to the scalability of applications.
This article aims to broaden the horizons of front-end developers and emphasize the importance of being agnostic when it comes to maintenance and scalability of front-end applications. Additionally, it delves into the concept of adapting ideas from other disciplines to fit the specific context of front-end and JavaScript development, avoiding the pitfall of blindly copying and pasting techniques.
Distinguishing Features of This Article
One common mistake in many articles and posts within the front-end community is the attempt to transplant ideas, techniques, and paradigms from other disciplines and languages directly into the front-end or JavaScript context. This often results in solutions that are incompatible or overly complex.
This issue manifests in areas such as design patterns, system designs, project folder structures, and architectures. It even extends to paradigms like functional and object-oriented programming, with attempts to replicate concepts such as inheritance, polymorphism, category theory, and monads.
While these subjects undoubtedly hold value, as senior engineers specialized in front-end development, we should draw inspiration from them and be willing to adapt or challenge them to fit the unique challenges of UI and browser-based problems rather than letting them hinder our progress.
The Original Clean Architecture
Before blindly adopting folder structures, classes, and interfaces from Uncle Bob’s Clean Architecture, it’s crucial to understand the problems it aims to solve and the motivations behind it. The solution should stem from a deep understanding of the problem at hand. Uncle Bob Martin’s video on “The Principles of Clean Architecture” is an excellent starting point for grasping the motivation behind this approach.
In essence, Clean Architecture addresses the issue of separating concerns. Its motivation is to distill all abstractions into a single actionable idea. It draws inspiration from various architectural systems that share the objective of creating systems that are:
- Independent of frameworks
- Testable
- Independent of the user interface (UI)
- Independent of the database
- Independent of any external agency
Given the emphasis on independence from frameworks, it becomes clear that implementing Clean Architecture in frameworks like React, Angular, or Vue, which tightly couple everything within their ecosystems makes no sense.
It’s a common misconception to expect frameworks to solely handle application scalability. Questions like “Which framework is better for scaling front-end applications?” often arise. This doesn’t mean you should abandon frameworks altogether or build everything from scratch. Rather, it’s about critically assessing where libraries and frameworks should operate and defining clear boundaries.
However, it’s essential to acknowledge that no system can achieve 100% decoupling. The goal is to minimize coupling as much as possible to make future changes less painful.
Front-end Clean Architecture
So, how can we achieve decoupling in front-end applications?
No matter how innovative modern framework ideas may be, they all revolve around the concept of web pages — HTML pages with images, CSS, scripts, and other external resources. This fundamental concept should be your focus, as it allows you to break free from initial dependencies imposed by starting with a framework from the beginning. It compels you to contemplate the abstractions common to most front-end applications.
From this perspective, there are at least four primary abstractions:
- Entities
- Services
- Use Cases / Stores
- Components
The flow of interaction follows the Clean Architecture principle: it moves from external layers to internal layers, meaning only external layers should access internal ones, not the other way around.
So let's drill down the layers.
👤 Entities
Entities represent relevant data used for rendering or updating your application. Their role is to act as adapters, accepting JSON data and transforming it into a suitable format for your application.
/**
* @Entity Product
*/
export const Product = ({
id = Number(-1),
title = String('No title'),
description = String('No description'),
rating = Number(-1),
stock = Number(-1),
brand = String('No Brand defined'),
category = String('No category'),
image = String('No url for image'),
...rest
}) => ({
id,
title,
description,
rating,
stock,
brand,
category,
price: {
...ProductPrice(rest)
}
})
/**
* @Entity ProductPrice
*/
export const ProductPrice = ({
price = Number(-1),
discountPercentage = String('No discount percentage')
}) => ({
raw: price,
discount: discountPercentage,
formatted: price.toLocaleString('pt-br',{style: 'currency', currency: 'BRL'})
})
What is that for? Have you ever seen some formatPrice() on every single Component in an application? Not only that, every component that has that formatting has to import it, so you are coupling your component to a helper, and repeating yourself.
Benefits
- To centralize your data, so if something is wrong with your information you already know where to look.
- Avoid formatting repetition in your component as said before in the formatPrice example.
- It serves as documentation, you already know the shape and contents of the relevant data in your application without having to console.log your API calls.
- Composable. Look at the ProductPrice entity. Since all of them have to return an object, you can compose and derive as you will, it's a language feature, for free.
In the code example I'm using the destructuring parameters feature in Javascript along with default parameters in case some property is missing, so it can help on debugs.
Entities can shape a smaller model like a product, but also at the application level, like a full-page JSON structure, just like a BFF.
📞 Services
Services handle external communication, such as making requests to endpoints (GET, POST, UPDATE, DELETE) or tracking analytics. They should always return promises of entities and remain stateless.
2 important rules to follow to be consistent and predictable:
- It should always return Promise<Entity> | Promise<Entity[]>
- It should be stateless, services should not deal with any data persistence.
Services are just functions with parameters that will send data and return something to our application. It can use the Entities to shape the returned JSON structure returned by the API.
import http from './http'
import { User } from '/entities/user'
export const getPersonList = async () => {
// Parallel fetching
const [users, photos, posts] = await Promise.all([
http.get('/users'),
http.get('/photos'),
http.get('/posts')
])
return users.map((user) => {
const photo = photos.find((photo) => photo.id === user.id)
const post = posts.find((post) => post.id === user.id)
return User({ ...item, photo, post })
})
}
In the code above, I'm using HTTP which is just a js module that wraps an implementation such as axios , fetch, or any other xmlHTTPRequestlibraries. That's a good practice so if you have to change the implementation or add some behavior, you can do it by changing just one module of your application.
Benefits
- External calls are in one place, so if there's some error in any call you know where to look.
- It's composable, you can call other services, unwrap the result ( since all of them return promises), and use the result to make another request.
🕋 Store
Stores, popularized by frameworks, deal with client-side data persistence. They store application state and facilitate communication between components. And it's stateful.
A Store can be just a simple implementation with a state using the Publish & Subscribe pattern. It's not only a form to persist data but also keeps communication between components at an application level.
Benefits
- All about persisting data is in one place, so you know where to find if you're having persisting issues.
- It can use services and manage whenever you need to do a fetch or use stored data.
- You can test all use cases of your application without any framework, so you can test the integration as fast as any other unit tests.
- It serves as documentation as a code, containing all the transitions from one state to another, very handy to debug especially when you have more than one component that responds to a specific user interaction.
Here is just an example using Oni.js as a store. You can see all use cases in a single file:
https://medium.com/media/fe08078f50f6533aad8fb55e6b211d73/hrefYou can see the application running and check the tests in more details here.
🧩 Components
I bet you were missing something right?
"What about React? What about Svelte? What about Vue? What about my favorite framework? Are you proposing that we should do it all with vanilla Javascript?!"
No... I'm not suggesting that you should do all with Vanilla Javascript. But you have to agree with me that we didn't need any framework until now. So we are following an important part of the Clean Architecture idea here, which is to push the framework to the borders of our application.
Components are responsible for UI presentation, user interaction, and view updates while maintaining performance.
There are a lot of libraries/frameworks that are capable of doing that, some of them are huge, and other ones are pretty light. But if you think about your application with these decoupled layers, you might wonder if you ever need a framework or if it can be clearer to you where is the best place to use it.
You might need a Client-side Router to respond to a URL and render an application, or you can think about static pages that have little behavior in them so you can wonder what if you use something close to Island Architecture and mount your component in some part of your application.
Well…That's up to you… but now I bet you can see more clearly where are the borders of your client-side application.
Check out the code example above to see components in action.
Folder Structure
While architecture isn’t solely about folder structure, it is an essential element connected to abstractions. In the front-end world, the domain is tightly coupled with the concept of pages. A structured folder approach might resemble:
src/
├── pages/
│ └── home/
│ ├── services
│ ├── entities
│ ├── use-cases
│ └── components
└── shared/
├── utils
├── services
├── entities
├── use-cases
└── components
This structure centralizes abstractions within a particular page, with the option to move shared abstractions to a shared folder when applicable. This approach offers clarity, allows for gradual transitions between specificity and generality, and simplifies the removal of entire pages if needed.
Benefits
- Easy to understand, easy to find things, easy to explain and it is flat.
- We never know from day 1 what is generic and what is specific. So, this way you can always start specific, then move to shared if you see an opportunity to use it in another context.
- If you ever want to remove an entire page you can do it without worrying if you something is being used on another page.
It will scream at you when you see something like:
import something from '../../home/..
on another page.
You can check out in the stackblitz code above an example of this folder structure in practice.
Bônus part — The Design System 💅
Design systems often become unnecessarily coupled with frameworks and libraries. However, design systems should remain agnostic. They should contain core elements like fonts, typography, colors, and atomic components while avoiding direct coupling with specific frameworks. This allows for flexibility and simplicity.
A design system should serve as the CSS foundation for your application, exporting styles as plain .css files for use with any framework. It’s crucial to maintain a clear distinction between your design system and the components used with various frameworks.
An excelent example of a DS documentation is this one:
They set up a single page with examples with just HTML and a link that exposes the CSS CDN to be imported and used. That's it. No framework, no MDX… No integration with doc tools… Simplicity.
In conclusion, this article introduces essential abstractions for front-end clean architecture: entities, services, stores, and components. It emphasizes the need to push frameworks to the borders of your application, freeing yourself from unnecessary dependencies. The suggested folder structure simplifies organization and promotes gradual transition between specific and shared abstractions. Finally, it advocates for a design system that remains agnostic to frameworks, enabling flexibility and simplicity.
Architectural decisions are also intertwined with choices such as Single Page Application (SPA) versus Multi-Page Application (MPA), Static versus Server-Side Rendering (SSR), hybrid combinations, the use of one or multiple repositories, and more. These considerations require meticulous design to ensure scalability and effectiveness.
Numerous tools are at our disposal to enhance our agnosticism in web development, including Parcel, Tailwind, Astro, and more. While it can be initially challenging, as it requires thoughtful craftsmanship and contemplation, the rewards of decoupling, particularly from your preferred framework, become evident rather swiftly.