What is a scalable front-end architecture?

When it comes to software development, the two most common meanings of the word scalability relate to the performance and long-term maintainability of a code base. You can have both, but focusing on good maintainability will make it easier to tune performance without affecting other parts of the application. This is even more true in the foreground, where we have an important distinction from the backend: local state.

In this series of articles, we will talk about how to develop and maintain a scalable front-end application using real-life tested methods. Most of our examples will use React and Redux, but we will often compare to other tech stacks to show how you can achieve the same effect. Let's start talking about the architecture of this series, the most important part of your software.

What is Software Architecture?
What exactly is architecture? It might seem pretentious to say that architecture is the most important part of your software, but hear me out.

Architecture is how you make the various units of your software interact to highlight the most important decisions you have to make and defer lesser decisions and implementation details. Designing a software's architecture means separating the actual application from its supporting technologies. Your actual application has no knowledge of databases, AJAX requests, or GUIs; instead, it is composed of use cases and domain units that represent the concepts covered by your software, regardless of the roles in which the use cases are executed or where the data is persisted.

There's something else important to talk about about architecture: it doesn't imply file organization, nor how you name files and folders.


Layering in front-end development
One way to separate the important stuff from the less important stuff is to use layers, each with different and specific responsibilities. In a layer-based architecture, a common approach is to divide it into four layers: application, domain, infrastructure, and input. These four layers are better explained in another article "NodeJS and Good Practices". We recommend that you read the first part of the post about them before proceeding. You don't need to read the second part as it is specific to NodeJS.

The domain and application layers are not that different between front-end and back-end because they are technology-agnostic, but we cannot say the same for input and infrastructure layers. In web browsers, the input layer usually has only one role, the view, so we might even call it the view layer. Also, the frontend doesn't have access to a database or queuing engine, so we won't find those in our frontend infrastructure layer. What we'll find are abstractions that encapsulate AJAX requests, browser cookies, LocalStorage, and even units for interacting with WebSocket servers. The main difference is just something that is abstracted away, so you can even have frontend and backend repositories with the exact same interface but with different technologies underneath. Can you see how awesome a good abstraction can be?

It doesn't matter if you use React, Vue, Angular or something else to create your views. It is important to follow the rules of the input layer without any logic, thus delegating the input parameters to the next layer. There is one more important rule about front-end layer-based architectures: in order for the input/view layer to always be in sync with the local state, you should follow a unidirectional data flow. Does this term sound familiar? We can do this by adding a specialized fifth layer: state, also known as storage.


State Layer
While following a unidirectional data flow, we never mutate or mutate data received by a view directly in the view. Instead, we dispatch what we call "actions" from the view. It works like this: an action sends a message to the data source, the data source updates itself, and resubmits to the view with the new data. Note that there is never a direct path from view to store, so if two subviews use the same data, you can dispatch actions from either subview, which will cause both subviews to re-render with the new data. It looks like I'm talking about React and Redux specifically, but that's not the case; you can achieve the same result with almost any modern front-end framework or library, such as React + context API, Vue + Vuex, Angular + NGXS, or even Ember, Use the data down-action method (aka DDAU). You can even do it with jQuery, using its event system to send actions!

This layer is responsible for managing the local and ever-changing state of your frontend, such as data fetched from the backend, ephemeral data created on the frontend but not yet persisted, or transient information like the state of a request. In case you were wondering, this is the layer where the actions and handlers responsible for updating the state reside.

Although we often see business rules and use case definitions in the code base directly placed in the action, if you read the description of the other layers carefully, you will find that we already have a place to put our use cases and business rules, not the state layer. Does this mean our actions are now use cases? no! Yes. So how should we treat them?

Let's think about it...we said Action is not a use case, and we already have a layer to put our use cases. The view should dispatch the action, the action receives the information from the view, hands it to the use case, dispatches a new action according to the response, and finally updates the state-updates the view and closes the one-way data flow. Do these actions sound like controllers now? Aren't they the place to take parameters from the view, delegate to the use case, and respond based on the result of the use case? That's exactly how you should treat them. There should be no complex logic or direct AJAX calls here, as these are the responsibility of another layer. The state layer should only know how to manage local storage, nothing more.

There is another important factor at play. Since the state layer manages the local storage consumed by the view layer, you'll notice that the two are somewhat coupled. There will be some view-only data in the state layer, like a boolean flag indicating whether a request is still pending, so that the view can display a spinner, which is perfectly fine.

code:

import api from './infra/api'; // has no dependencies
import { validateUser } from './domain/user'; // has no dependencies
import makeUserRepository from './infra/user/userRepository';
import makeArticleRepository from './infra/article/articleRepository';
import makeCreateUser from './app/user/createUser';
import makeGetArticle from './app/article/getArticle';

const userRepository = makeUserRepository({
  api
});

const articleRepository = makeArticleRepository({
  api
});

const createUser = makeCreateUser({
  userRepository,
  validateUser
});

const getArticle = makeGetArticle({
  userRepository,
  articleRepository
});

export {
  createUser,
  getArticle
};
export default ({ validateUser, userRepository }) => async (userData) => {
  if(!validateUser(userData)) {
    throw new Error('Invalid user');
  }

  try {
    const user = await userRepository.add(userData);
    return user;
  } catch(error) {
    throw error;
  }
};
export default ({ api }) => ({
  async add(userData) {
    const user = await api.post('/users', userData);

    return user;
  }
});

You'll notice that the important parts, the use cases createUser etc., are instantiated at the end of the file, and are the only objects that are exported, as they will be injected into the Action. The rest of your code doesn't need to know how the repository was created and how it works. It doesn't really matter, it's just a technical detail.
It doesn't matter to the use case whether the repository sends an AJAX request or persists something in LocalStorage; it's not the use case's responsibility to know that. If you want to use LocalStorage while your API is still under development, and then switch to using online calls to the API, as long as the code that interacts with the API follows the same interface as the code that interacts with LocalStorage, you don't need to change your use case.

Even if you have dozens of use cases, repositories, services, etc., you can do the injection manually as described above. If building all the dependencies is too messy, you can use a dependency injection package, as long as it doesn't increase coupling.

A rule of thumb for testing if your DI package is good enough is to check that going from a manual approach to using the library doesn't require touching anything more than the container code. If so, then the package is too much trouble and you should choose a different package. If you really want to use a package, we recommend Awilix. It's fairly simple to use, and off-the-cuff, just touching the container file. There's a really good series on how to use it and why, written by the package's author. 

Guess you like

Origin blog.csdn.net/weixin_44786530/article/details/130194702