DEV Community

Cover image for My experience with FSD (Feature-Sliced Design) architecture.
Petr Tcoi
Petr Tcoi

Posted on

My experience with FSD (Feature-Sliced Design) architecture.

In this article, I want to share my experience of developing applications using the FSD (Feature-Sliced Design) approach. I won't go into detail about it here, as plenty of good resources are already available, primarily the official website.

I found this approach to be very convenient. It not only applies well to new projects that can be structured appropriately from the start but also to projects that someone else had previously worked on and left in a tangled state. By following simple rules step by step, it is possible to organize and make any codebase convenient for further development. This article will focus on these simple rules, which I couldn't find and had to develop through my own experience. I hope my experience will be useful to others.

The basic idea

The entire code is divided into layers:

  • app - the top-level of the application;
  • pages - individual pages or screens of the application;
  • widgets - interface blocks that make up a page. For example, a top menu, a shopping cart, etc. Ideally, a page should be as "thin" as possible and primarily consist of widgets, each of which operates independently of others;
  • features - a business feature that provides value to the user. For example, adding and removing items from a shopping cart, calculating the total amount and discounts;
  • entities - simplified, these are the project's data entities: products, users, blog posts, etc.;
  • shared - resources used by all other layers within the application. This can include utilities, interfaces, and configurations for third-party services (database connections, Twilio CLI, etc.).

Image description

Each element is self-contained and contains everything necessary for its operation: UI components, types, and interfaces, utilities, etc. Only what needs to be accessible from the outside, via a public API, is exported.

Hierarchy is important here. Elements can only import and use elements from lower-level layers. For example, an element in the features layer can use elements from the entities and shared layers, but it cannot utilize elements from its layer or higher layers. This rule ensures that the project maintains a clear and structured organization.

For instance, when making changes to the "Cart" widget, we don't have to worry about impacting the "Product List" widget or altering the logic of the feature responsible for calculating discount amounts. The changes will only affect the widget itself and the pages where it is located. Therefore, the lower the level we descend, the more global and impactful the changes become.

Now, let me describe the approach that helps me create projects with a convenient structure for development.

Step 1. Shared Layer: Third-Party Services

Configuring database connections, authentication, SMS sending services, and so on. For these purposes, I have created a separate folder called "shared/services."



shared
├── services
│     ├── pinata
│     ├── prisma
│     │     ├── config
│     │     │     ├── prisma.ts
│     │     ├── index.ts
│     ├── twilio


Enter fullscreen mode Exit fullscreen mode

You can also include the main types related to the operation of a REST API and similar functionalities.



shared
├── types
│     ├── api
│     ├── result


Enter fullscreen mode Exit fullscreen mode

Now that all the third-party services with which our application will interact are neatly organized in one place, we can move on to the second step.

Step 2. Defining Entities

This step also does not require any complex project assessments. Here, you define the business entities and the interfaces for interacting with them. The goal of this stage is to abstract the work with entities as much as possible for all the elements in the higher layers.

For example, let's consider the business entity "NFT" (Non-Fungible Token) in the context of using Prisma as an example.



entities
├── collection
├── drop
├── nft
│     ├── db
│     │     ├── dbGetNftBasic.ts
│     │     ├── dbGetNftBasicList.ts
│     │     ├── dbGetNftMint.ts
│     │     ├── ...
│     ├── selectors
│     ├── types
│     ├── ui
│     │     ├── BuyButton
│     ├── utils
│     ├── index.ts


Enter fullscreen mode Exit fullscreen mode
  • db - This is where all the database queries and interactions used in the application are defined. This allows for easier testing and enables query optimization if needed. selectors - These are selectors used for querying the database through Prisma.
  • types - These are types generated from the selectors. In principle, the selectors and types can be combined into a single folder if desired.
  • ui - This folder contains any shared UI components. In your case, it includes a button component that redirects visitors to the NFT purchase page. Placing the button component here makes sense because it is used in multiple features, and code sharing between features is prohibited since they are in the same feature layer.
  • utils - This folder contains utility functions.
  • index.ts - This file defines what will be exposed to the rest of the code from all the aforementioned folders.

Step 3. Widgets and Features

These two layers contain the business logic. I combined them into one step because the boundary between them is not always clear. Formally, the distinction should be as follows:

Feature: Represents a valuable action, such as user registration or product editing form.

Widget: Renders components, creating an isolated block that can be placed on pages.

In practice, there are many boundary situations. Therefore, by default, I recommend treating any element as a Widget and placing all related code in its folder. If a part of a widget is needed in another widget, it is extracted into a separate feature - it is moved to a lower layer to become available for all widgets.

The sequence of actions is as follows:

  1. After defining the connections with external services and business entities, start creating a widget in a separate folder, placing all the necessary code in it without trying to separate its parts into separate elements outside that folder.
  2. Ideally, the widget should be self-contained, only interacting with our entities, and freely placed on any page. All the code related to it will be located in one place and guaranteed not to impact other parts of the application.
  3. If, when adding other widgets, it becomes apparent that they need to use the code from our widget, for example, a form for entering credit card data and processing a payment request, that form is extracted as a separate element in the feature layer. Now different widgets can use it for their purposes, and the widget where it was originally located remains isolated as before.
  4. In case, for some reason, the functionality of sending a payment request from a card is needed in another feature, it can be moved to the lowest layer - shared. This way, it becomes something like this (assuming the authorizenet service handles card-related tasks):


shared
├── services
│     ├── authorizenet
│     │     ├── config
│     │     ├── utils
│     │     │     ├── chargeCreditCard.ts
│     │     ├── index.ts
│     ├── twilio


Enter fullscreen mode Exit fullscreen mode

Yes, the general rule is to try to place the code at the highest possible layer, which is within the widgets, and move it to lower layers only when necessary. This approach helps to maintain a clear separation of concerns and isolate the functionality within its respective layer. The goal is to strike a balance between encapsulation and reusability, ensuring that each layer has its specific responsibilities while allowing flexibility when needed.

Step 4. Pages

This step involves the highest level of architecture. Pages should be kept as "thin" as possible and only handle tasks such as loading data related to the page (e.g., fetching information about a product based on the product slug in the URL) and determining the layout of widgets.

Summary

Upon initial reading, the FSD architecture may raise more questions than answers. However, in practice, it proves to be quite understandable and straightforward to work with.

A significant portion of the code is organized into the shared and entities folders, serving general purposes. The remaining elements are encapsulated within isolated widgets, and as needed, they can be moved to lower layers.

Code structured in this way is relatively easy to maintain. You can quickly identify "safe" and "risky" parts for modification, as well as understand the impact of changes. When modifying a bank card input form located in the features layer, for example, you can make changes without worrying about unforeseen effects on other features or code located in lower layers. You only need to review the few widgets that use the form. If a utility is placed within a widget's folder, it means it is used exclusively by that widget and nowhere else.

Compare this convenience to dealing with cluttered components and lib folders.

For my projects, I now always strive to align the code with this architecture, regardless of the application's size.

Top comments (2)

Collapse
 
rbower profile image
Ryan Bower

Great post. I'm currently in the process of scaling out several projects with FSD.

There's one aspect that I'm not quite sold on, and I'm wondering if you can weigh in with your experience. The official documentation suggests to place data structures related to external APIs in the shared layer.

github.com/feature-sliced/document...

I've found this approach contrary to the principles of FSD: API endpoints typically contain information related to specific business entities. To that end, shouldn't we be placing these definitions in the entities layer?

That said, the challenge here is that a single API call may contain information related to multiple business entities. What are your thoughts on this?

Collapse
 
gaundergod profile image
Gleb Kotovsky

Great take!