Architecture of a production enterprise frontend application
Many spears have been broken against the impregnable walls of the convenient architecture of a growing application. This is, in principle, quite predictable. We all have our own background, development experience and way of working with abstractions. What is clear and understandable for one, may be chaotic and mixed up for another. I want to talk about the choice that was made a year ago and which has shown itself to be excellent over these months.
Criteria
The following indicators became the criteria for evaluating the solution:
ease of understanding the structure of the application;
as a result, ease of employee onboarding;
convenience of parallel development in a team;
ease of choosing the location of the code;
ease of making changes;
ease of localization of possible bugs;
Tech stack
TypeScript
Vue 3
Vue-Route
Pinia
Start
I think we are all very familiar with the so-called basic structure of a Vue application:
/src
|-- /api
|-- /assets
|-- /components
|-- /composables
|-- /config
|-- /layout
|-- /plugins
|-- /router
|-- /store
|-- /tests
|-- /utils
|-- /view
|-- App.vue
|-- main.ts
As long as the application consists of five to seven simple pages, this approach works quite well. The parts are usually quite conscious. And there is usually no need for several developers, the application is small. When the application grows, new members of the development team appear, with such a structure, inevitable problems begin. The complexity of awareness and interaction increases almost exponentially.
To solve this problem, people started looking for other approaches to the situation. Modular architecture appeared and, as if in its continuation and development, the FSD approach. It was FSD that I turned to from the very beginning. Everything revolved around dividing the code into separate independent pieces, and the easily perceptible aroma of DDD was in the air. Everything was fresh and promising.
Feature-Sliced Design
I think everyone has heard about FSD. But just in case, I'll give you a quote from official website this methodology:
Feature-Sliced Design (FSD) is an architectural methodology for designing front-end applications. Simply put, it is a set of rules and conventions for organizing code. The main goal of this methodology is to make the project more understandable and stable in the face of constantly changing business requirements.
In addition to a set of rules, FSD is also a whole toolkit. We have a linter to check the architecture of your project, folder generators via CLI or IDE, and a rich library of examples.
Cool!
I went through the forums and documentation. I started to stretch it all onto the globe of a specific project and came across what, it seems, 100% (??) of teams come across – what is a feature? How is a feature different from an entity? And what is this specific piece of code? Where should I put it? The fires of holy wars were burning on the forums. Some said that everything was written in the documentation, others shouted that the definitions were incorrect. Still others argued about what could be put where and in what form.
In the end, it felt like FSD came to us like a Trojan horse to the gates of Troy, and behind the pretty wrapper was a potential portal to hell and endless arguments within the team. That definitely didn't suit me.
Solution
In the end, I returned in my thoughts to the usual modular approach. Which, although it did not have a beautiful website describing the methodology, in general allowed to structure the project well, acting in the usual paradigms of the front.
Root structure of the project
At the moment the project has the following structure:
/src
|-- /app
| |-- /assets
| |-- /config
| |-- /routes
| |-- /ui
| | |-- App.vue
| |-- main.ts
|-- /modules
| |-- /module1
| |-- /module2
|-- /plugins
|-- /test-utils
Modules
The structure of the module was chosen as follows:
/module
|-- /api
|-- /assets
|-- /composables
|-- /directives
|-- /helpers
|-- /routes
|-- /store
|-- /types
|-- /ui
| |-- /components
| | |-- /module-page-components
| | | |-- /body
| | | |-- /dialogs
| | | |-- /footer
| | | |-- /header
| | |-- /specific-component
| | | |-- SpecificComponent.vue
| | | |-- SpecificComponent.test.ts
| | | |-- types.ts
| | | |-- index.ts
| |-- /dialogs
| |-- /layouts
All folders are optional in their presence. If a module does not need, say, directives, this folder will not be there.
/api – methods for working with the backend, everything is broken into separate files
/assets – any statics related to a specific module. In our case, a maximum of a couple of images
/composables – all sorts of hooks related to the module
/directives — directives related to this module
/helpers – various helper utilities, all sorts of mappers, etc.
/routes – route settings (we'll get back to routes later)
/store – module stores
/types – folder responsible for module typing
/ui – the folder where the vue components are located (the ui component of the application)
Modules are allocated based on the structure of application pages, which in turn is based on the structure of business entities. Perhaps we were lucky here that this coincided. On the other hand, it is not a problem at all to allocate a module structurally and place it in a particular section on the site. The organization of routing allows you to do this without any problems.
Routing and file organization for it
The UI design of our application implied the presence of a header block common to the entire application, as well as fairly typical pages, each of which could be divided into a header, body, and footer. If you look at the directory structure in module-page-components, this division is reflected in it. Each module can have its own page layout, it will be located in the corresponding folder in /ui. But it can also use a common layout for all, which will be located in the shared/ui/layouts module. These layouts work with named routes. Thus, moving from page to page, it will be enough to simply indicate which components correspond to the names in the layout.
Example of route setup:
modules/shared/ui/layouts/BaseLayout.vue
<template>
<div class="base-layout">
<AppHeader />
<RouterView
name="contentHeader"
class="base-layout__header"
/>
<RouterView
name="contentBody"
class="base-layout__body"
/>
<RouterView
name="contentFooter"
class="base-layout__footer"
/>
</div>
</template>
modules/module1/routes/index.ts
export default [
{
path: paths.TARIFFS,
alias: paths.MAIN,
component: BaseLayout,
children: [
{
path: '',
name: names.TARIFFS,
components: {
contentHeader: () => import('@tariff/ui/components/tariffs/header'),
contentBody: () => import('@tariff/ui/components/tariffs/table'),
contentFooter: () => import('@tariff/ui/components/tariffs/footer'),
},
},
],
},
];
All these route settings are collected into a common route in the /app folder and passed to the application constructor on initial launch:
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
...tariffRoutes,
],
});
Interaction of components with each other
Since the common point of collection of components is the layout component, which can be common for completely different pages, it was necessary to decide how to organize this horizontal interaction. There were several options, but in the end we settled on using pinia stores, which are created for each page. The problem of accumulation of these stores in memory was solved by unmounting the store when switching to another page.
There is an opinion that using stores is almost an anti-pattern. I fully admit that there is a more elegant solution without using stores, but so far we have not found it. The pinia store perfectly solves our problems of sharing data between components located at the same hierarchical level. If you are ready to share suggestions on this matter, welcome to the comments.
Growth points
As expected, the /shared module is gradually becoming the largest and worst structured element. The code has to be split into additional categories within each folder. Overall, this does not affect the structure's comprehensibility yet, but we have potential problems here.
Conclusion
The chosen approach to code organization has proven itself to be excellent over a year of use. The application grew, new modules were added, the structure of the modules changed, special page designs appeared that did not fit into the overall concept adopted initially. Undeclared functionality was added. In general, during this year, what usually happens in any other developing project happened. All tasks were solved quite routinely. The project deadlines were not missed, the quality of the product was maintained at an acceptable level.