This is part of a series of articles about my open-source project (backend and frontend). if you want to join me feel free to contact me at - zvikfir10@gmail.com.
Introduction
Recently, I have re-started working on an open source project I came up with last year, called MyWay (it is named after my and my dog trainer girlfriend's dog "Maui"). This project aims to serve as a Customer Management System (CMS) platform for dog trainers, helping them to keep track of their work with each customer, including abilities such as sending a summary of the last session directly to the customer, or plan the progress that needs to be made along the training sessions in a chart that is easy to use and read.
In the beginning, I started the project with the technologies I knew back then, which were React, Express over Node, and MongoDB (it is worth mentioning that I didn't have a lot of experience with full-stack, and most of what I knew were from the freeCodeCamp courses I took on my free time). These worked great for me last year as I worked on the project for a few weeks. However, this time, when I tried to keep using them I felt a lot of discomfort. I felt that for a large portion of my code - I don't really know if it works, or how well it works.
I tried to take inspiration from several template projects I found on Github, but it was hard for me since each such template took its own approach into doing things. I personally needed to have a framework that would dictate a project structure and layout.
I consulted an open source community Facebook group named "Pull Request", and I was recommended to use NestJS. At first, it was really hard for me to get used to a whole new framework, which is written in Typescript rather than Javascript (although it is possible to use it with JS), and contains a whole new set of classes, objects, and methodologies. Nonetheless, it felt like the right thing to do, as Nest helps to keep your code organized, and much less error-prone.
I tried to find a similar solution to the front-end part, and eventually I chose Next.JS. It mainly provides the ability to pre-render, but I liked that it contained a special pages
directory which automatically created routes according to the file's name. However, NextJS does not provide a clear project structure and it still gives much freedom to each developer, which didn't fit for me.
I wound up combining several ideas (the main ones were taken from this post covering the use of MobX with React hooks, and from this GitHub project taken from Ariel Weinberger's excellent Udemy course covering NestJS) into what I think is a good project structure, and this is what I'm going to cover in this post.
The Proposed Structure
The structure I will cover here uses MobX, a very popular state management library. Although MobX is not an essential part and you can achieve a similar solution without using it, I think it is of great value and that's why I included it.
MobX is used to provide all the components in our app with an instance of the RootStore
. The RootStore
is a class that creates all the services your app requires, as well as all the stores. While creating each store, the RootStore
makes sure to provide an instance of itself, so that each store will be able to access other stores, and an instance of the its dependent services.
Before I explain each step in detail, you can view almost all the relevant changes I have made in the MyWay project in this commit.
First of all, create three folders: services
, stores
, and dto
. In the stores
folder, create a file named stores/index.ts
and in it create the RootStore
class:
stores/index.ts
export class RootStore {
constructor() {
const authService = new AuthService();
this.userStore = new UserStore(this, authService);
}
}
export const StoresContext = createContext(new RootStore());
export const useStores = () => useContext(StoresContext);
This code presents how you can create services in the RootStore
constructor so that each of this services will be a Singleton, since they are created only once, and create all the needed stores in your application. In this example we have the AuthService
and the UserStore
. The AuthService
will contain logic related to authentication, such as login, register, and logout. The UserStore
is a store that contains information about the user in the application, and it might want to save the user information once it is logging into its account, so that all the components in the system can use it.
As mentioned, note that each store is given the RootStore
instance (this
), and the services it requires.
Another important part of that code is how we expose it to all of the components in our app. To do so, we make use of React's context. We first use createContext
to create a context containing the RootStore
instance, and then we export a useStores
function that will easily allow us to use the context created.
Next up, let's create the AuthService
class.
We will most likely have many services in our app. To simplify their creation, we'll create a base class from which they will inherit. This base class will abstract the use of http libraries such as the built-in fetch
or axios
. This way, should the need to switch to a more modern library arises, you can do so easily (you can read a more detailed explanation here).
Create a file named services/base-http.service.ts
:
services/base-http.service.ts
import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios";
import Router from "next/router";
import { APIErrorResponse } from "../dto/api/api-error-response";
import { APIResponse } from "../dto/api/api-response";
// Taken from https://github.com/arielweinberger/task-management-frontend/blob/master/src/services/base-http.service.js
export default class BaseHttpService {
BASE_URL = process.env.BASE_URL || "http://localhost:3000";
// _accessToken: string = null;
async get<T = any>(
endpoint: string,
options: AxiosRequestConfig = {}
): Promise<T | void> {
Object.assign(options, this._getCommonOptions());
return axios
.get<APIResponse<T>>(`${this.BASE_URL}${endpoint}`, options)
.then((res: AxiosResponse<APIResponse<T>>) => res.data.data)
.catch((error: AxiosError<APIErrorResponse>) =>
this._handleHttpError(error)
);
}
async post<T = any>(
endpoint: string,
data: any = {},
options: AxiosRequestConfig = {}
): Promise<T | void> {
Object.assign(options, this._getCommonOptions());
return axios
.post<APIResponse<T>>(`${this.BASE_URL}${endpoint}`, data, options)
.then((res: AxiosResponse<APIResponse<T>>) => res.data.data)
.catch((error: AxiosError<APIErrorResponse>) =>
this._handleHttpError(error)
);
}
async delete<T = any>(
endpoint: string,
options: AxiosRequestConfig = {}
): Promise<T | void> {
Object.assign(options, this._getCommonOptions());
return axios
.delete<APIResponse<T>>(`${this.BASE_URL}${endpoint}`, options)
.then((res: AxiosResponse<APIResponse<T>>) => res.data.data)
.catch((error: AxiosError<APIErrorResponse>) =>
this._handleHttpError(error)
);
}
async patch<T = any>(
endpoint: string,
data: any = {},
options: AxiosRequestConfig = {}
): Promise<T | void> {
Object.assign(options, this._getCommonOptions());
return axios
.patch<APIResponse<T>>(`${this.BASE_URL}${endpoint}`, data, options)
.then((res: AxiosResponse<APIResponse<T>>) => res.data.data)
.catch((error: AxiosError<APIErrorResponse>) =>
this._handleHttpError(error)
);
}
_handleHttpError(error: AxiosError<APIErrorResponse>) {
if (error?.response?.data) {
const { statusCode } = error?.response?.data;
const requestUrl = error.response?.config.url;
if (
statusCode !== 401 ||
requestUrl?.endsWith("/api/auth/login") ||
requestUrl?.endsWith("/api/auth/register")
) {
throw error.response.data;
} else {
return this._handle401(error);
}
} else {
throw error;
}
}
_handle401(error: AxiosError<APIErrorResponse>) {
this.get("/api/auth/refresh")
.then(() => axios.request(error.config))
.catch((e) => Router.push("/login"));
}
_getCommonOptions() {
// const token = this.loadToken();
// return {
// headers: {
// Authorization: `Bearer ${token}`,
// },
// };
return {};
}
// get accessToken() {
// return this._accessToken ? this._accessToken : this.loadToken();
// }
// saveToken(accessToken : string) {
// this._accessToken = accessToken;
// return localStorage.setItem("accessToken", accessToken);
// }
// loadToken() {
// const token : string = localStorage.getItem("accessToken") as string;
// this._accessToken = token;
// return token;
// }
// removeToken() {
// localStorage.removeItem("accessToken");
// }
}
In this class, we expose the basic functions that are used in any http library: get
, post
, put
, patch
, and delete
. In each function, we simply call the http library we would like to use. In this case, it is axios
. You can easily use any other library you would like.
Since we are using NestJS, our API usually has a uniform response structure. We make sure to import and use the relevant interfaces so programmers who read our code can understand it more easily:
dto/api/api-response.ts
export interface APIResponse<T> {
data: T;
}
dto/api/api-error-response.ts
export interface APIErrorResponse {
statusCode: number;
message: string;
error?: string;
}
Another benefit we have from using this base class for our services is the ability to catch errors on any request sent in our application, and to apply a certain logic to it. For example, in case of authentication we might want to intercept any error with a status code of 401. In MyWay, I implemented the authentication with a JWT access token and a refresh token that are saved as cookies, so if I get a 401 response, I want to try to use my refresh token to get a new access token. You can see the logic applied in the _handle401
function. In addition, you can see in the commented code how to implement a strategy which saves the tokens in local storage.
Once we have this base class set up, we can now create the authentication service class:
services/auth.service.ts
import { LoginDto } from "../dto/auth/login.dto";
import { RegisterDto } from "../dto/auth/register.dto";
import { SessionUserDto } from "../dto/auth/session-user.dto";
import BaseHttpService from "./base-http.service";
export default class AuthService extends BaseHttpService {
async login(loginDto: LoginDto): Promise<SessionUserDto> {
return (await this.post<SessionUserDto>(
"/api/auth/login",
loginDto
)) as SessionUserDto;
}
async register(registerDto: RegisterDto): Promise<void> {
return await this.post("/api/auth/register", registerDto);
}
}
This code is quite self explanatory, so we'll move right to creating our UserStore
class.
stores/user.store.ts
import { makeAutoObservable } from "mobx";
import { RootStore } from ".";
import { LoginDto } from "../dto/auth/login.dto";
import { RegisterDto } from "../dto/auth/register.dto";
import { SessionUserDto } from "../dto/auth/session-user.dto";
import AuthService from "../services/auth.service";
export default class UserStore {
user: SessionUserDto | null;
constructor(
private readonly rootStore: RootStore,
private readonly authService: AuthService
) {
this.user = null;
makeAutoObservable(this);
}
async login(loginDto: LoginDto): Promise<void> {
this.user = await this.authService.login(loginDto);
}
async register(registerDto: RegisterDto): Promise<void> {
await this.authService.register(registerDto);
const { email, password } = registerDto;
const loginDto: LoginDto = { email, password };
this.user = await this.authService.login(loginDto);
}
}
In each store, we can create the state that we would like to expose to components which use it. In this case, the state contains the user which is currently logged in. You can also see how decoupling the logic from the components helps us to avoid code duplication: in the register
function, instead of re-writing the same logic of sending an API call to the server, and handling the possible errors all over again, we simply use the login
function which is already in the same store. In addition, in case we wanted to use some logic from another store, we would simply do so like this:
this.rootStore.someOtherStore.someFunction();
Remember that through the rootStore
field we can access all the other stores in our application.
Now that this is covered, let's see how we use the stores and services we created in our components. Let's take the LoginForm
component as an example:
components/auth/login.form.component.tsx
...
import { useStores } from "../../stores";
import { APIErrorResponse } from "../../dto/api/api-error-response";
import { observer } from "mobx-react-lite";
const LoginForm = observer(function LoginForm() {
const { userStore } = useStores();
return (
<Formik
initialValues={{
email: "",
password: "",
}}
onSubmit={(values, { setSubmitting, setStatus }) => {
userStore
.login(values)
.then(() => {
setStatus({ message: "You have logged in successfully." });
setTimeout(() => Router.push("/"), 2000);
})
.catch((e: APIErrorResponse) => {
setStatus({ error: e.message });
})
.finally(() => setSubmitting(false));
}}
...
export default LoginForm;
The only change we have made is to declare a userStore
variable taken from the useStores()
function call. With this store, we can use all the functions it exposes, as well as read its state like this:
const { user } = userStore;
That's it!
We have managed to create a service oriented project structure that can help us maintain organization in our project, separating logic and state from our components, so they can take care of only the view layer, avoid code duplication, and help us avoid runtime errors.
This project is still a work in progress, so you can see in later commits examples of more services and stores.
I hope that this post has been somewhat helpful to you, and feel free to leave some feedback. I'd love to hear from you.
I'd like to thank Shmuel Shoshtari for both motivating me to write this post, and for valuable feedback regarding this post and the project.
Top comments (0)