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 (131)

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

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 • Edited

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
 
michalkurzanowski profile image
Michał Kurzanowski

Thank you for the valuable information, this is exactly what I was looking for!

One of my team's OC24 LTD first projects, LegalBukmacher, was done the way we knew it at the time. It was really hard because we didn't understand how to do the architecture of the project correctly to avoid problems later. We wasted a lot of time on various edits, but now we have finally achieved the desired result. This is a really important topic for all FrontEnd developers who want to work with large projects.

Collapse
 
bespoyasov profile image
Alex Bespoyasov

Thank you for the valuable information, this is exactly what I was looking for!

Happy you found it helpful! 🙌

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

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

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

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
 
amirabbasj profile image
AmirabbasJ • Edited

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

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
 
giovannicorrea profile image
Giovanni Correa • Edited

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

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
 
sunnywang profile image
王二狗

I am a chinese FE developer, this article is so goods, looking forward to more good articles !!!

Collapse
 
bespoyasov profile image
Alex Bespoyasov

Thank you!

Yeah, I’ve got some new upcoming posts planned, stay tuned! ^_^

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

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

Collapse
 
hello10000 profile image
a

legendary

Collapse
 
bespoyasov profile image
Alex Bespoyasov

Thanks! 😃

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

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

Some comments may only be visible to logged-in visitors. Sign in to view all comments.