📂✨ Folder structure in a React hexagonal architecture

Juan Otálora
7 min readJul 14, 2024

--

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.

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.

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.

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;
};

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>;
}

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;
};

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);
};

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);
},
};

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",
});
};
  • 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;
};
  • 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,
};
};
  • 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! 💬

--

--

Juan Otálora
Juan Otálora

Written by Juan Otálora

I write about software, product and design for Frontend Engineers, empowering them for what the world needs.

Responses (2)