Hi, I’m ConardLi, is there a front-end architecture? This may be a puzzle in many people’s minds, because in actual business development, we rarely design standard code architecture for the front end, and may pay more attention to engineering, directory level, and the implementation of business code.

Today, we are going to look at a front-end Architecture model, which the original author called “Clean Architecture”. The article is very long and detailed. I spent a long time to read it, and after reading it, I gained a lot and translated it to you.

  • Dev. To /bespoyasov/…
  • Source code for the examples in this article: github.com/bespoyasov/…

We’ll start with a brief introduction to Clean Architecture, with concepts like domains, use cases, and application layers. Then there’s how to apply clean architecture to the front end, and whether it’s worth it.

Next, we’ll design a store application using the principles of clean architecture and implement it from scratch to see if it works.

The app will use React as its UI framework, just to show that this approach can work with React. You can also choose any other UI library to implement it.

Some TypeScript is used in the code, just to show how to use types and interfaces to describe entities. All code can be implemented without TypeScript, but it just doesn’t look as expressive.

Architecture and Design

Design is essentially taking things apart in a way that you can put them back together… Breaking things down into things that can be put together again is design. — Rich Hickey, Design, Refactoring, and Performance

System design is really about breaking systems apart, and the most important thing is that we can put them back together again without spending too much time.

I agree with the above, but I think another major goal of system architecture is system extensibility. The requirements of our applications are constantly changing. We wanted our program to be very easy to update and modify to meet constantly changing new requirements. A clean architecture can help us achieve this goal.

What is a clean architecture?

Clean architecture is a way to split responsibilities and functions based on how similar the application’s domain is.

Domains are program models abstracted from the real world. Can reflect the mapping of real-world and application data. For example, if we update the name of a product, replacing the old name with the new name is a domain transformation.

The functionality of a clean architecture is usually divided into three layers, as shown in the following diagram:

Domain layer

At the center is the domain layer, which describes the entities and data of the application subject area and the code to transform that data. Domains are at the heart of what distinguishes different programs.

You can think of domains as the parts that don’t change when we move from React to Angular, or change certain use cases. In the case of the store application, domains are products, orders, users, shopping carts, and ways to update these data.

Data structures and their transformations are insulated from the outside world. External event invocations trigger domain transformations, but do not determine how they are run.

For example, the ability to add items to the cart doesn’t care how items are added to the cart:

  • Users themselves add them by clicking the “Buy” button
  • Coupons are automatically added when the user uses them.

In both cases, an updated shopping cart object is returned.

The application layer

Outside the domain is the application layer, which describes the use cases.

For example, the “Add to cart” scenario is a use case. It describes what should be done after the button is clicked, as a kind of “coordinator” :

  • Send a request to the server;
  • Perform domain transformation;
  • Update the UI with the response data.

In addition, there are ports in the application layer, which describe how the application layer communicates with the outside world. Usually a port is an interface, a behavior contract.

A port can also be thought of as a “buffer” between the real world and the application. The input port tells us how the application needs to receive input from the outside, and the output port tells us how to prepare for communication with the outside.

Adapter layer

The outermost layer contains the adapter for the external service through which we convert the incompatible apis of the external service.

Adapters reduce the coupling between our code and external third-party services. They are generally classified as:

  • Driven – sending messages to our application;
  • Passive – Accepts messages sent by our application.

The average user interacts most often with a driven adapter, such as one that handles click events sent by a UI framework. It works with the browser API to translate events into signals that our application can understand.

Drivers interact with our infrastructure. On the front end, most of the infrastructure is back-end servers, but sometimes we may interact directly with other services, such as search engines.

Note that the farther away from the center, the more “service oriented” the functionality of the code is and the farther away from the application domain, which will be important later when we decide which layer a module is.

Depend on the rules

The three-tier architecture has one dependency rule: only the outer layer can depend on the inner layer. This means:

  • Domain must be independent
  • The application layer can depend on the domain
  • The outermost shell can depend on anything

Of course there are special cases where this rule may be violated, but it is best not to abuse it. For example, it is possible that some third-party libraries will be used in the domain, even though such dependencies should not exist. You’ll see an example of this in the code below.

Code that does not control the direction of dependencies can become very complex and difficult to maintain. Such as:

  • Cyclic dependency, module A depends on B, B depends on C, C depends on A.
  • Poor testability, even testing a small piece of functionality will have to simulate the entire system.
  • The coupling is too high, so the interaction between modules is fragile.

Advantages of a clean architecture

Separate areas

The core functions of all applications are split and maintained in one place – the domain

The functionality in the domain is independent, which means it’s easier to test. The less dependent the modules are, the less testing infrastructure is required.

Separate domains are also easier to test against business expectations. This helps make it easier for beginners to understand. In addition, separate domains make it easier to troubleshoot errors from requirements to code implementation.

Independent case

Application usage scenarios and use cases are described independently. It determines which third party services we need. We made external services more tailored to our needs, which gave us more room to choose the right third-party services. For example, now that the price of the payment system we call has gone up, we can quickly replace it.

The use case code is flat, easy to test, and extensible. We’ll see this in a later example.

Replaceable third-party services

Adapters make it easier to replace external third-party services. As long as we don’t change the interface, it doesn’t matter which third-party service implements it.

That way, if someone else changes the code, it doesn’t directly affect us. Adapters also reduce the spread of application runtime errors.

The cost of implementing clean architectures

Architecture is first and foremost a tool. Like any other tool, a clean architecture comes with additional costs in addition to its benefits.

Need more time

The first is time, more time to design, more time to implement, because it’s always easier to call third-party services directly than to write an adapter.

It’s hard to start with a clear idea of all the interactions and requirements of a module, and we need to be aware of where changes can occur, so we need to consider more extensibility.

Sometimes it seems redundant

In general, clean architectures are not suitable for all scenarios and can even be harmful in some cases. If it’s a small project, and you’re designing for a clean architecture, it raises the bar for getting started.

It’s harder to get started

Completely designing and implementing a clean architecture makes it more difficult for beginners to get started because they need to understand how the application works in the first place.

Code increase

This is a problem specific to the front end, where a clean architecture increases the volume of the final package. The larger the product, the longer the browser download and interpretation time, so the amount of code must be controlled, appropriate code cuts:

  • Describe use cases more simply;
  • Interact directly with the adapter and domain, bypassing use cases;
  • Split code

How can these costs be reduced

You can reduce implementation time and code by cutting corners and sacrificing architectural “cleanliness.” If I can get more benefit from giving something away, I will do it without hesitation.

So instead of following the design principles of clean architecture in all aspects, follow the core principles well.

Abstract domain

The abstraction of domains helps us understand the overall design and how they work, while making it easier for other developers to understand programs, entities, and the relationships between them.

Even if we skip the other layers directly, the realm of abstraction is easier to refactor. Because their code is centrally packaged in one place, other layers can be easily added as needed.

Follow the dependency rules

The second rule that should not be abandoned is dependency rules, or the direction in which they depend. External services need to fit inside, not the other way around.

If you try to call an external API directly, this is a problem, and it is best to write an adapter before the problem occurs.

Store app design

Having said the theory, we can start to practice, let’s actually design a store application.

The store will sell different kinds of cookies, and users can choose the cookies they want to buy and pay for them through a three-party payment service.

Users can see all cookies on the home page, but they can only buy cookies after logging in. Click the login button to jump to the login page.

Once logged in, users can add cookies to their shopping cart.

After adding the cookies to the cart, the user can pay for them. After payment, the shopping cart emptens and generates a new order.

First, let’s define and layer entities, use cases, and functionality.

Design field

The most important aspect of programming is domain design, which represents the transformation of entities to data.

Areas of the store may include:

  • Data types for each entity: users, cookies, shopping carts, and orders;
  • If you’re doing it in OOP, design factories and classes that generate entities as well.
  • Data conversion function.

The transformation methods in the domain should depend only on the rules of the domain and not on anything else. For example, the method should look like this:

  • Method of calculating the total price
  • A way to detect user tastes
  • Method of checking whether an item is in the shopping cart

Design application layer

The application layer contains use cases, and a use contains an actor, an action, and a result.

In the store app, we can make distinctions like this:

  • A product purchase scenario;
  • Payment, call the third-party payment system;
  • Interaction with products and orders: update, query;
  • Access different pages based on the role.

We typically describe use cases in terms of subject areas, such as “purchase”, which includes the following steps:

  • Query items from shopping cart and create new orders;
  • Create payment orders;
  • Notify users when payment fails;
  • Pay successfully, empty cart, display order.

The use case method is the code that describes the scenario.

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

Design adapter layer

In the adapter layer, we declare adapters for external services. The adapter can accommodate a variety of incompatible external services for our system.

On the front end, the adapter is typically a UI framework and an API request module to the back end. For example, in our store application:

  • User interface;
  • API request module;
  • Adapter for local storage;
  • The API returns to the adapter at the application layer.

Compare MVC architecture

Sometimes it’s hard to tell which layer some data belongs to, so here’s a quick comparison with the MVC architecture:

  • Models are generally domain entities
  • Controllers are typically associated with the transformation or application layer
  • View is the driver adapter

The concepts are very similar, though not identical in detail.

Implementation details – domain

Once we have identified which entities we need, we can begin to define their behavior. Here is the directory structure for our project:

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/
Copy the code

Domains are defined in the Domain directory, the application layer is defined in the Application directory, and adapters are defined in the Service directory. Finally, we’ll discuss whether there are alternatives to directory structures.

Creating domain entities

We have four entities in the domain:

  • Product (product)
  • User (user)
  • Order = order
  • Cart

The most important of these is user. In the reply, we store the user information, so we design a user type in the domain separately. The user type includes the following data:

// domain/user.ts

export type UserName = string;
export type User = {
  id: UniqueId;
  name: UserName;
  email: Email;
  preferences: Ingredient[];
  allergies: Ingredient[];
};
Copy the code

The user can put cookies in the cart, and we also add types to the cart and cookies.

// domain/product.ts

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


// domain/cart.ts

import { Product } from "./product";

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

Once the payment is successful, a new order will be created and we will add an order entity type.

/ / domain/order. Ts - ConardLi

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

export type Order = {
  user: UniqueId;
  cart: Cart;
  created: DateTimeString;
  status: OrderStatus;
  total: PriceCents;
};
Copy the code

Understand the relationships between entities

The advantage of designing entity types in this way is that we can check that their diagrams are realistic:

We can check the following points:

  • Whether the participant is a user
  • Is there enough information in the order
  • Whether some entities need to be extended
  • Is there enough scalability in the future

In addition, at this stage, types can help identify compatibility between entities and errors in the direction of invocation.

If everything goes as we expect, we can start the design domain transition.

Creating a data transformation

All sorts of things can happen with these types of data that we’ve just designed. We can add items to the cart, empty the cart, update items and user names, and so on. Let’s create the corresponding functions for each data conversion:

For example, to determine whether a user likes or dislikes a taste, we can create two functions:

// 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);
}
Copy the code

Add the item to the cart and check if the item is in the cart:

/ / domain/cart. Ts - ConardLi

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);
}
Copy the code

Here’s how to calculate the total price (we can design more features if needed, such as discounts, coupons, etc.) :

// domain/product.ts

export function totalPrice(products: Product[]) :PriceCents {
  return products.reduce((total, { price }) = > total + price, 0);
}
Copy the code

Create a new order and associate it with the user and his shopping 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),
  };
}
Copy the code

Detailed design – Shared kernel

You may have noticed some of the types we use when describing domain types. Such as Email, UniqueId, or DateTimeString. These are actually type aliases:

// shared-kernel.d.ts

type Email = string;
type UniqueId = string;
type DateTimeString = string;
type PriceCents = number;
Copy the code

I’m using DateTimeString instead of string to make it clear what this string is for. The more realistic these types are, the easier it is to troubleshoot problems.

These types are defined in the shared-kernel.d.ts file. Shared kernels refer to code and data on which dependency does not increase coupling between modules.

In practice, shared kernels can be explained as follows: We use TypeScript and use its standard type library, but we don’t treat them as dependencies. This is because the modules that use them have no effect on each other and can remain decoupled.

Not all code can be considered a shared kernel, and the main principle is that such code must be compatible with the system everywhere. If one part of a program is written in TypeScript and another part is written in another language, the shared core can only contain parts that work in both languages.

In our case, the entire application is written in TypeScript, so the aliases for the built-in types are perfectly acceptable as part of the shared kernel. This globally available type does not increase coupling between modules and can be used in any part of the program.

Implementation details – application layer

Now that we’ve finished designing the domain, we’re ready to design the application layer.

This layer will contain specific use case designs, such as a use case that is the complete process of adding an item to the cart and paying for it.

Use cases involve interactions between applications and external services, which are side effects. We all know it’s easier to call or debug methods that have no side effects, so most domain functions are implemented as pure functions.

To combine pure functions without side effects with interactions with side effects, we can use the application layer as an impure context with side effects.

Non-context pure data conversion

An impure context and pure data transformation with side effects is the way code is organized:

  • First perform a side effect to get some data;
  • Then perform pure functions on the data for data processing;
  • Finally, a side effect is performed, storing or passing the result.

For example, the “put an item in a shopping cart” use case:

  • First, the state of the shopping cart is obtained from the database.
  • Then call the shopping cart update function to pass in the information of the item to be added;
  • Finally, the updated shopping cart is saved to the database.

The process is like a sandwich: side effects, pure functions, side effects. All the major logical processing is in calling pure functions for data transformation, and all communication with the outside world is isolated in an imperative shell.

Design the use case

We chose the checkout scenario for our use case design, which is more representative because it is asynchronous and interacts with many third-party services.

We can think about what we want to express through the whole use case. There are some cookies in the user’s shopping cart, and when the user clicks the buy button:

  • To create a new order;
  • Payment in the third-party payment system;
  • Notify the user if payment fails;
  • If the payment is successful, save the order on the server;
  • Save order data in local storage and display it on the page;

When we design the function, we take both the user and the shopping cart as parameters and let the method complete the process.

type OrderProducts = (user: User, cart: Cart) = > Promise<void>;
Copy the code

Of course, ideally, a use case should not take two separate arguments, but a wrapped object, which we’ll do for simplicity.

Write interfaces to the application layer

Let’s take a closer look at the steps of the use case: the order creation itself is a domain function, and everything else we call the external service.

It is important to remember that external approaches always need to fit our needs. So, at the application layer, we not only describe the use cases themselves, but also define the means of communication — ports — that invoke external services.

Consider the services we might use:

  • Third-party payment service;
  • Notifying users of events and error services;
  • A service that saves data to local storage.

Note that we are talking about the interfaces of these services, not their concrete implementations. At this stage, it is important for us to describe the necessary behavior, because this is the behavior we rely on at the application layer when describing the scenario.

How to do this is not the point now; we can worry about which external services to call at the end so that the code is as low coupled as possible.

Also note that we split the interface by function. Everything related to payments is in the same module, everything related to storage is in another module. This makes it easier to make sure that the functionality of a third-party service does not get mixed up.

Payment System Interface

Our store app is just a small Demo, so the payment system will be simple. It will have a tryPay method that will accept the amount to be paid and then return a Boolean value indicating the result of the payment.

/ / application/ports. Ts - ConardLi

export interface PaymentService {
  tryPay(amount: PriceCents): Promise<boolean>;
}
Copy the code

Generally, payment is processed on the server side. But we’re just going to do a quick demo, so we’re going to do it right on the front end. Instead of talking directly to the payment system, we’ll call some simple apis later.

Notification service interface

If something goes wrong, we have to notify the user.

We can notify users in many different ways. Such as using the UI, sending emails, and even making the user’s phone vibrate.

In general, notification services are best abstracted as well, so we don’t have to worry about implementation right now.

Send a notification to the user:

// application/ports.ts

export interface NotificationService {
  notify(message: string): void;
}
Copy the code

Local storage interface

We store new orders in our local repository.

This store can be anything: Redux, MobX, any store. Repositories can be split across different entities, or the data for the entire application can be maintained together. But it doesn’t matter right now, because these are implementation details.

My usual practice is to create a separate storage interface for each entity: one for user data, one for shopping cart, and one for orders:

/ / application/ports. Ts - ConardLi

export interface OrdersStorageService {
  orders: Order[];
  updateOrders(orders: Order[]): void;
}
Copy the code

Use case method

Let’s see if we can build a use case using the existing domain method and the interface we just built. The script will contain the following steps:

  • Validation data;
  • Create an order;
  • Payment order;
  • Notification problem;
  • Save the results.

First, we declare the module of the service we want to invoke. TypeScript tells us that there is no implementation of the interface, so forget about it.

/ / application/orderProducts. Ts - ConardLi

const payment: PaymentService = {};
const notifier: NotificationService = {};
const orderStorage: OrdersStorageService = {};
Copy the code

Now we can use these modules as if they were real services. We can access their fields and call their methods. This is very useful when translating use cases into code.

Now we create a method called orderProducts to create an order:

/ / application/orderProducts. Ts - ConardLi
/ /...

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

Here, we think of interfaces as conventions for behavior. That is, the module example actually does what we expect:

/ / application/orderProducts. Ts - ConardLi
/ /...

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();
}
Copy the code

Note that the use case does not directly invoke third-party services. It depends on the behavior described in the interface, so as long as the interface stays the same, we don’t need to care which module implements it and how, such a module is replaceable.

Implementation details – adapter layer

Now that we’ve “translated” our use cases into TypeScript, let’s check to see if reality meets our needs.

Usually not, so we call the third-party service by encapsulating the adapter.

Add UI and use cases

First of all, the first adapter is a UI framework. It connects the browser API to our application. In this scenario of order creation, it is the “checkout” button and click event handler that invokes the functionality of the specific use case.

/ / UI/components/Buy. Benchmark - ConardLi

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:
    awaitorderProducts(user! , cart); setLoading(false);
  }

  return (
    <section>
      <h2>Checkout</h2>
      <form onSubmit={handleSubmit}>{/ *... * /}</form>
    </section>
  );
}
Copy the code

We can encapsulate the use case with a Hook. We suggest encapsulating all services into it and returning the use case method:

/ / application/orderProducts. Ts - ConardLi

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

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

  return { orderProducts };
}
Copy the code

We use hook as a dependency injection. First we use the hooks useNotifier, usePayment, useOrdersStorage to get the instance of the service. Then we use the function useOrderProducts to create a closure. Allow them to be called in the orderProducts function.

Also note that the use case functions are separated from the rest of the code, which is more test-friendly.

Payment service Implementation

The use case uses the PaymentService interface, which we’ll implement first.

For the payment operation, we still use a fake API. Again, we don’t need to write the entire service now. We can implement it later. The most important thing now is to implement the specified behavior:

/ / services/paymentAdapter. Ts - ConardLi

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

export function usePayment() :PaymentService {
  return {
    tryPay(amount: PriceCents) {
      return fakeApi(true); }}; }Copy the code

FakeApi is a function that fires a timeout after 450 milliseconds, simulating a delayed response from the server, and returns the parameters we passed in.

/ / services/API. Ts - ConardLi

export function fakeApi<TResponse> (response: TResponse) :Promise<TResponse> {
  return new Promise((res) = > setTimeout(() = > res(response), 450));
}
Copy the code

Notification Service Implementation

We’ll simply use Alert for notifications because the code is decoupled and we can rewrite the service later.

/ / services/notificationAdapter. Ts - ConardLi

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

export function useNotifier() :NotificationService {
  return {
    notify: (message: string) = > window.alert(message),
  };
}
Copy the code

Local storage implementation

We implement local storage using react. Context and Hooks.

We create a new context, pass it to the provider, and export it so that other modules can use it via Hooks.

/ / store the TSX - ConardLi

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>
  );
};
Copy the code

We can implement a Hook for each function point. So that we don’t break the service interface and the storage, at least from the interface point of view they are separate.

// services/storageAdapter.ts

export function useOrdersStorage() :OrdersStorageService {
  return useStore();
}
Copy the code

In addition, this approach allows us to customize additional optimizations for each store: creating selectors, caches, and so on.

Validation data flow chart

Now let’s verify how the user communicates with the application.

The user interacts with the UI layer, but the UI can only access the service interface through ports. In other words, we can always replace the UI.

Use cases are handled at the application layer and tell us exactly what external services are required. All major logic and data is encapsulated in the realm.

All external services are hidden in the infrastructure and follow our specifications. If we need to change the service that sends the message, we only need to change the adapter that sends the message.

This approach makes code easier to replace, easier to test, and more extensible to adapt to changing needs.

What can be improved

This is enough to get you started and get a feel for a clean architecture, but I want to point out some of the things I did above to make the example simpler.

Read on to understand what a clean architecture with “no cutting corners” looks like.

Use objects instead of numbers to represent prices

You may have noticed that I use a number to describe the price, which is not a good habit.

// shared-kernel.d.ts

type PriceCents = number;
Copy the code

Numbers can only represent quantity, not money, and the price of money is meaningless without it. Ideally, a price should be an object with two fields: value and currency.

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

type Price = {
  value: AmountCents;
  currency: Currency;
};
Copy the code

This solves the problem of storing the currency and saves a lot of effort in storing and processing the currency. I didn’t do that in the example to keep it as simple as possible. In real life, the structural definition of price would be much closer to this.

In addition, it is worth mentioning the unit of price, such as the dollar’s smallest unit is the cent. Displaying prices this way allows me to avoid worrying about floating-point calculations.

Break down code by function, not by layer

Code can be broken down into folders “by feature” rather than “by layer”, with a feature being part of the pie chart below.

This structure is clearer because it allows you to deploy different function points separately:

Note the use across components

If we are talking about splitting the system into components, we have to consider cross-component code usage. Let’s look at the code that creates the order:

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),
  };
}
Copy the code

This function uses the totalPrice method imported from another Product module. There is nothing wrong with this in itself, but if we want to consider breaking up the code into separate functions, we cannot directly access the code of other modules.

Use ts-brand instead of type aliases

I used type aliases in my shared kernel writing. They are easy to implement, but TypeScript has no mechanism to monitor and enforce them.

This doesn’t seem to be a problem either: it doesn’t matter if you replace DateTimeString with string, and the code will still compile. However, this makes the code brittle and unreadable, because you can use arbitrary strings and the chances of errors increase.

There is a way to let the TypeScript understand we want to have a specific type – ts – brand (https://github.com/kourge/ts-brand). It keeps track of exactly how types are used, but makes the code a little more complicated.

Be aware of possible dependencies in the domain

The next problem is that we created a date in the createOrder function’s domain:

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

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

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

    status: "new".total: totalPrice(products),
  };
}
Copy the code

A function like new Date().toisostring () may be called many times, and we can wrap it in a hleper:

/ / lib/datetime. Ts - ConardLi

export function currentDatetime() :DateTimeString {
  return new Date().toISOString();
}
Copy the code

Then call 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),
  };
}
Copy the code

But the rule of the realm is not to rely on anything else, so createOrder is best if all data is passed in from the outside, and the date can be the last argument:

/ / domain/order. Ts - ConardLi

export function createOrder(user: User, cart: Cart, created: DateTimeString) :Order {
  return {
    user: user.id,
    products,
    created,
    status: "new".total: totalPrice(products),
  };
}
Copy the code

This way we don’t break the dependency rule by relying on third-party libraries even on the creation date:

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);
}
Copy the code

This keeps the domain independent and makes testing easier.

In the previous example, I didn’t do this for two reasons: It distracts from our focus, and IF it only uses features of the language itself, I don’t see any problem with relying on your own Helper. Such helpers can even be considered shared kernels because they only reduce code duplication.

Note the relationship between the shopping cart and the order

In this small example, Order would contain Cart, because the Cart only represents the list of products:

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

export type Order = {
  user: UniqueId;
  cart: Cart;
  created: DateTimeString;
  status: OrderStatus;
  total: PriceCents;
};
Copy the code

This can cause problems if the shopping cart has other attributes that are not associated with the order, so it makes more sense to just use ProductList:

type ProductList = Product[];

type Cart = {
  products: ProductList;
};

type Order = {
  user: UniqueId;
  products: ProductList;
  created: DateTimeString;
  status: OrderStatus;
  total: PriceCents;
};
Copy the code

Make use cases easier to test

Use cases also have a lot to discuss. For example, the orderProducts function is difficult to test independently of React, which is not good. Ideally, testing should not cost too much.

We implemented the use case using Hooks:

/ / application/orderProducts. Ts - ConardLi

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 };
}
Copy the code

In the implementation of the specification, use case methods can be placed outside of Hooks, and the service passes in use cases either as parameters or using dependency injection:

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

  // ...
}
Copy the code

The Hooks code can then be treated as an adapter, leaving only the use cases in the application layer. The orderProdeucts method is easy to test.

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

  return (user: User, cart: Cart) = >
    orderProducts(user, cart, {
      notifier,
      payment,
      orderStorage,
    });
}
Copy the code

Configure automatic dependency injection

At the application layer, we inject dependencies into services manually:

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 };
}
Copy the code

Better yet, dependency injection can be done automatically. Having already implemented the simplest version of injection with the last parameter, you can further configure automatic dependency injection.

In this particular application, I don’t think it makes much sense to set up dependency injection. It distracts us and makes the code too complex. In the case of React and hooks, we can use them as “containers” that return implementations of specified interfaces. Yes, it’s still manual, but it doesn’t increase the barrier to entry, and it’s faster to read for new developers.

Things can be more complicated in real projects

The examples in this article are streamlined and the requirements are simple. Obviously, our actual development is much more complicated than this example. So I also want to talk about common problems that can arise when using clean architectures in real development.

Branch business logic

The most important problem is that we didn’t go deep enough into the actual scenarios of the requirements. Imagine a store with a product, a discounted product, and a product that has been written off. How do we accurately describe these entities?

Should there be an extensible “base” entity? How exactly should this entity expand? Should there be additional fields? Should these entities be mutually exclusive?

There may be too many questions and too many answers, and we can’t consider all of them if it’s hypothetical.

The solution depends on the situation, and I can only recommend a few of my experiences.

Inheritance is not recommended, even if it looks “extensible.”

Copy-and-paste code isn’t always bad, and sometimes it can be even more useful. Create two nearly identical entities and observe how they behave in reality. At some point, their behavior may differ greatly, and sometimes it may differ by only one or two fields. Merging two very similar entities is much easier than writing a lot of checks.

If you must expand something.

Remember covariant, contravariant, and invariant so you don’t have to do something unexpected.

Choosing between different entities and extensibility, it is recommended to use concepts like block and modifier in BEM to help you think, if I consider it in the context of BEM, it can help me determine if I have a separate entity or “modifier extension” of the code.

The BEM – Block Element Modfier (Block Element editor) is a useful way to create front-end components and front-end code that can be reused.

Interdependent use cases

The second problem is use-case dependent, with events from one use case triggering another.

The only way I know and it helps me deal with this problem is to break the use cases down into smaller atomic use cases. They will be easier to put together.

Often, this problem occurs as a result of another big problem in programming. That’s entity composition.

The last

In this article, we introduced the “clean architecture” of the front end.

This is not a gold standard, but rather a collection of accumulated experience across many projects, specifications, and languages.

I find it a very convenient solution to help you decouple your code. Keep layers, modules, and services as separate as possible. Not only can you publish and deploy independently, but it also makes it easier to migrate from one project to another.

What is your ideal front-end architecture?

The original article “# Clean Architecture for the Front End” was first published on code Secret Garden. Welcome to follow it.

If this article has helped you, please leave a like. Thanks ~