The Idea Behind SOLID And How It Should Change The Way We Structure Frontend Code

The Idea Behind SOLID And How It Should Change The Way We Structure Frontend Code

At some point, the complexity of frontend codebases grows to a size where maintenance becomes an uninspiring burden. Fundamental software design principles like SOLID, which are meant to help with that, are not easy to use without much experience or guidance. In this article, I propose an alternative shortcut to better code design solutions and illustrate its usage with examples for code and file structure.

Let's start discussing essential questions right away: Why do frontend codebases tend to be not-that-great? I don't mean that type of situation where the application is just unlucky to be created by someone inexperienced. I'm talking about that vibe, that sometimes even an utterly decent codebase has the charm of something crafted in a garage. The phenomenon may look unexplainable, but the reason is pretty much on the surface. If you read frontend blogs, took some courses, etc., you might find that almost all of them are dedicated to frameworks, libraries, and solving practical problems. In other words, they are primarily teaching "how to make it", while skipping the "what should we want to do" part. Libraries' documentation usually follows the same path, only rarely providing shallow architectural guidance. Is it wrong, though?

In general, it's perfectly fine to rely on people improving their software design skills separately from learning programming language and its ecosystem. However, the exclusive challenge of frontend engineers is to keep the balance between improving these crucial skills and adapting to the perpetually evolving world of JavaScript. And it is very easy to just give yourself up to this "frontend flow", which sooner or later will turn one into a framework guru who cannot actually build complex software in a sustainable way. As controversial as it sounds, the more someone studies frontend development without knowledge of Software Design, the more likely they are just becoming worse as engineers in total.

Challenges for frontend people do not end here. Even if engineers dedicate time to studying Software Design, materials on it are usually not adapted for the world of UI. So, it's not easy to convert this knowledge into practical skills; in particular, even if you try to do something clever, it's hard to tell whether you are doing the right thing. So, regardless of your knowledge, unless you are lucky to have someone experienced around, you can only "learn by doing". Codebases then play the role of a learning ground that inevitably turns into something with DIY vibes. It's fair to say the complexity of software fundamentals is a problem not only for people dealing with JS in a browser. But the contrast between the amount of code that brings interactive UI to life and the available knowledgebase about how to do that right is indeed remarkable.

Statement: I do believe that Software Design theory is overcomplicated. I can even propose the conspiracy theory – it was overcomplicated on purpose to sell books, courses, and workshops. Who knows! Jokes aside – it indeed is a sophisticated topic that hardly can be expressed in the "For Dummies" series format. But its weakest spot – it never says "Why" clearly, while this is essential for understanding. Let's take the following incantation as an example: "A class should have only one reason to change". It's not that convincing if you think about it cause, "why"? "To make code more robust" – what does that mean? Is it really important? Can't we just test my code and fix found problems? Does it mean I have to put everything in different files? I don't use classes; may I just go?

I experienced all these problems myself. In the past, I studied Software Engineering at university. I could implement most of the GoF patterns without looking them up, and I could explain every letter of SOLID even after multiple beers and shots. And yet I severely butchered some parts of my past projects without realizing it. My "Aha!" moment happened after reading "Clean Architecture" by Robert C. Martin for the second time (second, so it's not an ad for this book – see the statement above). There you can find a lot of good patterns and ideas wrapped around the concept of designing everything with scenarios of changes in mind. Very smart, but still abstract and obscure. Some explanations are backed by stories about working with punched cards. And revelation came to me after I realized that it was all invented by people who had to deal with the ancient ways of bringing code to life. Nowadays, updating the code is easy – type, save, commit, push, deploy. But in the old times, it was all a nightmare in comparison because the update had to be made practically "manually". For example, "deployment" of the above-mentioned punched cards could fail even because of a mechanical problem. Imagine working with the code in this way! And so, under such conditions, the less you have to interact with, the better. But what does it have to do with our today's work?

As I mentioned above, modern code delivery pipelines would look futuristically magical to the developers in the 60s–70s – but so does our work. Our development infrastructure is much faster – but so are our businesses. Even though we can make changes much faster, the number of changes to be done is also significantly larger. And so the cognitive load and its continuous growth replaced bad hardware as "developer problem number one". Every step you make now is much easier, but you have to make much more steps, so at the end of the day, we want the same thing as in the past – reduce the number of these steps. The game changed, but not the rules – the less you have to interact with, the better. Every smart theoretical concept that has been introduced last 50-ish years – Object–Oriented Design, GoF design patterns, SOLID, Dependency Injection, and many others – is meant to eventually serve this purpose. Therefore every code design solution can be validated against "whether it helps to decrease interactions with the code in future". Using this criterion is like having this missing experienced mentor near you. Naturally, you will still make mistakes, but it'd be easier to identify them early on. Code, optimized with this principle in mind, eventually becomes mature-looking and easy to work with.

This is still a bit abstract, so let's get practical. First, let's talk about "interacting with the code". We interact with the code mainly by reading and writing it and, less obviously, by searching for it and switching between its locations. So, we can tell that the code is well designed when, for making a change, engineers don't have to:

  • Read or see too much of the irrelevant code.
  • Rewrite the code, or write too much of the problem-irrelevant code.
  • Search for the places to update for too long.
  • Switch between locations while making an update.

Let's look at the following example. This component fetches dog breeds from the API and shows breed names as a list.

import React, { useEffect } from 'react';

export const Doggos = () => {
  const [breeds, setBreeds] = useState([]);

  useEffect(() => {
    fetch('https://dog.ceo/api/breeds/list/all').then((response) => {
      const { message, status } = response.json();

      if (status === 'success') {
        setBreeds(Object.keys(message));
      } else {
        alert('Who let the dogs out?');
      }
    });
  }, [alert, fetch, setBreeds]);

  return (
    <ul>
      {breeds.map((breed) => (
        <li key={breed}>{breed}</li>
      ))}
    </ul>
  );
};

And let's imagine that the roadmap for this includes many iterations with UI updates. That means we'll be making many changes to the returned JSX. And so, whenever we open the file to make these UI changes, we will have to look at the irrelevant code about breeds fetching. Then, if some other component also needs this data, we will have to either refactor the code or write more breeds-irrelevant code for fetching the data from the API. Say we chose the latter and then the next turn of events – we also need to show sub-breeds left behind by our response parsing code. So, we'll have to switch between locations where this code resides to fix the bug.

From this perspective, this is for sure not a well-designed code. We can try to make it better by moving breed fetching code to a custom hook, which we can put in a separate file:

import React from 'react';

import { useDogBreedsAPI } from './useDogBreedsAPI';

export const Doggos = () => {
  const breeds = useDogBreedsAPI();

  return (
    <ul>
      {breeds.map((breed) => (
        <li key={breed}>{breed}</li>
      ))}
    </ul>
  );
};

Now, we don't have to look at how we talk to API, and any necessary changes to breed-fetching logic can be done in one place only. But – another problem – now, the vague name of the constant "breeds", as well as the mystery behind breeds appearing in our system, will strongly motivate engineers to go check out our custom hook every now and then – which is, again, undesired switching between code locations. We can try to avoid that by giving a more precise constant name as well as bringing fetch invocation back to the component:

import React, { useEffect } from 'react';

import { useDogBreedsAPI } from './useDogBreedsAPI';

export const Doggos = () => {
  const { fetchBreeds, breedNames } = useDogBreedsAPI();

  useEffect(() => {
    fetchBreeds()
  }, [fetchBreeds])

  return (
    <ul>
      {breedNames.map((name) => (
        <li key={name}>{name}</li>
      ))}
    </ul>
  );
};

It's wordier but less mysterious, plus now we are controlling the start of fetching. There are other ways to approach this; for example, fetching could be delivered through React Context or one of the available state management solutions. Also, UI code could be moved out to a separate file, and so on – the number of the combinations of solutions here are nearly uncountable. So this example is not showing the perfect one, it rather describes the process of continuous checks for possibilities of reducing future interactions with this code. The most important thing to remember here is that it depends on how you work with the code and what changes you usually make. For example, if the code you are writing will most likely never be updated, it's OK to keep everything in one file. Engineers just have to be sure performance boost from reducing switching is helping them more than they are slowed down by having to read and process extra code.

Such an approach can be used not only with the code but also with the file structure. Again, how do we interact with the file structure during our work with an existing project? Mainly, we visually process the names of files and folders, open and close them, search for them, and switch between them. Therefore, we can tell that a folder structure is well-designed when for making a change, engineers can:

  • See as few file and folder names as possible.
  • Open as few irrelevant files and folders as possible.
  • Find necessary files as fast as possible.
  • Minimize switching between different project locations.

That means that in such a folder structure necessary file can be found by its name and all parent folder names without opening wrong files and folders along the way and without having to process irrelevant files and folder names. Also, required changes can be done without switching between different folders in the project's view. Let's see an example:

├── actions/
│   ├── cats.js
│   ├── dogs.js
│   ├── parrots.js
│   └── hamsters.js
├── petUtils/
│   ├── formatCatOrDog.js
│   └── talkToParrot.js
├── reducers/
│   ├── cats.js
│   ├── dogs.js
│   ├── parrots.js
│   └── hamsters.js
├── screens/
│   └── furryPets/
│       ├── cats/
│       │   └── Cats.js
│       ├── dogs/
│       │   └── Dogs.js
│       └── otherPets/
│           ├── birds/
│           │    └── Parrots.js
└── tests (not all tree shown)/
    ├── actions/
    │   ├── dogs.test.js
    │   └── ...
    ├── reducers/
    │   ├── dogs.test.js
    │   └── ...
    └── screens/
        └── dogs/
            ├── Dogs.test.js
            └── ...

This is a classic group-by-purpose kind of folder structure. Here, to make a full-scoped change with, for example, the Dogs, you must interact with almost every folder in this structure, see almost all the files, and constantly switch between 7 locations. While some workflows might still be friendly to such a structure, it's not the case for us in our example. So, let's try to improve this by grouping code "by feature" and adding prefixes to file names:

├── modules/
│   ├── Cats/
│   │   ├── cats.actions.js
│   │   ├── cats.actions.test.js
│   │   ├── cats.reducers.js
│   │   ├── cats.reducers.test.js
│   │   ├── Cats.js
│   │   └── Cats.test.js
│   ├── Dogs/
│   │   ├── dogs.actions.js
│   │   ├── dogs.actions.test.js
│   │   ├── dogs.reducers.js
│   │   ├── dogs.reducers.test.js
│   │   ├── Dogs.js
│   │   └── Dogs.test.js
│   ├── Parrots/
│   │   ├── parrots.actions.js
│   │   ├── parrots.actions.test.js
│   │   ├── parrots.reducers.js
│   │   ├── parrots.reducers.test.js
│   │   ├── Parrots.js
│   │   └── Parrots.test.js
│   └── Hamsters/
│       ├── hamsters.actions.js
│       ├── hamsters.actions.test.js
│       ├── hamsters.reducers.js
│       ├── hamsters.reducers.test.js
│       ├── Hamsters.js
│       └── Hamsters.test.js
└── utils /
    └── petUtils/
        ├── formatCatOrDog.js
        └── talkToParrot.js

It looks more structured, and you can tell reducers from actions by the name of the file. However, it's wordier and visually noisy, right? But notice – you don't have to interact with all the folders, you have no reasons for keeping them all open. So, if we work only with the Dogs, our folder structure will be mostly collapsed:

├── modules/
│   ├── Cats
│   ├── Dogs/
│   │   ├── dogs.actions.js
│   │   ├── dogs.actions.test.js
│   │   ├── dogs.reducers.js
│   │   ├── dogs.reducers.test.js
│   │   ├── Dogs.js
│   │   └── Dogs.test.js
│   ├── Parrots
│   └── Hamsters
└── utils /
    └── petUtils/
        ├── formatCatOrDog.js
        └── talkToParrot.js

Quite an improvement. Let's say our workflow doesn't include working with tests right away; yet, we are still bound to visually interact with them. Also, note that we still interact with Parrot util by seeing its wrongly placed "talkToParrot.js" file. Let's do something with all of that – tests can have their local subfolder, and the parrot util file can be moved under the "Parrots" folder:

├── modules/
│   ├── Cats
│   ├── Dogs/
│   │   ├── _tests_
│   │   ├── dogs.actions.js
│   │   ├── dogs.reducers.js
│   │   └── Dogs.js
│   ├── Parrots
│   └── Hamsters
└── utils/
    └── petUtils/
        └── formatCatOrDog.js

Further improvements are still possible. Also, same as with code examples, this can be approached differently – we might want to keep actions and reducers as a separate structure, have different module grouping, etc. – the range of possibilities is wide. But again, this example is not about perfect folder structure. It's about continuous adjustments of folder structure according to the workflow with the end goal of reducing interactions with irrelevant files and folders.

In fact, in these examples, we were intensively applying Single Responsibility, Open-Closed, and Dependency Inversion principles. I could spend many lines here explaining how exactly, but I would rather leave this for readers to figure out. Instead, I'd like to highlight the main idea again – the less you have to interact with, the better. It's not a silver bullet, maybe not even a drawing of a silver bullet. But it's a great way to distinguish right from wrong, which is crucial for building your perfect code design workflow and making your code work for you, not against you.

Want to try this, but not sure where to start?

  • Identify things you change rarely, and if they live together with volatile ones – separate them. Usually, code for API calls, URL params parsing, and domain-agnostic utils change rarely and can be moved out.
  • Move things around and "try them out". Don't hesitate to bring things back and forth – you have to learn. Also, don't give up after failed attempts.
  • Try to think outside "I'm used to what I have". Even if you know every place in your repo by heart, reducing that cognitive load can be a game-changer.
  • Even minor improvements are worth the hassle. Every tiny step forward makes further steps easier.