Frontend development based on FSD

Hello! This is Vadim, a frontend developer at Perspective Monitoring. Today I want to share our typical frontend application structure, talk about the architectural methodology we use, as well as the main problems we encountered and how to solve them.

We have a small demo project – hba-demo-todo-appavailable for anyone interested. This is a minimal configuration, far from real projects, but I will use it to demonstrate the code.

Tech stack

For frontend development we use Vue.js third version. Recently, we have been using as our main architectural methodology Feature Sliced ​​Design (FSD). Based Vuetify 3 developed our own UI-kitexpanding functionality Vuetify for the specific requirements of our projects. We also use:

  • Vue-router for routing;

  • Pinia to manage the state;

  • Vee-validate 4 for form validation;

  • Axios to send requests to the server;

  • vue-native-websocket-vue3 for integration with WebSocket.

To organize the logic of components, we mainly follow the approach Composition API. For assembling projects we use Vitebut the demo project is currently being built using Webpack. TypeScript We use it optionally, depending on the project requirements.

Transition to FSD

Even before the introduction of FSD, the structure of our frontend applications was based on a certain set of principles formed by our team. With each iteration of refactoring any of the projects, we improved the structure and spread our “best practices” to other projects. Despite this, our typical structure still had a number of shortcomings, the main one being the lack of a clear distribution of responsibilities between components. The code was divided into components at the discretion of the developers, based on their understanding, and not on any general rules. In some situations, this led to chaos, especially on large projects involving several frontend developers of different levels.

After long discussions, having collected all the pros and cons, we finally decided to transfer our main projects to the FSD methodology. The main reason was its clear division of code into functional areas.

Looking ahead, I would like to note that with the advent of FSD, our projects acquired a clearer and more rigorous structure, but, following this, the entry threshold for new developers increased significantly. This happened, for the most part, due to the fact that correct and fruitful work with the methodology requires a deep understanding of its principles.

Project structure

According to the FSD methodology, our demo application is divided into layers, layers consist of slices, and slices in turn consist of segments.

Project structure

Project structure

Our demo application uses a standard set of layers:

  • app – application configuration, its global settings and providers used (for example, plugins responsible for routing, state storage, etc.);

  • entities – business entities and their interaction, data models and services for working with this data;

  • features – application functions responsible for user interaction with business entities;

  • pages – application pages that combine entity components, widgets and features;

  • shared – a common layer for components, utilities and other code that is not directly related to the specifics of the business, but is accessible on any other layer;

  • widgets – components that combine the logic of features and business entities.

Layer entities usually arouses the greatest interest, as it represents the logic of the business entities. Let's consider the contents of this layer in more detail using the example of a slice note.

Structure of the note slice

Structure of the note slice

In our note application, this is a notebook in which each user can make their own notes (todo), notebooks can be edited and deleted. The note slice is divided into the following segments:

  • api – interaction with external APIs;

  • model – abstractions describing the structure and behavior of a business entity;

  • store – defining a repository (in this case Pinia) for a specific entity;

  • ui – components of the user interface of a specific entity.

The index.js file is the public API for the slice, it exports only those slice components that should be available for use in other parts of the application. It also simplifies the process of importing the necessary components.

This slice-and-dice breakdown helps to structure the code associated with a business entity and make it more understandable.

Interacting with the server

HTTP and WebSocket protocols are used to organize interaction between the client and the server. After the initial application download and socket connection establishment, the server sends the client a portion of the data necessary for rendering some pages of the application interface. The remaining data is requested as needed by accessing the server API. Creation, updating, and deletion of data through the client interface occurs by sending HTTP requests to the server, in turn, the server notifies the client of all changes in business entity data via socket events.

Processing incoming data

To process and save data about business entities received via a socket channel, a special plugin for Pinia was developed, called wsRelated. The main task of the plugin is to automatically update the storage depending on the type of incoming data. In the demo project, the plugin is located at “src/shared/utils/wsRelated”.

To start working with the plugin, you need to connect it to the current instance of Pinia:

import { createPinia } from 'pinia'
import { wsRelatedPlugin } from '@/shared/utils/wsRelated'
 
const pinia = createPinia()
pinia.use(wsRelatedPlugin)
 
export { pinia }

Next, in order to use the plugin with a specific Pinia storage, when defining this storage, you must pass the appropriate parameters to the defineStore method. Let's look at an example of defining a storage for the todo entity:

export const useTodoStore = defineStore({
  id: 'todo',
  state: () => ({
    todos: [],
  }),
  wsRelated: {
    todo: 'todos',
  },
})

In this example, the wsRelated object configuration specifies that messages of type todo should update the todos array in the todo store state. This allows WebSocket messages to be processed automatically and store state updated without having to write additional code for each message type.

Main plugin code:

export const wsRelatedPlugin = ({ options, store }) => {
  if (options.wsRelated) {
    return Object.keys(options.wsRelated).reduce((wsActions, itemKey) => {
      const valueKey = options.wsRelated[itemKey]
      wsActions[`SOCKET_${valueKey}`] = getSetDef(store, valueKey)
      wsActions[`SOCKET_${itemKey}_added`] = getAddedDef(store, valueKey)
      wsActions[`SOCKET_${itemKey}_updated`] = getUpdatedDef(store, valueKey)
      wsActions[`SOCKET_${itemKey}_deleted`] = getDeletedDef(store, itemKey, valueKey)
 
      return wsActions
    }, {})
  }
}

The plugin takes an object as input and extracts values ​​from it by two keys – options and store. If options has the wsRelated property, the plugin creates a set of handlers for several types of events (added, updated, deleted) and returns them as an object. These handlers will be automatically called when receiving the corresponding messages via the WebSocket channel.

The plugin has a number of auxiliary methods:

  • getSetDef – creates a handler that sets a value in the store based on data received via WebSocket;

  • getAddedDef – creates a handler that adds a new array element to the storage if it is not there yet;

  • getUpdatedDef – creates a handler that updates an existing array element in the storage;

  • getDeletedDef – creates a handler that removes an array element from storage by its identifier.

You can study the code of these methods in more detail in the demo project itself.

Features of FSD and difficulties in implementation

It is worth noting once again that in order to use the FSD methodology correctly, developers need a deep understanding of its principles. If the project has already been written and has its own characteristics, then before starting to work with the methodology, it is important for the developer to understand whether its rules contradict the specific characteristics of the project. At the first stages of implementation, this can slow down and complicate the process. This is where the first disadvantage of FSD comes from – a high entry threshold.

Another important and quite common problem is a certain “blurring” of the boundaries between features and widgets. Often, when working with the methodology, developers get confused about where exactly a particular component should be placed. On the one hand, it seems to be a feature, but on the other, a widget suggests itself. I myself sometimes find it difficult to immediately determine which layer it would be more correct to assign a component to. In our team, such controversial issues are usually resolved by analyzing the recommendations and rules described in the documentation, as well as discussing with colleagues and making collective decisions. Understanding comes with experience, but in the development of complex projects, even experienced developers have questions about the distribution into features and widgets.

The next problem we encountered when switching to FSD is the rigid isolation of slices within a layer. We encountered this limitation mostly within the entities layer. It so happened that in real projects our business entities have rigid coupling, which contradicts the rules of the methodology. Let's consider the following example:

import { useNotesStore } from '@/entities/note'
 
export class Todo {
  constructor(data) {
    this.id = data.id
    this.noteId = data.noteId
    this.text = data.text
    this.done = data.done
  }
 
  get note() {
    if (!this.noteId) return null
 
    const { notesData } = useNotesStore()
 
    return notesData.find(item => item.id === this.noteId) || null
  }
}

The todo entity is described by a model that includes a note getter that returns a notepad instance for a specific todo. In the context of FSD, this example has a gross error, namely: accessing data from another slice lying at the same level. This problem is quite common, since in practice it is not always possible to untie one entity from another.

How to solve the problem without violating the principles of the methodology? The solution depends on the specific situation – you can highlight separate layers, move the code to a higher level or even duplicate pieces of code, but we decided to go our own way and wrote another plugin for Pinia – dispatchPlugin. The plugin allows you to centrally manage method calls and provides access to getters of various Pinia storages. Thanks to this plugin, we bypass direct imports between entities inside the entities layer, but despite this, the connection between entities remains, just at a different level.

In our demo application, the plugin is located at “src/shared/utils/dispatch”. To start using it, we connect the plugin to the current instance of Pinia:

import { createPinia } from 'pinia'
import { dispatchPlugin } from '@/shared/utils/dispatch'
 
const pinia = createPinia()
pinia.use(dispatchPlugin)
 
export { pinia }
 
Основной код плагина:
 
const _stores = {}
 
export const dispatchPlugin = ({ store }) => {
  _stores[store.$id] = store
}

The dispatchPlugin function is called when each Pinia store is initialized and adds a reference to it to the _stores object under a key corresponding to its ID. The plugin has two helper functions – dispatch And get.

Function dispatch allows you to call a method (action) on any of the available Pinia stores. It takes the name of the store, the name of the method, and additional data – arguments for the call. Then it calls the corresponding store method with the arguments passed to it.

Function get allows you to get the values ​​of getters of a specific Pinia storage. It takes the storage name and the name of the getter and returns the value of that getter.

Now let's look at an example of implementing the todo model again, but using the dispatchPlugin plugin:

import { get } from '@/shared/utils/dispatch'
 
export class Todo {
  constructor(data) {
    this.id = data.id
    this.noteId = data.noteId
    this.text = data.text
    this.done = data.done
  }
 
  get note() {
    if (!this.noteId) return null
 
    const notesData = get('note', 'notesData')
 
    return notesData.find(item => item.id === this.noteId) || null
  }
}

Cross-imports between entities are no longer required, and interaction with the note entity data is now done through a middleware implemented using dispatchPlugin.

Thus, this plugin is a kind of “workaround” that simplifies the process of interaction of some entities with the storages of others without violating the restrictions imposed by the FSD methodology. This approach is not ideal, but it has the right to exist and solves the problem of the connectivity of business entities in the context of our projects.

Summarizing

In this article, I tried to talk about the “bricks” that our typical frontend application structure is built from. Having given a brief overview of the methodology, we also touched on its shortcomings. I hope that our experience of implementing FSD will help someone make the right choice. The project used for demonstration is a very simple example of working with FSD. When working with larger and more complex projects, our example also remains relevant, but other difficulties may arise that are not described in the article and are related to the specific features of the project itself. I would like to note that you should not blindly follow all the principles of the methodology, the main thing is to find a balance between the advantages of its implementation and the ease of development in the future. Thank you for your attention and welcome constructive criticism.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *