DEV Community 👩‍💻👨‍💻

Cover image for Clean Architecture on Frontend
Alex Bespoyasov
Alex Bespoyasov

Posted on • Updated on • Originally published at bespoyasov.me

Clean Architecture on Frontend

Not very long ago I gave a talk about the clean architecture on frontend. In this post I'm outlining that talk and expanding it a bit.

I'll put links here to all sorts of useful stuff that will come in handy as you read:

What's the Plan

First, we'll talk about what the clean architecture is in general and get familiar with such concepts as domain, use case and application layers. Then we'll discuss how this applies to the frontend and whether it's worth it at all.

Next, we'll design the frontend for a cookie store following the rules of the clean architecture. And finally, we'll implement one of the use cases from scratch to see if it's usable.

The store will use React as its UI framework just to show that this approach can be used with it as well. (And because the talk this post is based on was addressed to developers who already use React 😄) Although React is not necessary, you can use everything I show in this post with other UI libs or frameworks too.

There will be a little TypeScript in the code, but only to show how to use types and interfaces to describe entities. Everything we'll look at today can be used without TypeScript, except the code won't be as expressive.

We will hardly talk about OOP today, so this post should not cause any severe allergies. We will only mention OOP once at the end, but it won't stop us from designing an application.

Also, we'll skip tests today because they are not the main topic of this post. I will keep in mind testability though and mention how to improve it along the way.

And finally, this post is mostly about you grasping the concept of clean architecture. The examples in the post are simplified, so it isn't literal instruction on how to write the code. Understand the idea and think about how you can apply these principles in your projects.

At the end of the post, you can find a list of methodologies that are related to clean architecture and used on the frontend more widely. So you can find a best fit depending on the size of your project.

And now, let's dig in!

Architecture and Design

Designing is fundamentally about taking things apart... in such a way that they can be put back together. ...Separating things into things that can be composed that's what design is.

— Rich Hickey. Design Composition and Performance

System design, says the quote in the epigraph, is the system separation so that it can be reassembled later. And most importantly, be assembled easily, without too much work.

I agree. But I consider another goal of an architecture to be the extensibility of the system. The demands on the program are constantly changing. We want the program to be easy to update and modify to meet new requirements. The clean architecture can help achieve this goal.

The Clean Architecture

The clean architecture is a way of separating responsibilities and parts of functionality according to their proximity to the application domain.

By the domain, we mean the part of the real world that we model with a program. This is the data transformations that reflect transformations in the real world. For example, if we updated the name of a product, replacing the old name with the new one is a domain transformation.

The Clean Architecture is often referred to as a three-layer architecture, because the functionality in it is divided into layers. The original post about The Clean Architecture provides a diagram with the layers highlighted:

Layer diagram: the domain is in the center, the application layer around it, and the adapters layer on the outside

Image credits: cleancoder.com.

Domain Layer

At the center is the domain layer. It is the entities and data that describe the subject area of the application, as well as the code to transform that data. The domain is the core that distinguishes one application from another.

You can think of the domain as something that won't change if we move from React to Angular, or if we change some use case. In the case of the store, these are products, orders, users, cart, and functions to update their data.

The data structure of domain entities and the essence of their transformations are independent from the outer world. External events trigger domain transformations, but do not determine how they will occur.

The function of adding an item to cart doesn't care how exactly the item was added: by the user himself through the “Buy” button or automatically with a promo code. It will in both cases accept the item and return an updated cart with the added item.

Application Layer

Around the domain is the application layer. This layer describes use cases, i.e. user scenarios. They are responsible for what happens after some event occurs.

For example, the “Add to cart” scenario is a use case. It describes the actions that are should be taken after the button is clicked. It's the kind of “orchestrator” that says:

  • go to the server, send a request;
  • now perform this a domain transformation;
  • now redraw the UI using the response data.

Also, in the application layer theree are ports—the specifications of how our application wants the outside world to communicate with it. Usually a port is an interface, a behavior contract.

Ports serve as a “buffer zone” between our application's wishes and the reality. Input Ports tell us how the application wants to be contacted by the outside world. Output Ports say how the application is going to communicate with the outside world to make it ready.

We will look at ports in more detail later.

Adapters Layer

The outermost layer contains the adapters to external services. Adapters are needed to turn incompatible APIs of external services into those compatible with our application's wishes.

Adapters are a great way to lower the coupling between our code and the code of third-party services. Low coupling reduces needs to change one module when others are changed.

Adapters are often divided into:

  • driving—which send signals to our application;
  • driven—which receive the signals from our application.

The user interacts most often with driving adapters. For example, the UI framework's handling of a button click is the work of a driving adapter. It works with the browser API (basically a third-party service) and converts the event into a signal that our application can understand.

Driven adapters interact with the infrastructure. In the frontend, most of the infrastructure is the backend server, but sometimes we may interact with some other services directly, such as a search engine.

Note that the farther we are from the center, the more “service-oriented” the code functionality is, the farther it is from the domain knowledge of our application. This will be important later on, when we decide which layer any module should belong to.

Dependency Rule

The three-layer architecture has a dependency rule: only the outer layers can depend on the inner layers. This means that:

  • the domain must be independent;
  • the application layer can depend on the domain;
  • the outer layers can depend on anything.

Only the outer layers can depend on the inner layers

Image credits: herbertograca.com.

Sometimes this rule can be violated, although it is better not to abuse it. For example, it is sometimes convenient to use some “library-like” code in a domain, even though there should be no dependencies. We'll look at an example of this when we get to the source code.

An uncontrolled direction of dependencies can lead to complicated and confusing code. For example, breaking a dependency rule can lead to:

  • Cyclic dependencies, where module A depends on B, B depends on C, and C depends on A.
  • Poor testability, where you have to simulate the whole system to test a small part.
  • Too high coupling, and as a consequence, brittle interaction between modules.

Advantages of Clean Architecture

Now let's talk about what this separation of code gives us. It has several advantages.

Separate domain

All the main application functionality is isolated and collected in one place—in the domain.

Functionality in the domain is independent, which means that it is easier to test. The less dependencies the module has, the less infrastructure is needed for testing, the less mocks and stubs are needed.

A stand-alone domain is also easier to test against business expectations. This helps new developers to grasp on what the application should do. In addition, a stand-alone domain helps look for errors and inaccuracies in the “translation” from the business language to the programming language more quickly.

Independent Use Cases

Application scenarios, use cases are described separately. They dictate what third-party services we will need. We adapt the outside world to our needs, not the other way around. This gives us more freedom to choose third-party services. For example, we can quickly change the payment system if the current one starts charging too much.

The use case code also becomes flat, testable and extensible. We will see this in an example later on.

Replaceable Third-Party Services

External services become replaceable because of adapters. As long as we don't change the interface, it doesn't matter which external service implements the interface.

This way, we create a barrier to change propagation: changes in someone else's code do not directly affect our own. Adapters also limit the propagation of bugs in the application runtime.

Costs of Clean Architecture

Architecture is first of all a tool. Like any tool, the clean architecture has its costs besides its benefits.

Takes Time

The main cost is time. It is required not only for design, but also for implementation, because it is always easier to call a third-party service directly than to write adapters.

It is also difficult to think through the interaction of all the modules of the system in advance, because we may not know all the requirements and constraints beforehand. When designing, we need to keep in mind how the system can change, and leave room for expansion.

Sometimes Overly Verbose

In general, a canonical implementation of the clean architecture is not always convenient, and sometimes even harmful. If the project is small, a full implementation will be an overkill that will increase the entry threshold for newcomers.

You may need to make design tradeoffs to stay within budget or deadline. I'll show you by example exactly what I mean by such tradeoffs.

Can Make Onboarding More Difficult

Full implementation of the clean architecture can make the onboarding more difficult because any tool requires the knowledge on how to use it.

If you over-engineer at the beginning of a project, it will be harder to onboard new developers later. You have to keep this in mind and keep your code simple.

Can Increase the Amount of Code

A problem specific for frontend is that the clean architecture can increase the amount of code in the final bundle. The more code we give to the browser, the more it has to download, parse and interpret.

The amount of code will have to be watched and decisions will have to be made about where to cut corners:

  • maybe describe the use case a little simpler;
  • maybe access the domain functionality directly from the adapter, bypassing the use case;
  • maybe we'll have to tweak the code splitting, etc.

How to reduce costs

You can reduce the amount of time and code by cutting corners and sacrificing the “cleanliness” of the architecture. I'm generally not a fan of radical approaches: if it's more pragmatic (e.g. benefits will be higher than potential costs) to break a rule, I'll break it.

So, you can balk at some aspects of the clean architecture for a while with no problem at all. The minimum required amount of resources, however, that are definitely worth devoting to are two things.

Extract Domain

The extracted domain helps to understand what we are designing in general and how it should work. The extracted domain makes it easier for new developers to understand the application, its entities and relationships between them.

Even if we skip the other layers, it still will be easier to work and refactor with the extracted domain which is not spread over the code base. Other layers can be added as needed.

Obey Dependency Rule

The second rule not to be discarded is the rule of dependencies, or rather their direction. External services must adapt to our need and never otherwise.

If you feel that you are "fine-tuning" your code so that it can call the search API, something is wrong. Better write an adapter before the problem spreads.

Designing the application

Now that we've talked about theory, we can get down to practice. Let's design the architecture of a cookie store.

The store will sell different kinds of cookies, which may have different ingredients. Users will choose cookies and order them, and pay for the orders in a third-party payment service.

There will be a showcase of cookies that we can buy on the home page. We will only be able to buy cookies if we are authenticated. The login button will take us to a login page where we can log in.

Store main page

(Don't mind how it looks, I'm no web-designer 😄)

After a successful login we will be able to put some cookies in the cart.

Cart with cookies

When we've put the cookies in the cart, we can place the order. After payment, we get a new order in the list and a cleared shopping cart.

We'll implement the checkout use case. You can find the rest use cases in the source code.

First we'll define what kind of entities, use cases and functionality in the broad sense we'll have at all. Then let's decide which layer they should belong to.

Designing Domain

The most important thing in an application is the domain. It is where the main entities of the application and their data transformations are. I suggest that you start with the domain in order to accurately represent the domain knowledge of the app in your code.

The store domain may include:

  • the data types of each entity: user, cookie, cart, and order;
  • the factories for creating each entity, or classes if you write in OOP;
  • and transformation functions for that data.

The transformation functions in the domain should depend only on the rules of the domain and nothing else. Such functions would be, for example:

  • a function for calculating the total cost;
  • user's taste preference detection
  • determining whether an item is in the shopping cart, etc.

Domain entities diagram

Designing Application Layer

The application layer contains the use cases. A use case always has an actor, an action, and a result.

In the store, we can distinguish:

  • A product purchase scenario;
  • payment, calling third-party payment systems;
  • interaction with products and orders: updating, browsing;
  • access to pages depending on roles.

Use cases are usually described in terms of the subject area. For example, the “checkout” scenario actually consists of several steps:

  • retrieve items from the shopping cart and create a new order;
  • pay for the order;
  • notify the user if the payment fails;
  • clear the cart and show the order.

The use case function will be the code that describes this scenario.

Also, in the application layer there are ports—interfaces for communicating with the outside world.

Use cases and ports diagram

Designing Adapters Layer

In the adapters layer, we declare adapters to external services. Adapters make incompatible APIs of third-party services compatible to our system.

On the frontend, adapters are usually the UI framework and the API server request module. In our case we will use:

  • UI-framework;
  • API request module;
  • Adapter for local storage;
  • Adapters and converters of API answers to the application layer.

Adapters diagram with splitting by driving and driven adapters

Note that the more functionality is “service-like”, the farther away it is from the center of the diagram.

Using MVC Analogy

Sometimes it's hard to know which layer some data belongs to. A small (and incomplete!) analogy with MVC may help here:

  • models are usually domain entities,
  • controllers are domain transformations and application layer,
  • view is driving adapters.

The concepts are different in detail but quite similar, and this analogy can be used to define domain and application code.

Into Details: Domain

Once we've determined what entities we'll need, we can start defining how they behave.

I'll show you the code structure in project right away. For clarity, I divide the code into folders-layers.

src/
|_domain/
  |_user.ts
  |_product.ts
  |_order.ts
  |_cart.ts
|_application/
  |_addToCart.ts
  |_authenticate.ts
  |_orderProducts.ts
  |_ports.ts
|_services/
  |_authAdapter.ts
  |_notificationAdapter.ts
  |_paymentAdapter.ts
  |_storageAdapter.ts
  |_api.ts
  |_store.tsx
|_lib/
|_ui/
Enter fullscreen mode Exit fullscreen mode

The domain is in the domain/ directory, the application layer is in application/, and the adapters are in services/. We will discuss alternatives to this code structure at the end.

Creating Domain Entities

We will have 4 modules in the domain:

  • product;
  • user;
  • order;
  • shopping cart.

The main actor is the user. We will store data about the user in the storage during the session. We want to type this data, so we will create a domain user type.

The user type will contain ID, name, mail and lists of preferences and allergies.

// domain/user.ts

export type UserName = string;
export type User = {
  id: UniqueId;
  name: UserName;
  email: Email;
  preferences: Ingredient[];
  allergies: Ingredient[];
};
Enter fullscreen mode Exit fullscreen mode

Users will put cookies in the cart. Let's add types for the cart and the product. The item will contain ID, name, price in pennies and list of ingredients.

// domain/product.ts

export type ProductTitle = string;
export type Product = {
  id: UniqueId;
  title: ProductTitle;
  price: PriceCents;
  toppings: Ingredient[];
};
Enter fullscreen mode Exit fullscreen mode

In the shopping cart, we will only keep a list of the products that the user has put in it:

// domain/cart.ts

import { Product } from "./product";

export type Cart = {
  products: Product[];
};
Enter fullscreen mode Exit fullscreen mode

After a successful payment an new order is created. Let's add an order entity type.

The order type will contain the user ID, the list of ordered products, the date and time of creation, the status and the total price for the entire order.

// domain/order.ts

export type OrderStatus = "new" | "delivery" | "completed";

export type Order = {
  user: UniqueId;
  cart: Cart;
  created: DateTimeString;
  status: OrderStatus;
  total: PriceCents;
};
Enter fullscreen mode Exit fullscreen mode

Checking Relationship Between Entities

The benefit of designing entity types in such a way is that we can already check whether their relationship diagram corresponds to reality:

Entity Relationship Diagram

We can see and check:

  • if the main actor is really a user,
  • if there is enough information in the order,
  • if some entity needs to be extended,
  • if there will be problems with extensibility in the future.

Also, already at this stage, types will help highlight errors with the compatibility of entities with each other and the direction of signals between them.

If everything meets our expectations, we can start designing domain transformations.

Creating Data Transformations

All sorts of things will happen to the data whose types we've just designed. We will be adding items to the cart, clearing it, updating items and user names, and so on. We will create separate functions for all these transformations.

For example, to determine if a user is allergic to some ingredient or preference, we can write functions hasAllergy and hasPreference:

// domain/user.ts

export function hasAllergy(user: User, ingredient: Ingredient): boolean {
  return user.allergies.includes(ingredient);
}

export function hasPreference(user: User, ingredient: Ingredient): boolean {
  return user.preferences.includes(ingredient);
}
Enter fullscreen mode Exit fullscreen mode

The functions addProduct and contains are used to add items to cart and check if an item is in cart:

// domain/cart.ts

export function addProduct(cart: Cart, product: Product): Cart {
  return { ...cart, products: [...cart.products, product] };
}

export function contains(cart: Cart, product: Product): boolean {
  return cart.products.some(({ id }) => id === product.id);
}
Enter fullscreen mode Exit fullscreen mode

We also need to calculate the total price of the list of products—for this we will write the function totalPrice. If required, we can add to this function to account for various conditions, such as promo codes or seasonal discounts.

// domain/product.ts

export function totalPrice(products: Product[]): PriceCents {
  return products.reduce((total, { price }) => total + price, 0);
}
Enter fullscreen mode Exit fullscreen mode

To allow users to create orders, we will add the function createOrder. It will return a new order associated with a specified user and their cart.

// domain/order.ts

export function createOrder(user: User, cart: Cart): Order {
  return {
    user: user.id,
    cart,
    created: new Date().toISOString(),
    status: "new",
    total: totalPrice(products),
  };
}
Enter fullscreen mode Exit fullscreen mode

Note that in every function we build the API so that we can comfortably transform the data. We take arguments and give the result as we want.

At the design stage, there are no external constraints yet. This allows us to reflect data transformations as close to the subject domain as possible. And the closer the transformations are to reality, the easier it will be to check their work.

Detailed design: Shared Kernel

You may have noticed some of the types we used when describing domain types. For example, Email, UniqueId or DateTimeString. These are type-alias:

// shared-kernel.d.ts

type Email = string;
type UniqueId = string;
type DateTimeString = string;
type PriceCents = number;
Enter fullscreen mode Exit fullscreen mode

I usually use type-alias to get rid of primitive obsession.

I use DateTimeString instead of just string, to make it clearer what kind of string is used. The closer the type is to the subject area, the easier it will be to deal with errors when they occur.

The specified types are in the file shared-kernel.d.ts. Shared kernel is the code and the data, dependency on which doesn't increase coupling between modules. More about this concept you can find in "DDD, Hexagonal, Onion, Clean, CQRS, ...How I put it all together".

In practice, the shared kernel can be explained like this. We use TypeScript, we use its standard type library, but we don't consider them as dependencies. This is because the modules that use them may not know anything about each other and remain decoupled.

Not all code can be classified as shared kernel. The main and most important limitation is that such code must be compatible with any part of the system. If a part of the application is written in TypeScript and another part in another language, the shared kernel may contain only code that can be used in both parts. For example, entity specifications in JSON format are fine, TypeScript helpers are not.

In our case, the entire application is written in TypeScript, so type-alias over built-in types can also be classified as shared kernel. Such globally available types do not increase coupling between modules and can be used in any part of the application.

Into Detail: Application Layer

Now that we have the domain figured out, we can move on to the application layer. This layer contains use cases.

In the code we describe the technical details of scenarios. A use case is a description of what should happen to the data after adding an item to cart or proceeding to checkout.

Use cases involve interaction with the outer world, and thus, the use of external services. Interactions with the outside world are side-effects. We know that it is easier to work with and debug functions and systems without side-effects. And most of our domain functions are already written as pure functions.

To combine clean transformations and interaction with the impure world, we can use the application layer as an impure context.

Impure Context For Pure Transformations

An impure context for pure transformations is a code organization in which:

  • we first perform a side-effect to get some data;
  • then we do a pure transformation on that data;
  • and then do a side-effect again to store or pass the result.

In the “Put item in cart” use case, this would look like:

  • first, the handler would retrieve the cart state from the store;
  • then it would call the cart update function, passing the item to be added;
  • and then it would save the updated cart in the storage.

The whole process is a “sandwich”: side-effect, pure function, side-effect. The main logic is reflected in data transformation, and all communication with the world is isolated in an imperative shell.

Functional architecture: side-effect, pure function, side-effect

Impure context is sometimes called a functional core in an imperative shell. Mark Seemann wrote about this in his blog. This is the approach we will use when writing use case functions.

Designing Use Case

We will select and design the checkout use case. It is the most representative one because it is asynchronous and interacts with a lot of third-party services. The rest of the scenarios and the code of the whole application you can find on GitHub.

Let's think about what we want to achieve in this use case. The user has a cart with cookies, when the user clicks the checkout button:

  • we want to create a new order;
  • pay for it in a third-party payment system;
  • if the payment failed, notify the user about it;
  • if it passed, save the order on the server;
  • add the order to the local data store to show on the screen.

In terms of API and function signature, we want to pass the user and the cart as arguments, and have the function do everything else by itself.

type OrderProducts = (user: User, cart: Cart) => Promise<void>;
Enter fullscreen mode Exit fullscreen mode

Ideally, of course, the use case should not take two separate arguments, but a command that will encapsulate all the input data inside itself. But we don't want to bloat the amount of code, so we'll leave it that way.

Writing Application Layer Ports

Let's take a closer look at the steps of the use case: the order creation itself is a domain function. Everything else is external services that we want to use.

It's important to remember that it's the external services that have to adapt to our needs and not otherwise. So, in the application layer, we'll describe not only the use case itself, but also the interfaces to these external services—the ports.

The ports should be, first of all, convenient for our application. If the API of external services isn't compatible with our needs, we'll write an adapter.

Let's think of the services we will need:

  • a payment system;
  • a service to notify users about events and errors;
  • a service to save data to the local storage.

Service we're going to need

Note that we are now talking about the interfaces of these services, not their implementation. At this stage, it is important for us to describe the required behavior, because this is the behavior we will rely on in the application layer when describing the scenario.

How exactly this behavior will be implemented is not important yet. This allows us to postpone the decision about which external services to use until the very last moment—this makes the code minimally coupled. We'll deal with the implementation later.

Also note that we split the interfaces by features. Everything payment-related is in one module, storage-related in another. This way it will be easier to ensure that the functionality of different third party services are not mixed up.

Payment System Interface

The cookie store is a sample application, so the payment system will be very simple. It will have a tryPay method, which will accept the amount of money that needs to be paid, and in response will send a confirmation that everything is OK.

// application/ports.ts

export interface PaymentService {
  tryPay(amount: PriceCents): Promise<boolean>;
}
Enter fullscreen mode Exit fullscreen mode

We won't handle errors, because error handling is a topic for a whole separate big post 😃

Yes, usually the payment is done on the server, but this is a sample-example, let's do everything on the client. We could easily communicate with our API instead of directly with the payment system. This change, by the way, would only affect this use case, the rest of the code would remain untouched.

Notification Service Interface

If something goes wrong, we have to tell the user about it.

The user can be notified in different ways. We can use the UI, we can send letters, we can user's phone to vibrate (please, don't).

In general, the notification service would also be better to be abstract, so that now we don't have to think about the implementation.

Let it take a message and somehow notify the user:

// application/ports.ts

export interface NotificationService {
  notify(message: string): void;
}
Enter fullscreen mode Exit fullscreen mode

Local Storage Interface

We will save the new order in a local repository.

This storage can be anything: Redux, MobX, whatever-floats-your-boat-js. The repository can be divided into micro-stores for different entities or be one big repository for all the application data. It's not important right now either, because these are implementation details.

I like to divide the storage interfaces into separate ones for each entity. A separate interface for the user data store, a separate one for the shopping cart, a separate one for the order store:

// application/ports.ts

export interface OrdersStorageService {
  orders: Order[];
  updateOrders(orders: Order[]): void;
}
Enter fullscreen mode Exit fullscreen mode

In the example here I make only the order store interface, all the rest you can see in source code.

Use Case Function

Let's see if we can build the use case using the created interfaces and the existing domain functionality. As we described earlier, the script will consist of the following steps:

  • verify the data;
  • create an order;
  • pay for the order;
  • notify about problems;
  • save the result.

All steps of the custom script in the diagram

First, let's declare the stubs of the services we're going to use. TypeScript will swear that we haven't implemented the interfaces in the appropriate variables, but for now it doesn't matter.

// application/orderProducts.ts

const payment: PaymentService = {};
const notifier: NotificationService = {};
const orderStorage: OrdersStorageService = {};
Enter fullscreen mode Exit fullscreen mode

We can now use these stubs as if they were real services. We can access their fields, call their methods. This comes in handy when “translating” a use case from the business language to software language.

Now, create a function called orderProducts. Inside, the first thing we do is create a new order:

// application/orderProducts.ts
//...

async function orderProducts(user: User, cart: Cart) {
  const order = createOrder(user, cart);
}
Enter fullscreen mode Exit fullscreen mode

Here we take advantage of the fact that the interface is a contract for behavior. This means that in the future the stubs will actually perform the actions we now expect:

// application/orderProducts.ts
//...

async function orderProducts(user: User, cart: Cart) {
  const order = createOrder(user, cart);

  // Try to pay for the order;
  // Notify the user if something is wrong:
  const paid = await payment.tryPay(order.total);
  if (!paid) return notifier.notify("Oops! 🤷");

  // Save the result and clear the cart:
  const { orders } = orderStorage;
  orderStorage.updateOrders([...orders, order]);
  cartStorage.emptyCart();
}
Enter fullscreen mode Exit fullscreen mode

Note that the use case does not call third-party services directly. It relies on the behavior described in the interfaces, so as long as the interface remains the same, we don't care which module implements it and how. This makes the modules replaceable.

Into Detail: Adapters Layer

We have “translated” the use case into TypeScript. Now we have to check if the reality matches our needs.

Usually it doesn't. So we tweak the outside world to suit our needs with adapters.

Binding UI and Usecase

The first adapter is a UI framework. It connects the native browser API with the application. In the case of the order creation, it is the “Checkout” button and the click handler, which will launch the use case function.

// ui/components/Buy.tsx

export function Buy() {
  // Get access to the use case in the component:
  const { orderProducts } = useOrderProducts();

  async function handleSubmit(e: React.FormEvent) {
    setLoading(true);
    e.preventDefault();

    // Call the use case function:
    await orderProducts(user!, cart);
    setLoading(false);
  }

  return (
    <section>
      <h2>Checkout</h2>
      <form onSubmit={handleSubmit}>{/* ... */}</form>
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

Let's provide the use case through a hook. We'll get all the services inside, and as a result, we'll return the use case function itself from the hook.

// application/orderProducts.ts

export function useOrderProducts() {
  const notifier = useNotifier();
  const payment = usePayment();
  const orderStorage = useOrdersStorage();

  async function orderProducts(user: User, cookies: Cookie[]) {
    // …
  }

  return { orderProducts };
}
Enter fullscreen mode Exit fullscreen mode

We use hooks as a “crooked dependency injection”. First we use the hooks useNotifier, usePayment, useOrdersStorage to get the service instances, and then we use closure of the useOrderProducts function to make them available inside the orderProducts function.

It's important to note that the use case function is still separated from the rest of the code, which is important for testing. We'll pull it out completely and make it even more testable at the end of the article, when we do the review and refactoring.

Payment Service Implementation

The use case uses the PaymentService interface. Let's implement it.

For payment, we will use the fake API stub. Again, we are not forced to write the whole service now, we can write it later, the main thing—to implement the specified behavior:

// services/paymentAdapter.ts

import { fakeApi } from "./api";
import { PaymentService } from "../application/ports";

export function usePayment(): PaymentService {
  return {
    tryPay(amount: PriceCents) {
      return fakeApi(true);
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

The fakeApi function is a timeout which is triggered after 450ms, simulating a delayed response from the server. It returns what we pass to it as an argument.

// services/api.ts

export function fakeApi<TResponse>(response: TResponse): Promise<TResponse> {
  return new Promise((res) => setTimeout(() => res(response), 450));
}
Enter fullscreen mode Exit fullscreen mode

We explicitly type the return value of usePayment. This way TypeScript will check that the function actually returns an object that contains all the methods declared in the interface.

Notification Service Implementation

Let the notifications be a simple alert. Since the code is decoupled, it won't be a problem to rewrite this service later.

// services/notificationAdapter.ts

import { NotificationService } from "../application/ports";

export function useNotifier(): NotificationService {
  return {
    notify: (message: string) => window.alert(message),
  };
}
Enter fullscreen mode Exit fullscreen mode

Local Storage Implementation

Let the local storage be React.Context and hooks. We create a new context, pass the value to provider, export the provider and access the store via hooks.

// store.tsx

const StoreContext = React.createContext<any>({});
export const useStore = () => useContext(StoreContext);

export const Provider: React.FC = ({ children }) => {
  // ...Other entities...
  const [orders, setOrders] = useState([]);

  const value = {
    // ...
    orders,
    updateOrders: setOrders,
  };

  return (
    <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
  );
};
Enter fullscreen mode Exit fullscreen mode

We will write a hook for for each feature. This way we won't break ISP, and the stores, at least in terms of interfaces, they will be atomic.

// services/storageAdapter.ts

export function useOrdersStorage(): OrdersStorageService {
  return useStore();
}
Enter fullscreen mode Exit fullscreen mode

Also, this approach will give us the ability to customize additional optimizations for each store: we can create selectors, memoization, and more.

Validate Data Flow Diagram

Let's now validate how the user will communicate with the application during the created use case.

Use case data flow diagram

The user interacts with the UI layer, which can only access the application through ports. That is, we can change the UI if we want to.

Use cases are handled in the application layer, which tells us exactly what external services are required. All the main logic and data is in the domain.

All external services are hidden in the infrastructure and are subject to our specifications. If we need to change the service of sending messages, the only thing we will have to fix in the code is an adapter for the new service.

This scheme makes the code replaceable, testable and extensible to changing requirements.

What Can Be Improved

All in all, this is enough to get you started and gain an initial understanding of the clean architecture. But I want to point out things that I have simplified to make the example easier.

This section is optional, but it will give an expanded understanding of what clean architecture “with no cut corners” might look like.

I would highlight a few things that can be done.

Use Object Instead of Number For the Price

You may have noticed that I use a number to describe the price. This is not a good practice.

// shared-kernel.d.ts

type PriceCents = number;
Enter fullscreen mode Exit fullscreen mode

A number only indicates the quantity but not the currency, and a price without currency is meaningless. Ideally, price should be made as an object with two fields: value and currency.

type Currency = "RUB" | "USD" | "EUR" | "SEK";
type AmountCents = number;

type Price = {
  value: AmountCents;
  currency: Currency;
};
Enter fullscreen mode Exit fullscreen mode

This will solve the problem of storing currencies and save a lot of effort and nerves when changing or adding currencies to the store. I didn't use this type in the examples so as not to complicate it. In the real code, however, the price would be more similar to this type.

Separately, it's worth mentioning the value of the price. I always keep the amount of money in the smallest fraction of the currency in circulation. For example, for the dollar it is cents.

Displaying the price in this way allows me not to think about division and fractional values. With money this is especially important if we want to avoid problems with floating point math.

Split Code by Features, not Layers

The code can be split in folders not “by layers” but “by features”. One feature would be a piece of the pie from the schematic below.

This structure is even more preferable, because it allows you to deploy certain features separately, which is often useful.

Component is a piece of a hex pie

Image credits herbertograca.com.

I recommend reading about it in "DDD, Hexagonal, Onion, Clean, CQRS, ... How I put it all together".

I also suggest to look at Feature Sliced, which is conceptually very similar to component code division, but easier to understand.

Pay Attention to Cross-Component Usage

If we're talking about splitting system into components, it's worth mentioning the cross-component use of code as well. Let's remember the order creation function:

import { Product, totalPrice } from "./product";

export function createOrder(user: User, cart: Cart): Order {
  return {
    user: user.id,
    cart,
    created: new Date().toISOString(),
    status: "new",
    total: totalPrice(products),
  };
}
Enter fullscreen mode Exit fullscreen mode

This function uses totalPrice from another component—the product. Such usage is fine by itself, but if we want to divide the code into independent features, we can't directly access the functionality of the other feature.

You can also see a way around this restriction in "DDD, Hexagonal, Onion, Clean, CQRS, ... How I put it all together" and Feature Sliced.

Use Branded Types, not Aliases

For the shared kernel I used type-aliases. They are easy to operate with: you just have to create a new type and reference e.g. a string. But their disadvantage is that TypeScript has no mechanism to monitor their use and enforce it.

This doesn't seem to be a problem: so someone uses string instead of DateTimeString—so what? The code will compile.

The problem is exactly that the code will compile even though a broader type is used (in clever words precondition is weakened). This first of all makes the code more fragile because it allows you to use any strings, not just strings of special quality, which can lead to errors.

Secondly it's confusing to read, because it creates two sources of truth. It's unclear if you really only need to use the date there, or if you can basically use any string.

There is a way to make TypeScript understand that we want a particular type—use branding, branded types. Branding enables to keep track of exactly how types are used, but makes the code a little more complicated.

Pay Attention to Possible Dependency in Domain

The next thing that stings is the creation of a date in the domain in the createOrder function:

import { Product, totalPrice } from "./product";

export function createOrder(user: User, cart: Cart): Order {
  return {
    user: user.id,
    cart,

    // Вот эта строка:
    created: new Date().toISOString(),

    status: "new",
    total: totalPrice(products),
  };
}
Enter fullscreen mode Exit fullscreen mode

We can suspect that new Date().toISOString() will be repeated quite often in the project and would like to put it in some kind of a helper:

// lib/datetime.ts

export function currentDatetime(): DateTimeString {
  return new Date().toISOString();
}
Enter fullscreen mode Exit fullscreen mode

...And then use it in the domain:

// domain/order.ts

import { currentDatetime } from "../lib/datetime";
import { Product, totalPrice } from "./product";

export function createOrder(user: User, cart: Cart): Order {
  return {
    user: user.id,
    cart,
    created: currentDatetime(),
    status: "new",
    total: totalPrice(products),
  };
}
Enter fullscreen mode Exit fullscreen mode

But we immediately remember that we can't depend on anything in the domain—so what should we do? It's a good idea that createOrder should take all the data for the order in a complete form. The date can be passed as the last argument:

// domain/order.ts

export function createOrder(
  user: User,
  cart: Cart,
  created: DateTimeString
): Order {
  return {
    user: user.id,
    products,
    created,
    status: "new",
    total: totalPrice(products),
  };
}
Enter fullscreen mode Exit fullscreen mode

This also allows us not to break the dependency rule in cases where creating a date depends on libraries. If we create a date outside a domain function, it is likely that the date will be created inside the use case and passed as an argument:

function someUserCase() {
  // Use the `dateTimeSource` adapter,
  // to get the current date in the desired format:
  const createdOn = dateTimeSource.currentDatetime();

  // Pass already created date to the domain function:
  createOrder(user, cart, createdOn);
}
Enter fullscreen mode Exit fullscreen mode

This will keep the domain independent and also make it easier to test.

In the examples I chose not to focus on this for two reasons: it would distract from the main point, and I see nothing wrong with depending on your own helper if it uses only language features. Such helpers can even be considered as the shared kernel, because they only reduce code duplication.

Pay Attention to Relationship Between Cart and Order

In this little example, Order includes the Cart, because the cart only represents a list of products:

export type Cart = {
  products: Product[];
};

export type Order = {
  user: UniqueId;
  cart: Cart;
  created: DateTimeString;
  status: OrderStatus;
  total: PriceCents;
};
Enter fullscreen mode Exit fullscreen mode

This may not work if there are additional properties in the Cart that have nothing to do with the Order. In such cases, it is better to use data projections or intermediate DTO.

As an option, we could use the “Product List” entity:

type ProductList = Product[];

type Cart = {
  products: ProductList;
};

type Order = {
  user: UniqueId;
  products: ProductList;
  created: DateTimeString;
  status: OrderStatus;
  total: PriceCents;
};
Enter fullscreen mode Exit fullscreen mode

Make the user case more testable

The use case has a lot to discuss as well. Right now, the orderProducts function is hard to test in isolation from React—that's bad. Ideally, it should be possible to test it with minimal effort.

The problem with the current implementation is the hook that provides use case access to the UI:

// application/orderProducts.ts

export function useOrderProducts() {
  const notifier = useNotifier();
  const payment = usePayment();
  const orderStorage = useOrdersStorage();
  const cartStorage = useCartStorage();

  async function orderProducts(user: User, cart: Cart) {
    const order = createOrder(user, cart);

    const paid = await payment.tryPay(order.total);
    if (!paid) return notifier.notify("Oops! 🤷");

    const { orders } = orderStorage;
    orderStorage.updateOrders([...orders, order]);
    cartStorage.emptyCart();
  }

  return { orderProducts };
}
Enter fullscreen mode Exit fullscreen mode

In a canonical implementation, the use case function would be located outside the hook, and the services would be passed to the use case via the last argument or via a DI:

type Dependencies = {
  notifier?: NotificationService;
  payment?: PaymentService;
  orderStorage?: OrderStorageService;
};

async function orderProducts(
  user: User,
  cart: Cart,
  dependencies: Dependencies = defaultDependencies
) {
  const { notifier, payment, orderStorage } = dependencies;

  // ...
}
Enter fullscreen mode Exit fullscreen mode

The hook would then become an adapter:

function useOrderProducts() {
  const notifier = useNotifier();
  const payment = usePayment();
  const orderStorage = useOrdersStorage();

  return (user: User, cart: Cart) =>
    orderProducts(user, cart, {
      notifier,
      payment,
      orderStorage,
    });
}
Enter fullscreen mode Exit fullscreen mode

Then the hook code could be considered an adapter, and only the use case would remain in the application layer. The orderProducts function could be tested by passing the required service mochas as dependencies.

Configure Automatic Dependency Injection

There, in the application layer, we now inject services by hand:

export function useOrderProducts() {
  // Here we use hooks to get the instances of each service,
  // which will be used inside the orderProducts use case:
  const notifier = useNotifier();
  const payment = usePayment();
  const orderStorage = useOrdersStorage();
  const cartStorage = useCartStorage();

  async function orderProducts(user: User, cart: Cart) {
    // ...Inside the use case we use those services.
  }

  return { orderProducts };
}
Enter fullscreen mode Exit fullscreen mode

But in general, this can be automated and done with dependency injection. We already looked at the simplest version of injection through the last argument, but you can go further and configure automatic injection.

In this particular application, I didn't think it made much sense to set up a DI. It would distract from the point and overcomplicate the code. And in the case of React and hooks, we can use them as a “container” that returns an implementation of the specified interface. Yes, it's manual work, but it doesn't increase the entry threshold and is quicker to read for new developers.

What in real projects could be more complicated

The example in the post is refined and intentionally simple. It is clear that life is much more surprising and complicated than this example. So I also want to talk about common problems that can arise when working with the clean architecture.

Branching Business Logic

The most important problem is the subject area that we lack knowledge about. Imagine a store has a product, a discounted product, and a write-off product. How do we properly describe these entities?

Should there be a “base” entity that will be expanded? How exactly should this entity be expanded? Should there be additional fields? Should these entities be mutually exclusive? How should user cases behave if there's another entity instead of a simple one? Should the duplication be reduced immediately?

There may be too many questions and too many answers, because neither the team nor the stakeholders know yet how the system should actually behave. If there are only assumptions, you can find yourself in an analysis paralysis.

Specific solutions depend on the specific situation, I can only recommend a few general things.

Don't use inheritance, even if it's called “extension”. Even if it looks like the interface is really inherited. Even if it looks like “well, there's clearly a hierarchy here”. Just wait.

Copypaste in code is not always evil, it's a tool. Make two almost identical entities, see how they behave in reality, observe them. At some point you'll notice that they've either become very different, or they really only differ in one field. It's easier to merge two similar entities into one than it is to create checks for every possible condition and variant.

If you still have to extend something...

Keep in mind covariance, contravariance, and invariance so you don't accidentally come up with more work than you should.

Use the analogy with blocks and modifiers from BEM when choosing between different entities and extensions. It helps me a lot to determine if I have a separate entity or a “modifier-extension” the code, if I think of it in the context of BEM.

Interdependent Use Cases

The second big problem is related use cases, where an event from one use case triggers another.

The only way to handle this, which I know and which helps me, is to break up the use cases into smaller, atomic use cases. They will be easier to put together.

In general, the problem with such scripts, is a consequence of another big problem in programming, entities composition.

There's a lot already written about how to efficiently compose entities, and there's even a whole mathematics section. We won't go far there, that's a topic for a separate post.

Conclusions

In this post, I've outlined and expanded a bit on my talk on the clean architecture on the frontend.

It's not a gold standard, but rather a compilation of experience with different projects, paradigms, and languages. I find it a convenient scheme that allows you to decouple code and make independent layers, modules, services, which not only can be deployed and published separately, but also transferred from project to project if needed.

We haven't touched on OOP because architecture and OOP are orthogonal. Yes, architecture talks about entity composition, but it doesn't dictate what should be the unit of composition: object or function. You can work with this in different paradigms, as we've seen in the examples.

As for OOP, I recently wrote a post about how to use the clean architecture with OOP. In this post, we write a tree picture generator on canvas.

To see how exactly you can combine this approach with other stuff like chip slicing, hexagonal architecture, CQS and other stuff, I recommend reading DDD, Hexagonal, Onion, Clean, CQRS, ... How I put it all together and the whole series of articles from this blog. Very insightful, concise, and to the point.

Sources

Design in Practice

System Design

Books about Design and Coding

Concepts from TypeScript, C# and Other Languages

Patterns, Methodologies

Top comments (119)

Collapse
 
uriannrima profile image
Luciano Lima

By the Lord's mercy, an article that isn't afraid to be big, tackle something that is REALLY important and above the "10 packages to use in your next project", and with so much useful information!!!

Alex, you're a godsend. Loved and hope to use it as material in future projects.

Small P.S.: Loved to see the Branded Types being used in the architecture. I'm also hoping to use more io-ts and more of a "functional" strategy to manage those scenarios and have more "safety".

Again great article man.

Collapse
 
ashoutinthevoid profile image
Full Name

If you're already in the fp-ts ecosystem, you might consider using newtype-ts.

Even if you stick with the recommendation in the article, using the unique symbol approach (as in the newtype-ts examples) seems to completely avoid the string constant collision worry that ts-brand mentions repeatedly in its readme.

Collapse
 
bespoyasov profile image
Alex Bespoyasov Author

Oh, seems interesting, thanks!
I’ll definitely check it out :–)

Collapse
 
uriannrima profile image
Luciano Lima

Definitely! I really didn't knew that you could use symbols to brand types, which is really great! I'll try to take a look at the newtype-ts. Thanks @ashoutinthevoid !

Collapse
 
bespoyasov profile image
Alex Bespoyasov Author • Edited on

Thanks a lot! Glad you liked it!

Yeah, I wanted to gather all the experience I have with different paradigms and languages I used. And it feels like Clean Architecture with a bit of functional programming works well.

Thanks again for the comment ☺️

Collapse
 
jcarlosweb profile image
Carlos Campos

Thank you very much, I am just learning that now. I will have to dedicate more time to your post for a few days. Anyway I didn't know that domain logic is considered Frontend.

Collapse
 
joachimzeelmaekers profile image
Joachim Zeelmaekers

Great Article Alex!

I really like the fact that you explain the patterns and architecture by example and show why it's an improved way of working. The part I loved the most was: "Split Code by Features, not Layers". I believe this is the only way to avoid code duplication and bad design. I even think this is one of the strengths of using a micro frontend architecture in which the design of the application is very important.

I want to thank you for this article!
Hope to read more from you soon!

Collapse
 
bespoyasov profile image
Alex Bespoyasov Author

Thanks for the review! ^_^

Yeah, I wasn’t sure at first if I needed to split the code by features right at the start. I thought it could overcomplicate the mental model of “layers”.

But I felt like it was a really important part, so I decided to add a section about it after the reader is familiar with layers.

Hope it is clear enough though 😅

Collapse
 
92srdjan profile image
WingsDevelopment • Edited on

Hello Alex, I loved your article, and I am planning to use this architecture in huge enterprise project. I have some concerns about redux & redux-toolkit paradigm that is recommended from their documentation.
So if I understood your plan correctly in your domain you define your interface OrdersStorageService, that requires 2 things, some state, and some callback for setting the state. But then you end up with 1 reducer in redux? Because if you have single callback per model, then I assume you will end up with 1 reducer by slice, which is 'not recommended' or maybe I'm just paranoid and that's totally fine ?
I even posted full question in stackoverflow, you can find it here: stackoverflow.com/questions/716495... where I am trying to Implement your architecture with react + redux-toolkit, local state, and custom hook store.

Collapse
 
bespoyasov profile image
Alex Bespoyasov Author

Hey!

So, I've noticed the same question in the issue in the repository and answered it right there, if you don't mind:

Collapse
 
giovannicorrea profile image
Giovanni Correa • Edited on

This is one of the most comprehensive and well-written posts I've ever read. The subject is also quite crucial. I'm not sure why the front end world (in general) doesn't seem to care about architecture or things like SoC or DI. More people will comprehend the importance and relevance of these strategies if we can share them more widely.

The main reason for using clean architecture (for example, Co2 Cartridges uk) in frontend projects, in my opinion, is to be able to swap out UI libraries with minimal effort. We lose this capability by relying on React for non-UI layers.

Collapse
 
bespoyasov profile image
Alex Bespoyasov Author

Thank you!

Totally. I had some hard time working with coupled “React-driven-code”, it was difficult and inefficient.

I now tend to less depend on third-party code and modules and decouple my own code from the “outside” one.

Collapse
 
amirabbasj profile image
AmirabbasJ • Edited on

This was awesome

I mean you won't get this much from an ordinary post, yeah maybe some of them would actually be understandable but this one was literally everything.
It wasn't just about clean architecture, it was a more of a "fp approach to architecture with typescript" + using good practices (like having type alias)
Most articles won't explain further or even provide an example, they'll just copy paste some high level stuff

I really appreciate your effort, and I'm waiting for future posts

Collapse
 
bespoyasov profile image
Alex Bespoyasov Author

Thank you! ^_^

Yup, I was trying to compile all the good practices that help me writing code in one big post.

Glad you liked it!

Collapse
 
jackmellis profile image
Jack

This is one of the best and most detailed posts I've read. The subject is so important too. I can't understand how (in general) the front end world doesn't really care about architecture and things like SoC or DI. The more we can share these kinds of techniques the more people will understand their importance and relevance.

Collapse
 
bespoyasov profile image
Alex Bespoyasov Author

Thank you! I really appreciate you find it useful and important ^_^

Collapse
 
jackmellis profile image
Jack

I commented on my appreciation of this post when I first came across it but I just came back to add a thought I had:
I often see people still writing these services and repositories i.e. NotificationService as a class with a tonne of methods on it. You don't need classes to do this sort of thing. There's no reason why you can't just have individual functions as mini services. If you're using standalone functions instead of classes with dozens of methods, you really cut down on implementation creep and having to mock random unrelated dependencies when you're just trying to test a single method.
I think this whole services as classes thing is just a "well we did it in OOP" thing. Start thinking in functions!
Maybe we stick to this pattern because constructor injection is the most common way to implement DI but it's not the only way - and in a predominantly functional application, it's probably not even the best/easiest way.

Collapse
 
bespoyasov profile image
Alex Bespoyasov Author • Edited on

That’s correct!

In this post, I tried to bind “clean architecture principles” and “functional core in imperative shell” with pure transformations because I think it takes the best from different paradigms.

I use objects for services here just because it’s easier for me to think of a service as a “package” with a couple of functions inside, and easier to check if the service implements the interface. (I try not to use classes if there is no need to keep some state in the entity.) So, basically, it is just a function, but in a box 😃

I also think that DI via constructors is a convenient way to compose objects, although for composing functions there are other technics like boxes and mapping :–)

In general, I try not to be an advocate for a single particular programming paradigm. For example, immutability, pure functions and functional composition are ideal for describing data transformations. But for working with state I prefer objects instead of monadic state changes from pure FP 😅

Collapse
 
yxw007 profile image
Potter

This is the most easy to understand software architecture design article I have seen, opened my awareness, thank you ~

Collapse
 
bespoyasov profile image
Alex Bespoyasov Author

Thanks!

So glad it helped! ^_^

Collapse
 
twigman08 profile image
Chad Smith

Now this is a post and should be a model of the type of stuff I really am looking for here! Post is definitely saved and I will be looking back at a lot. Thanks so much for it!

Collapse
 
bespoyasov profile image
Alex Bespoyasov Author

Thank you very much! I really wanted to make it as detailed and useful as I could :–)

Collapse
 
rdlopes profile image
Rui Lopes

I appreciate the fact that you're advocating clean architecture on the frontend, vastly undergone.
The adapters are clearly defined, it's always a pain to see UI developers that mix UI elements with API calls.

But then, I browse through your code and I see React ??? And zero tests ???

What's the use of explaining that clean architecture is about getting rid of intertial framework dependencies and set right away the dependency that is the most bloattered of all ?
On regular 3G, just tested, more than 4 seconds to display, React accounts for more than 1,7Mo ! A gigantic failure on performance, which is your main point.

No tests nowhere, except for the package.json that lists an entry for testing... a bit cynical...

Now, I can understand that you wanted to go fast, but that's the whole point of your post... Forget about React ! Build a UI which is standard, WebComponents for the win here; light, fast, W3C standard.

And please, if you cite Clean Code and Clean Architecture from Martin, I would expect a test first development with a test for each component built.

I felt really uncomfortable when I noticed the difference between what your profess and what you do in reality.

I'll gladly take your post as descriptive, because I noticed a real quality in the writing and references used.

But for the others, do as he says, not as he does.

Collapse
 
jackmellis profile image
Jack

The empahsis of this article was on clean architecture. I think getting bogged down talking about tests, filling the example code with tests, etc. would've detracted from the main point of the article. The repo unless I'm mistaken, is just a proof that demonstrates the text.

And whether or not React was used is also missing the point a bit. Clean Architecture does not have any bearing on the rendering library. The point is that you could swap out React for some other library or framework or web components, and the majority of the code would stay exactly the same. That's surely the point?

Collapse
 
rdlopes profile image
Rui Lopes

If you develop just for the sake of developping, I'm happy with that.
But never forget the reason why you apply those principles in the first place.

Collapse
 
mfpopa profile image
Mihail

If you're developing an application that's a few thousand lines of code, then you probably do not need to worry about maintenance, scalability and separation of concerns because you probably don't have the time and budget for it. For that scenario, clean architecture is an overkill and mixing API calls inside some UI components is fine.

Domain-centric architectures (clean, hexagonal, DDD, etc.) have been invented in the object-oriented world to deal with the complexity of software systems have millions and tens of millions of lines of code. At that scale, you cannot afford NOT TO have a proper architecture in place. For these systems, the frontend was in most cases part of the outermost layer of the clean architecture. It was part of the delivery mechanism that connected the user to the business domain logic implemented in code that runs on the server. Generally speaking, clean architecture does not map very well to the frontend without incurring some of the costs that the author highlighted in the article. Of course the code needs to be written to achieve low coupling, high cohesion and separation of concerns, but it can be done without adopting a domain-centric architecture the way it's usually presented by the likes of Bob Martin.

Every time you listen to Bob Martin talk about clean architecture and TDD, keep in mind that he's not referring to frontend apps that are few thousand lines of code, but rather software systems with multiple API that amount to hundreds of thousands or millions of lines of code at a minimum.

React accounts for 128KB minified and 43KB gzipped at most in any application. No idea where you took the 1.7MB from. Not to mention that the subject of the article is not performance. Clean architecture is not concerned about performance either, but about how to write loosely coupled code with clear boundaries between different concerns. The demo setup for this article is not meant to be state of the art frontend application, as the author actually explains several times in the article, but a regular demo to help the reader navigate the article. So it does not makes sense to test its performance over a 3G connection and complain about it.

I would forget React and any other library and framework if there were better ways to build web apps out there. Web Components do not replace React and the like, at least not yet (reactjs.org/docs/web-components.html). If you think web components are so great, have a think why Microsoft, Google, Facebook, Netflix, AirBnb, etc use these frameworks and not Web Components for their web apps. I'm sure web components will continue to evolve, but we're not where we can use them for everything.

Collapse
 
rdlopes profile image
Comment marked as low quality/non-constructive by the community. View Code of Conduct
Rui Lopes

So you abandoned consistency to start developing for your glory.
That's OK, ego can be a good driver too; keep it up.

Thread Thread
 
mfpopa profile image
Mihail • Edited on

Come on mate, feels like you're posting comments here to sound smart, but don't actually think the subject in-depth. I don't know how much software development experience you have, but it sounds like not a lot, otherwise you'd be familiar with the fact that there are always trade-offs. To achieve the consistency I think you talk about, in every single project, you end up working 100+ hours a week and be paid for 40. This is definitely the case for some projects, in general small projects. I know because I tried doing TDD and clean architecture for small projects where the client did not care to pay for framework independence, unit testing and a solid architecture. If that's how you like to spend your life, great, but it's not for me. If it is glory and ego that makes me choose a good work-life balance, then by all means that's what I am doing.

To be clear, for large projects (at least hundreds of thousands of lines of code), clients understand the importance of a good architecture and there I'm always advocating for the right type of architecture and definitely testing. Also, even for small projects I'm always setting up a layered architecture, but there is usually no unit testing, just UAT, and I don't spend more than 1-2 hours thinking about the architecture. I usually start with a template from previous projects.

tradeoffs #worklifebalance

Collapse
 
grad profile image
Alex Grad • Edited on

Great article, Alex. One question. Hooks are part of React. React is about the UI layer. So why are hooks part of the application layer? It’s the same with using hooks in services. How can you reuse them across different UI libraries?

Collapse
 
bespoyasov profile image
Alex Bespoyasov Author

Thanks!

I described this nuance in these sections of the post:

TL:DR; it is indeed non-canonical, in a perfect world, the use case is independent from the framework.

In a canonical implementation, the use case function would be located outside the hook, and the services would be passed to the use case via the last argument or via a DI

In the sections' examples, I show how to do it using DI with the last argument for the use case. Basically, we're just passing all the things we gathered from hooks via object. If we did that, the hook would become just an adapter which is better.

However, it is not always reasonable to do. In this case, for example, hooks are used just as “DI” to inject services and work with data storage.

Also, notice that even using hooks as DI, we don't mix up the use case function and the hook that is exposing the use case to the UI. The use case is still independent from the framework.

Conceptually the hook is already just an adapter and not a part of the application layer. It's just located there because of my laziness :–)

Collapse
 
grad profile image
Alex Grad

I think that the main reason for using clean architecture in frontend projects is to be able to replace a UI library with a different one with minimal effort. By making non-UI layers dependent on React, we lose this capability.

Thread Thread
 
jwhenry3 profile image
Justin Henry

Depending on the UI library in question, you will probably never be able to just "swap it out", and in most environments, the dev shop will have created a design system, an accepted UI suite to use (material, kendo, etc) and will hardly ever change. The real flexibility is in portability and extendability, which is more-so for your own libraries you write, not the application itself. The application is the implementation detail, and your libraries are where the clean architecture matters the most.

So long as you have decent organization and have well-built and taken-care-of library architecture, your application will probably just use the library and mock/override what is needed.

This is why Angular uses modules and allows you to inject provider overrides. React is more open-book, which is where a hook-driven DI makes sense. React Context and several other state management tools also aide in dependency injection and overrides, it's just a matter of what you prefer.

Thread Thread
 
grad profile image
Alex Grad

you will probably never be able to just "swap it out"

I use TypeScript + MobX and can change React to Vue or Angular at any time. So it depends on the architecture of your application.

Collapse
 
laibulle profile image
Guillaume BAILLEUL

this functional approach of clean architecture will help me a lot in my next projects. I was so disappointed to only see overingeneered OO implementations.

Thanks a lot

Collapse
 
bespoyasov profile image
Alex Bespoyasov Author

Glad it helped! ^_^

Collapse
 
fiterv profile image
Vadym

Great article, thank you for writing it @bespoyasov .

Collapse
 
bespoyasov profile image
Alex Bespoyasov Author

Thank you! ^_^

Collapse
 
ryotokubo profile image
ryoto kubo

Great Article Alex!

I have one question.

I believe that Clean Architecture has the idea of "designing so that the outer layers depend on the inner layers".

When I look at the source code, I see that the Application Business Rules depend on the Interfase Adapters.

Collapse
 
bespoyasov profile image
Alex Bespoyasov Author

Thank you! ^_^

Interfaces (aka ports) are the part of the application layer. But their implementation is the part of adapters layer.

The application layer dictates the requirements for the third-party services. Since the ports are behavior contracts they are also determined by the application layer.

There is a post I refer to in the text:

…It explains this specificity in more detail :–)

Collapse
 
ryotokubo profile image
ryoto kubo

Thanks for answering!
I'll read the article too!

Collapse
 
fenriuz profile image
Fenriuz

I'm not even a frontend, but I still appreciate the post. Thank you!

Collapse
 
bespoyasov profile image
Alex Bespoyasov Author

Woah, that’s so cool! Thank you ^_^

Collapse
 
camerenisonfire profile image
Cameren Dolecheck

Jeeze, this has got to be one of the best produced posts on Dev.to.

I learned a lot and probably still need to reread it a couple more times to fully get some of it. The list of sources at the end is something that needs to be done far more often.

Collapse
 
bespoyasov profile image
Alex Bespoyasov Author

Thanks! Glad you liked it 😊

Collapse
 
dantereve profile image
Mathias Oviève

Amazing article, thank you! Really clear explanation with loads of examples. 💪

I'm just wondering why you don't use the concept of Presenters of the Clean Architecture? Is there any drawback in using Presenters?

Collapse
 
bespoyasov profile image
Alex Bespoyasov Author

Thanks! ☺️

About presenters, no drawback, it’s just the app is so small that there wasn’t any need for additional data transformations before render ¯_(ツ)_/¯

In the real apps, there might be a case for them, depends on the complexity of a system.

In my experience though, the only mediators between a use case and the UI were formatters and data converters for components. Basically, my teams and I managed to split all the processes is such a way and create such DTOs that the UI logic became insignificant and mostly was about rendering stuff and handling user events.

Collapse
 
egucciar profile image
Erica Gucciardo

This is a great read and incredibly thorough! Thank you so much for this. I have been following the CA approach in React for awhile but am newer to TypeScript. The way this code is seperated and adheres to the principles of the architecture while also paying mind to expediency and complexities of the code, is wonderful. I enjoy how it also leverages the full capabilities of Types, React and hooks. The only thing I'd add is perhaps a testing approach ~ clearly you alluded to it but I'm curious myself where the majority of tests we want to write would live with highest levels of confidence. Also how would you handle complex derived presentational logic, would it be seperated as well and then wrapped in useMemo? Once again great article.

Collapse
 
bespoyasov profile image
Alex Bespoyasov Author

Thank you very much for the review! Really glad you find it useful 😊

Yup, the tests are a very important tool for developing robust software. I intentionally skipped tests in this post to avoid distraction from the main topic. Although, I mentioned the testability and how to improve it in the post text. So basically, we keep tests in mind along the way :–)

As for the complex UI logic, I would add some presenters to handle that. In my opinion, it’s better to separate the UI logic from the application logic. So basically, application layer would reflect the app data transformations, and UI presenters — only the complex UI changes.

In some cases, presenters might resemble finite state machines. I find them a very convenient way to describe the UI changes.

Collapse
 
albertdugba profile image
Albert

This is by far one of the excellent pieces I have read on the Dev.to . Thanks for pouring it out for us. This will go a long way to help me in frontend engineering.

Collapse
 
bespoyasov profile image
Alex Bespoyasov Author

Thank you! I'm glad you find this useful ^_^

Collapse
 
embrycode profile image
Mason Embry

This is seriously great and exactly what I was looking for. Seeing how you do this with TS interfaces and modules as opposed to OOP really helps me to understand it as a frontend guy. I'm trying to level-up on how I architect my features and this is a great starting point. Thank you.

Collapse
 
bespoyasov profile image
Alex Bespoyasov Author

I’m so glad it helped! ^_^

Collapse
 
quyctd profile image
Quy Dinh Cong

Awesome article! Respect the effort you put to create this article 👍🏻 Thank you for sharing, love it 💯

Collapse
 
bespoyasov profile image
Alex Bespoyasov Author

Thanks! 😊

Collapse
 
solalem profile image
solalem

I was always frustrated by the lack of 'sane structure' that is independent of libraries/ frameworks in the front end development as I was used to in backend side. Now I saw Clean Architecture can be applied here too! I think I am going to like working in frontend projects. Thanks!

Collapse
 
bespoyasov profile image
Alex Bespoyasov Author

Thank you!
Glad it helped ^_^

Collapse
 
latobibor profile image
András Tóth

I wish CSS/SCSS would be mentioned every time we talk about frontend...

Collapse
 
egucciar profile image
Erica Gucciardo

With this approach any modularized CSS approach should work fine!

Collapse
 
latobibor profile image
András Tóth

What do you mean by modularized? How does it play together with the cascade? Can you define it for me? I don't know if I can agree or not yet :).

Collapse
 
convers39 profile image
Jerry • Edited on

Great Article! Thank you for your effort!

Just being curious, have you applied clean architecture at work, or are there any open source project which introduced this practice.

We are considering to apply clean architecture in our production, while also concerning the cost and lack of real world examples.

And also personally I am wondering why clean architecture is not popular in the frontend world, is the cost even higher so that most of time we just give up on those benefits of clean? Or are there any other architectures to achieve those best practices which we could gain from clean.

Collapse
 
bespoyasov profile image
Alex Bespoyasov Author

Thank you!

have you applied clean architecture at work, or are there any open source project which introduced this practice.

Yup, I apply this approach in my work. A specific implementation might differ but the overall idea stays the same.

Most of my projects' repos are closed though, but I've got one open-source project where I use this approach: github.com/bespoyasov/tmstmp.

I am wondering why clean architecture is not popular in the frontend world, is the cost even higher so that most of time we just give up on those benefits of clean? Or are there any other architectures to achieve those best practices which we could gain from clean.

IMO, this architecture style is suitable for applications with rich domain logic which isn't always the case with the frontend.

Sometimes, frontend applications are “thin client apps” and the main logic is kept on the server. In these cases, it's not really necessary to develop sophisticated architecture for the client app.

Also, on the frontend, there's huge impact of reactivity and FSM programming. The nature of UI imposes a lot of constraints on the code style and data flow for the frontend development.

Making UI deterministic and declarative is difficult, and sometimes clean architecture just doesn't fit the requirements.

Collapse
 
eblahoi profile image
eblahoi

Wow, a high quality content.
Makes you look at the front-end development from a whole new angle.

Thank you!

Collapse
 
bespoyasov profile image
Alex Bespoyasov Author

Thanks! 😊

Collapse
 
happylittleone profile image
littleone

Can the domain layer call the adapters directly? or only the application layer can interact with the adapters? thanks!

Collapse
 
bespoyasov profile image
Alex Bespoyasov Author

As I mentioned in the post, the canonical way assumes distinction between the functionality.

That means, there not only should always be the application layer, but also layers should have their own DTOs.

However, not every project needs all this. In the post, I mentioned a couple of reasons for not using all of the “canonical stuff”.

Collapse
 
happylittleone profile image
littleone

well , if domain have DTO, it then has side effects, no good for tests I think, Is there a principle like "keep domain layers pure, easy to test" ?

Thread Thread
 
bespoyasov profile image
Alex Bespoyasov Author

DTO (data transfer object) is just a data structure, it doesn’t affect “purity”.

Layers should be decoupled, so that changes in one of them don’t affect others. DTOs are one of the ways to do that.

In the domain layer, DTOs can be the domain entities themselves. In other—specific objects designed in a way to provide only as much information as needed.

(You can find out more about this in, for example, “Domain Modeling Made Functional” and “Domain Driven Design”.)

But again, not every project needs this. Smaller apps can live without that strict separation. Every solution depends on the particular problem 😃

Collapse
 
davidnussio profile image
David Nussio

Thanks for sharing this high quality article.
What do you think about transforming currentDatetime into lib / datetime.ts as a service that implements a port interface?

Collapse
 
bespoyasov profile image
Alex Bespoyasov Author

Thanks! Glad you liked it ^_^

I would use that if the domain entity is strictly pure but we have a requirement because of which we need to somehow use a third-party service for dates.

In this case, the entity would take the datetime as an argument. The use case function would call the service, receive the datetime and use it to create the entity.

And it still would fit in the impureim sandwich approach:

  • gather impure data (datetime)
  • perform pure operation (create entity)
  • perform impure side effect (save the entity, for example)

But in simpler cases I would go without extra services if they are just “language feature wrappers” 😃

Collapse
 
happylittleone profile image
littleone

nice article! I have questions:

  1. why don't use oop, classes?
  2. why there is no repositories ?
Collapse
 
cess11 profile image
PNS11 • Edited on

With Java style OOP there's a lot of ceremony involved in making classes composable compared to pure functions, and if you're changing a class it's harder to tell whether it will break dependencies and composability while changing a pure function is in principle fine as long as the parameter and return contracts stay the same.

For building UI and application flow it helps a lot to have a set of components that are easy to judge whether they compose well and to what extent they're likely to cause problems when modified. Certain OOP implementations overcome this, you could check out the GUI library and contracts in Racket, Common Lisp CLOS or perhaps Pharo Smalltalk.

Collapse
 
happylittleone profile image
littleone

so, class itself is a bad design? the new comers of programming langue(rust,t go) just remove it.

Thread Thread
 
cess11 profile image
PNS11

Depends on the purpose and who's implementing something. In single threaded applications mutable data structures implemented as classes might be a good option for performance reasons.

Golang has structs, they're kinda-sorta object-like, but no classes. Rust is designed to be a good fit for C++ developers and sticks to inherited designs except when it conflicts with the borrower and type system.

Collapse
 
bespoyasov profile image
Alex Bespoyasov Author • Edited on

Thanks!

1) These principles are applicable to any paradigm. There are lots of books about using the clean architecture with OOP, but very few resources about that with any other. Also, fronted is more close to multi-paradigm and FP than to OOP.

However, I wrote about OOP as well, take a look 😃

2) No need for them in this particular example. They would complicate the post so it would become more difficult for people who aren't familiar with the concept.

I wanted to keep the post as close to the real world as possible but as simple as possible at the same time.

Collapse
 
andyjpg profile image
Peigeng Jiang Andy

Thank you Alex

Collapse
 
dongphuchaitrieu profile image
Đồng Phục Hải Triều

Really high quality article.
Thank you!

Collapse
 
bespoyasov profile image
Alex Bespoyasov Author

Thanks for reading! ^_^