Building a simple REST API using Nodejs, Expressjs, Prisma, and SQL - Part 04

Setting up Folder Structure, and Refactoring

Building a simple REST API using Nodejs, Expressjs, Prisma, and SQL - Part 04

In the last three articles, we created a new database project in PlanetScale to power our SQL DB. Then we created a user route for handling crud operations via Prisma client. And then we went on to add TypeScript and input validation using Zod.

We will start by creating six new folders inside src. The folders are routes were will put in the routes, the HTTP Methods. We will handle what happens in the route in a folder named controllers. And the data fetching and all the db talking will be done in the services folder. The model that we created using Zod, the schema, will go in the models folder. And two more folders named middlewares and utils. All our middleware code will be in the middlewares folder and helpers will be in the utils.

Now our folder structure will look like this:

src/
    routes/
    middlewares/
    controllers/
    services/
    models/
    utils/
index.ts

We will start refactoring by moving our user_model.ts file from the src directory to the models folder. Then we will make some changes to the user_model.ts file.

// src/models/user_model.ts
import { z } from 'zod'

export const CreateUserSchema = z.object({
  username: z.string().min(3).toLowerCase(),
})

export type User = z.infer<typeof CreateUserSchema>

We will add another check and a parsing to our username field validation.

Now we will create a new file named user_service.ts in the services folder. From the index.ts file the code inside the route functions which fetches the data from db will be abstracted to user_service.ts file.

// src/services/user_service.ts
import db from './../utils/db_connect'
import { User } from '@prisma/client'
import { type User as UserModel } from './../models/user_model'

export const getUsers = async (): Promise<User[]> => {
    try {
        const users: User[] = await db.user.findMany()
        return users
      } catch (err) {
        throw new Error('Failed to fetch users')
      }
}

export const getUserById = async (id: string): Promise<User | null> => {
    try {
        const user: User | null = await db.user.findUnique({
          where: { id }
        })
        return user
      } catch (error) {
        throw new Error('Failed to fetch user')
      }
}

export const createUser = async (userData: UserModel): Promise<User> => {
    try {
        const newUser = await db.user.create({ data: userData })
        return newUser
    } catch (error) {
        throw new Error('Failed to create user')
    }
}

export const updateUserById = async (id: string, userData: UserModel): Promise<User> => {
    try {
        const user: User | null = await db.user.update({
            where: { id },
            data: userData
          })
        return user
      } catch (error: any) {
        throw new Error('Failed to update user')
      }
}

export const deleteUserById = async (id: string): Promise<void> => {
    try {
        const user: User = await db.user.delete({
            where: { id }
        })
    } catch (error: any) {
        throw new Error('Failed to delete user')
    }
}

Now we will create a file named user_controller.ts, controller files will act as the intermediaries between route files and service files.

// src/controllers/user_controller.ts
import { Request, Response } from "express"
import { User } from "@prisma/client"
import * as userService from '../services/user_service'

export const getUsers = async (req: Request, res: Response<User[] | string>) => {
    try {
        const users = await userService.getUsers()
        res.status(200).json(users)
      } catch (err) {
        res.status(404).json('Items not found')
      }
}

export const getUserById = async (req: Request, res: Response<User | string>) => {
    const { id } = req.params
    try {   
      const user = await userService.getUserById(id)
      if (!user) {
        res.status(404).json('Item not found');
      } else {
        res.status(200).json(user);
      }
    } catch (error: any) {
      res.status(404).json(error.message)
    }
}

export const createUser = async (req: Request, res: Response) => {
    try {
        const newUser = await userService.createUser(req.body)
        res.status(201).json(newUser)
    } catch (error: any) {
        res.status(400).json(error.message)
    }
}

export const updateUserById = async (req: Request, res: Response) => {
    const { id } = req.params
    try {
      const user = await userService.updateUserById(id, req.body)
      res.status(200).json(user)
    } catch (error: any) {
      res.status(404).json(error.message)
    }
}

export const deleteUserById = async (req: Request, res: Response) => {
    const { id } = req.params
    try {
        await userService.deleteUserById(id)
        res.sendStatus(204)
    } catch (error: any) {
      res.status(400).json(error.message)
    }
}

Now that we have taken out major code blocks from routes in index.ts to user_controller.ts file and user_service.ts file. We will now create a new file named user_route.ts inside the folder routes.

// src/routes/user_route.ts
import { Router } from 'express'
import { getUsers, 
    getUserById, 
    createUser, 
    updateUserById, 
    deleteUserById 
} from '../controllers/user_controller'
import { validateSchema } from '../middlewares/validation_middleware'
import { CreateUserSchema } from '../models/user_model'

const route = Router()

route.get('/', getUsers)

route.get('/:id', getUserById)

route.post('/', validateSchema(CreateUserSchema.strict()), createUser)

route.put('/:id', validateSchema(CreateUserSchema.strict()), updateUserById)

route.delete('/:id', deleteUserById)

export default route

We have now simplified our index.ts file by doing all the abstractions and refactoring. The user_route.ts is now a small file with easy to read code.

Now the flow of code for the api/v1/users/{userId} route is, we first call the getUserById function provided by user_controller. And then the user_controller will call the getUserById function provided by user_service.

Next, let's add the validation middleware which uses Zod for validating the JSON body input for create user and update user routes.

Inside the folder middlewares, create a file named validation_middleware.ts.

// src/middlewares/validation_middleware.ts
import { Request, Response, NextFunction } from "express";
import * as z from "zod";

export const validateSchema = (schema: z.Schema<any>) => (req: Request, res: Response, next: NextFunction) => {
    try {
      schema.parse(req.body);
      next();
    } catch (e: any) {
      res.status(400).json({ error: e.errors });
    }
  };

Now we have the validation code housing in the middlewares folder, this way we are decluttering out the index.ts file.

Okay, so now we have the main files created and put in the folders. Next, let's create a new file named db_connect.ts inside the folder utils.

import { PrismaClient } from '@prisma/client';

const db = new PrismaClient();

export default db;

This file will connect to a database or in some cases databases and will expose the client interface to use in other parts of the application.

We now have all the folder structuring and refactoring done.

Now our index.ts should look like below with a few changes here and there.

// src/index.ts
import dotenv from 'dotenv'
import express, { type Application } from 'express'
import userRoute from './routes/user_route'
import db from './utils/db_connect'

dotenv.config()

const app: Application = express()

const router = express.Router()

app.use(express.json({ limit: '100mmb' }))

app.use('/api/v1', router)

router.use('/users', userRoute)

app.listen(process.env.PORT, () => {
  db.$connect().then(() => {
    console.log('Database connected')
    return console.log(`Server running on port ${process.env.PORT}`)
  }).catch((err) => {
    console.log('Database connection failed', err)
    return process.exit(1)
  })
})

Here we have set a route to use the user route we created in the file user_route.ts for any path that starts with /users.

Also in app.listen we check if our database connection was successfully established or not. In case of a failed database connection, we will exit the process.

And that's it, we now have a folder structure letting us separate the concerns and also letting us jump between the files in a more modular way.