DEV Community

Cover image for Folder structure in a React hexagonal architecture
Juan Otálora
Juan Otálora

Posted on • Updated on

Folder structure in a React hexagonal architecture

Certainly, the folder structure is one of those decisions that if not made well from the beginning can lead to many problems as the code scales: the folder structure.

Bad folder skeletons exist, just as good ones do. Fortunately, modern IDE refactoring tools allow files and folders to be moved without many complications.

Meme

In this post, we will look at the folder structure that I propose for a clean architecture for React with Redux and TS, so you will see naming specific to these technologies, but it can be applied to any other front-end library/framework. Feel free to adapt it to your needs 🙂

React Folders

Although in the previous article we discussed why React components (and their styles and hooks) are part of the infrastructure, it is also important to consider that they are a very important part of the application. Ultimately, SPAs may have business logic, but without a user interface, it is nothing. That's why in the structure I propose, *the basic React folders are located at the root of the project *(/src), as in most front-end projects.

Here we will find some folders such as /components, /pages, /router, /hooks, /styles, ... In an upcoming article in the series, I will explain how to structure these folders with a tweaked Atomic Design, so I won't give it too much importance in this post.

React folder structure

Vertical Slicing

Let's set aside the onion-like UI that represents hexagonal architecture for a moment. The first thing that comes to mind when we have to divide our code is to divide it into layers: one folder for the domain, another for the application, and one last for infrastructure. If these folders start to grow, we can divide each one into folders for different functionalities: one for "tasks", another for "goals", another for "users", ...

But let's put ourselves in the shoes of someone who has to develop a new feature for a moment: for example, deleting tasks. Does it make sense to be moving through the different /tasks folders in domain, infrastructure, and application?

Vertical slicing tells us that it's better to make the first division by functionality and then by layers. That is, at the first level, have folders for /tasks, /goals, /users, ... And within each of these three folders, have /domain, /application and /infrastructure.

Vertical slicing

Now, let's go into more detail on each of these three folders located into a /modules or /features folder.


If you found this helpful or enjoyable, add a reaction! ❤️ Your likes are appreciated and keep me motivated!


Domain ⚙️

/modules/<slice>/domain

Mainly in this folder, we will have 3 things:

  • Domain types and enumerations
  • Repository interfaces
  • Domain utilities

Let's start with the types. It's important to define all the types that represent entities of our business: "Task", "Goal" and "User". Also, enums that represent states such as "TaskStatus" or "GoalType". These will be used from different points of the application, so let's try to put effort into creating them.

In the same files as the types, we will place the creator functions, responsible for creating an object of that type based on parameters. It's interesting to bring the concept of named constructors to functional programming.

// Declare the type
export type Task = {
  id: string;
  title: string;
  status: TaskStatus;
  goalId?: Goal["id"];
};

/* Declare the constructors.
In this case, we receive by parameters
all the task props and in the constructor
we only ensure the task is valid */
export const createTask = (task: Task): Task => {
  ensureTaskIsValid(task);
  return task;
};
Enter fullscreen mode Exit fullscreen mode

Along with the types, the repository interfaces such as "TaskRepository" or "GoalRepository" will serve us to implement the repositories in the infrastructure.

export interface GoalsRepository {
  getAll: () => Promise<Array<Goal>>;
  save: (goal: Goal) => Promise<void>;
  delete: (goalId: Goal["id"]) => Promise<void>;
}
Enter fullscreen mode Exit fullscreen mode

Finally, a utility functions folder where we will place all domain services, guards, or that logic specific to our domain. I place them all under a folder called utils.

export const calcPercentageOfDoneByTasks = (tasks: Array<Task>): number => {
  const doneTasks = tasks.filter((task) => task.status === TaskStatus.DONE);
  if (doneTasks.length === 0 || tasks.length === 0) return 0;
  return doneTasks.length / tasks.length;
};
Enter fullscreen mode Exit fullscreen mode

There are many things in DDD that I won't go into detail about, such as favouring composition over inheritance or value objects. If you're interested, we can do an article later on looking at best practices we can apply with DDD in front-end projects.

Let's allow our business to express itself freely in this folder. All the logic that we can push to the domain (if it belongs to the domain, of course) is better. You will surely find a lot of validations in your components that you have developed in the same component but should be in a domain utility. Can you create a task with an empty string as the title? This smells like business, let's try to push it there.

Application 🚀

/modules/<slice>/application

We move to the next layer, the application layer. Here, all the use cases that the user can execute are located. These use cases are usually related to actions that the user can perform in the interface such as "Create a task", "Delete a goal", or "Assign a task to a user". The premise of use cases is that it doesn't matter how you design the interface, the use case should be invariant.

You have to be careful because sometimes very general use cases are created such as "Save task" or "Save goal". If your use case is "Assign a task to a user", don't assign the task to the user on the front-end and send the modified task to the use case. It's better if you pass the task and the user to whom it is assigned to the use case and decouple all this functional logic from the interface.

export const getTaskNoteOrCreateOneUseCase = async (
  notesRepository: NotesRepository,
  taskId: Task["id"]
): Promise<Note> => {
  const note = await notesRepository.getByTaskId(taskId);
  return note ?? createNoteByTaskId(taskId);
};
Enter fullscreen mode Exit fullscreen mode

Some use cases will need to access repositories (whose implementation we will talk about later). In this case, it may make sense to call the repository implementation directly from the use case, but we would be dirtying it (application cannot import infrastructure). Instead, we will receive the repository implementation as a parameter and for that, we will use the interface that we will have generated in the domain.

We will talk about dependency injection in a couple of chapters, but for now, we stay with this and with the fact that we can use currying to make our code more readable.

Infrastructure 🔌

/modules/<slice>/infrastructure

Finally, in this folder, we find all the logic of connection with the backend or implementations such as Local Storage and even the Redux dispatch that we will talk about in more detail in the next chapter.

I like to implement the repository as a typed object with the domain interface. This way, if we want to use it, we just have to do repositoryName.getAll(), being much more semantic than having to call a function getAll() or getAllFromRepositoryName().

/* Yes. I modified the store in the
implementation of the repository. 
We'll talk about this in future articles */
export const goalsRepository: GoalsRepository = {
  getAll: async (): Promise<Array<Goal>> => {
    const dtos = await getAllGoalsApiRest();
    const goals = dtos.map(mapGoalFromApiRest);

    store.dispatch(saveGoalsAction(goals));
    return goals;
  },
  save: async (goal: Goal): Promise<void> => {
    store.dispatch(saveGoalsAction([goal]));

    const dto = mapGoalToApiRest(goal);
    return saveGoalApiRest(dto);
  },
  delete: async (goalId: Goal["id"]): Promise<void> => {
    store.dispatch(deleteGoalAction(goalId));

    return deleteGoalApiRest(goalId);
  },
};
Enter fullscreen mode Exit fullscreen mode

Some people like to include the technologies they use in the repository name. For example, apiRestReduxRepository. In the backend, with many more connections to external services, it may make more sense, but in the front-end, I have found a few cases where I like to do it that way.

What else do we have in this layer besides the repository implementation?

  • Clients connect with external servers, for example, the REST client that executes the fetch and that we will call from the same repository.
export const deleteGoalApiRest = async (id: string): Promise<void> => {
  await apiRestClient(`${PATH}/${id}`, {
    method: "DELETE",
  });
};
Enter fullscreen mode Exit fullscreen mode
  • DTOs define the types used in calls to external services to comply with the contract. These DTOs don't have to be the same as our domain types. They can contain some differences, and it's important to keep them separate. For example, GraphQL contaminates objects with a __typename property that we're not interested in the domain.
export type TaskApiRestDTO = {
  id: string;
  title: string;
  goalId?: string;
  status: string;
};
Enter fullscreen mode Exit fullscreen mode
  • Mappers transform DTOs into domain entities and vice versa. I call them from the repository just before making the client call.
export const mapTaskToApiRest = (entity: Task): TaskApiRestDTO => {
  return {
    id: entity.id,
    title: entity.title,
    status: mapTaskStatusToApiRest(entity.status),
    goalId: entity.goalId,
  };
};

export const mapTaskFromApiRest = (dto: TaskApiRestDTO): Task => {
  return {
    id: dto.id,
    title: dto.title,
    status: mapTaskStatusFromApiRest(dto.status),
    goalId: dto.goalId,
  };
};
Enter fullscreen mode Exit fullscreen mode
  • Assemblers, although not very common in the front-end, help us compose domain objects based on several DTOs. In mature applications with Backend For Frontend, it is usually not necessary, but in an MVP they can save us a lot of development time.

Conclusion

Vertical slicing allows us to be more agile when developing new functionality. By dividing everything by layers and following the hexagonal architecture, we achieve code with functional logic that is decoupled from the interface and infrastructure, which saves us time when refactoring, scales much better, and is much more readable.

There are some things I haven't explained in depth because I will delve into them in more detail in future articles. In the next one, we will see how to manage Redux, how to read from its store, and where to execute dispatch.


I'd love to hear your thoughts on this! What do you think? Feel free to drop a comment below and share your perspective with me! 💬

Top comments (7)

Collapse
 
alexboisseau profile image
Alex Boisseau

Thanks for this interesting article !

Collapse
 
juanoa profile image
Juan Otálora

Thanks for your comment Alex!

Collapse
 
khoi_tran_0327eceac70215f profile image
Khoi Tran

I'm interested in your articles, hope to read the next one soon. I also hope you can include github link for people like me to have a clear viewpoint of how the clean architecture react works.

Collapse
 
juanoa profile image
Juan Otálora

Hi Khoi! Thanks for your comment. The next article will arrive soon :) Also, I'm preparing a sandbox repository, but it's still incomplete. As soon as I have it, I will publish it in all the posts of the series.

Collapse
 
nassmim profile image
nassmim • Edited

Hi Juan!

Great article thanks for this!

Still no github?

Collapse
 
renancferro profile image
Renan Ferro

Niice article man! I noticed that you are new here in the Dev community. So I would like to welcome you and let's make this community even more wonderful ✌️

Collapse
 
juanoa profile image
Juan Otálora

Thanks Renan! Nice to meet you 🙂

PS. Your webpage is insane 😯