Refactoring a Trello Backend into a Scalable REST API Architecture

I'm Shubham (@shubhamsinghbundela), I'm a Software Engineer, a Full-stack developer, a tech enthusiast, and a technical writer here on @Hashnode. I have a strong zeal to share my acquired knowledge and I am also willing to learn from others.
Till now, the backend was working, but everything was in a single file / unstructured format.
Now the goal is to:
Follow clean folder structure
Make code scalable & maintainable
Before diving into this structured version, I recommend reading my initial implementation where everything was built in a single file: Building a Trello - Backend from Scratch → https://shubhamsinghbundela.hashnode.dev/building-a-trello-backend-from-scratch
Step 1: Initialize Project
npm init -y
It Creates package.json
Step 2: Install Dependencies
npm install express
npm install --save-dev nodemon
npm install dotenv // This package is used to load environment variables from the .env file.
Step 3: Setup App Entry (src/app.js)
Create src/app.js
import express from "express";
const app = express();
app.use(express.json());
export default app;
This file is responsible for:
Initializing express app
Adding global middlewares
Step 4: Environment Variables
Create .env file:
PORT=3000
NODE_ENV=development
MONGODB_URI=your_mongodb_connection_string
Step 5: Server Entry Point (server.js)
create server.js
import "dotenv/config"; //This automatically loads .env variables into process.env
import app from "./src/app.js";
const PORT = process.env.PORT || 3000;
const start = async () => {
app.listen(PORT, () => {
console.log(`Server is running at \({PORT} in \){process.env.NODE_ENV} mode`);
});
};
start().catch((err) => {
console.error("Failed to start server", err);
process.exit(1);
});
This file is responsible for:
Starting the server
Handling startup errors
Step 6: Scripts Setup
Update package.json:
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
}
Use:
npm run dev
Step 7: Setup Database Connection
Create:
src/common/config/db.js
import mongoose from "mongoose";
import dns from "dns";
dns.setServers(["1.1.1.1", "8.8.8.8"]);
const connectDB = async () => {
const conn = await mongoose.connect(process.env.MONGODB_URI);
console.log(`MongoDB connected: ${conn.connection.host}`);
};
export default connectDB;
Initially, I tried connecting to MongoDB (cloud) but faced a DNS issue
Problem:
MongoDB connection was failing due to DNS resolution.
Fix:
After watching a YouTube video: , I added this:
const dns = require("dns");
dns.setServers(["1.1.1.1", "8.8.8.8"]);
This fixed the issue and I was able to connect to cloud MongoDB.
Step 8: Standard API Response Structure
Create:
src/common/utils/api-response.js
class ApiResponse {
static ok(res, message, data = null) {
return res.status(200).json({
success: true,
message,
data
});
}
}
export default ApiResponse;
Why This is Important
Instead of writing:
res.status(200).json({ message: "Success" });
To avoid repeating response logic across multiple APIs, I implemented a reusable utility using the DRY (Don't Repeat Yourself) principle:
ApiResponse.ok(res, "Success", data);
Step 9: Standard API Error Structure
While building APIs, handling errors properly is as important as handling success responses.
1**. Create Custom Error Class**
Create:
src/common/utils/api-error.js
class ApiError extends Error {
// Inherit from built-in JavaScript Error
constructor(statusCode, message) {
super(message);
this.statusCode = statusCode;
// Capture clean stack trace
Error.captureStackTrace(this, this.constructor);
}
static badRequest(message = "Bad Request") {
return new ApiError(400, message);
}
static forbidden(message = "Forbidden") {
return new ApiError(403, message);
}
static notFound(message = "Not Found") {
return new ApiError(404, message);
}
}
export default ApiError;
Why Extend Error?
JavaScript provides built-in errors like:
ErrorTypeErrorReferenceError
But they don’t include HTTP status codes, which are required in APIs.
2. Global Error Middleware
Create:
src/common/middleware/error.middleware.js
import ApiError from "../utils/api-error.js";
const errorHandler = (err, req, res, next) => {
// Handle known (custom) errors
if (err instanceof ApiError) {
return res.status(err.statusCode).json({
success: false,
message: err.message
});
}
// Handle unknown errors
return res.status(500).json({
success: false,
message: "Internal Server Error"
});
};
export default errorHandler;
3. Register Middleware in App
Update src/app.js:
import express from "express";
import authRoute from "./modules/auth/auth.routes.js";
import errorHandler from "./common/middleware/error.middleware.js";
const app = express();
app.use(express.json());
app.use(errorHandler);
export default app;
This must be the last middleware in the app.
Final Error Flow
Request → Route → Controller → Service → throws ApiError → Passes to errorHandler middleware → JSON response
Step 10: Authentication Module (Modular Structure)
Now that we have:
Clean folder structure
Database setup
Standard response & error handling
Next step is to build a modular authentication system
Module Structure
Inside src/modules/auth/, I created:
auth/
├── auth.controller.js
├── auth.middleware.js
├── auth.model.js
├── auth.routes.js
├── auth.service.js
Why This Structure?
Each layer has a clear responsibility:
Model → Database schema
Routes → Define API endpoints
Controller → Handle request/response
Service → Business logic
Middleware → Authentication / validation
This makes code clean, maintainable, and scalable
Create User Schema (auth.model.js)
import mongoose from "mongoose";
const userSchema = new mongoose.Schema({
username: String,
password: String
}, { timestamps: true });
const userModel = mongoose.model("users", userSchema);
export default userModel;
timestamps automatically adds:
createdAtupdatedAt
Register Auth Routes in App.js
Update src/app.js:
import express from "express";
import authRoute from "./modules/auth/auth.routes.js";
import errorMiddleware from "./common/middleware/error.middleware.js";
const app = express();
app.use(express.json());
// Register routes
app.use("/api/auth", authRoute);
// Global error handler
app.use(errorMiddleware);
export default app;
All auth APIs will now be available under:
/api/auth/*
Define Routes (auth.routes.js)
import { Router } from "express";
import * as controller from "./auth.controller.js";
const router = Router();
router.post("/signup", controller.signup);
export default router;
Controller Layer (auth.controller.js)
import ApiResponse from "../../common/utils/api-response.js";
import * as authService from "./auth.service.js";
const signup = async (req, res, next) => {
try {
const user = await authService.signup(req.body);
ApiResponse.ok(res, "User get Created", user);
} catch (error) {
next(error); // pass error to global error middleware
}
};
export { signup };
Responsibility of Controller
Calls service layer
Sends response using
ApiResponseKeeps logic minimal
Business Logic (auth.service.js)
import userModel from "./auth.model.js";
import ApiError from "../../common/utils/api-error.js";
const signup = async ({ username, password }) => {
// Step 1: Check if user already exists
const userExists = await userModel.findOne({ username });
if (userExists) {
throw ApiError.forbidden("User Already Exists");
}
// Step 2: Create new user
const newUser = await userModel.create({
username,
password
});
return newUser;
};
export {
signup
};
Final Result: Testing /api/auth/signup API Using Postman
Request Flow
POST /api/auth/signup
↓
auth.routes.js
↓
auth.controller.js
↓
auth.service.js
↓
MongoDB
Step 11: Implement Signin
Now that the signup API is ready, the next step is to allow users to login (signin) and generate a token for authentication.
Define Routes
Inside auth.route.js, we define the signin route:
import { Router } from "express";
import * as controller from "./auth.controller.js";
const router = Router();
router.post("/signup", controller.signup);
router.post("/signin", controller.signin);
export default router;
This creates an endpoint:
POST /api/auth/signin
Controller Layer
Now we handle the request in the controller.
const signin = async (req, res, next) => {
try {
const token = await authService.signin(req.body);
ApiResponse.ok(res, "Signin successfully", token);
} catch (error) {
next(error);
}
};
Responsibility of Controller:
Receive request data (
req.body)Call service layer
Send response using
ApiResponsePass errors to global error handler
Service Layer (Business Logic)
Now the actual authentication logic lives in auth.service.js:
const signin = async ({ username, password }) => {
const userExists = await userModel.findOne({
username: username,
password: password,
});
if (!userExists) {
throw ApiError.notFound("User not found");
}
const token = jwt.sign(
{
userId: userExists.id,
},
"shubham123"
);
return token;
};
Final Result: Testing /api/auth/signin API Using Postman
Step 12: Create organisation inside Organisation Module
Now that authentication is complete, the next step is to allow users to create organisations — similar to how Trello works.
Requirements
After login, a user should be able to:
Create an organisation
Automatically become the admin
Ensure organisation names are unique
Only authenticated users can create organisations
Module Structure
Inside src/modules/, create a new folder:
src/modules/org/
├── org.controller.js
├── org.middleware.js
├── org.model.js
├── org.routes.js
├── org.service.js
Create Organisation Schema
Create:
src/modules/org/org.model.js
import mongoose from "mongoose";
const orgSchema = new mongoose.Schema({
orgName: "String",
description: "String",
admin: mongoose.Types.ObjectId,
member: [mongoose.Types.ObjectId]
}, { timestamps: true });
const orgModel = mongoose.model("organisations", orgSchema);
export default orgModel;
Authentication Middleware
To ensure only logged-in users can create organisations:
Inside:
src/modules/auth/auth.middleware.js
import jwt from "jsonwebtoken";
const authMiddleware = (req, res, next) => {
const token = req.headers.token;
const decode = jwt.verify(token, "shubham123");
req.userId = decode.userId;
next();
};
export default authMiddleware;
Register Organisation Routes
Update src/app.js:
import orgRoute from "./modules/org/org.routes.js";
app.use("/api/org", orgRoute);
Now all organisation APIs will be available under:
/api/org/*
Define Routes
Create:
src/modules/org/org.routes.js
import { Router } from "express";
import * as controller from "./org.controller.js";
import authMiddleware from "../auth/auth.middleware.js";
const router = Router();
router.post(
"/create-organisation",
authMiddleware,
controller.createOrganisation
);
export default router;
Controller Layer
Create:
src/modules/org/org.controller.js
import ApiResponse from "../../common/utils/api-response.js";
import * as orgService from "./org.service.js";
const createOrganisation = async (req, res, next) => {
try {
const org = await orgService.createOrganisation(req);
ApiResponse.ok(
res,
"Org get created",
org
);
} catch (error) {
next(error);
}
};
export { createOrganisation };
Responsibility of Controller
Calls service layer
Sends success response
Passes errors to global middleware
Business Logic (Service Layer)
Create:
src/modules/org/org.service.js
import ApiError from "../../common/utils/api-error.js";
import orgModel from "./org.model.js";
const createOrganisation = async (req) => {
// Step 1: Check if organisation already exists
const orgExist = await orgModel.findOne({
orgName: req.body.orgName
});
if (orgExist) {
throw ApiError.forbidden("Organisation already exists");
}
// Step 2: Create organisation
const newOrg = await orgModel.create({
orgName: req.body.orgName,
description: req.body.description,
admin: req.userId,
member: []
});
return newOrg;
};
export { createOrganisation };
Request Flow
POST /api/org/create-organisation
↓
auth.middleware.js (verify token)
↓
org.controller.js
↓
org.service.js
↓
MongoDB
↓
Response
Final Result: Testing api/org/create-organisation API Using Postman
Step 13: Add Members to Organisation
Now that organisations are created, the next step is to allow admins to add members — similar to how teams work in Trello.
Requirement
Admin can add users to their organisation
Users are added using their username
Only existing users can be added
Prevent adding the same user twice
Update Routes
Update:
src/modules/org/org.routes.js
import { Router } from "express";
import * as controller from "./org.controller.js";
import authMiddleware from "../auth/auth.middleware.js";
const router = Router();
router.post("/create-organisation", authMiddleware, controller.createOrganisation);
router.post("/add-member", authMiddleware, controller.addMember);
export default router;
Controller Layer
Update:
src/modules/org/org.controller.js
const addMember = async (req, res, next) => {
try {
const data = await orgService.addMember(req);
ApiResponse.ok(
res,
"Member get added",
data
);
} catch (error) {
next(error);
}
};
export {
addMember
};
Responsibility
Calls service layer
Sends response
Passes errors to middleware
Business Logic (Service Layer)
Update:
src/modules/org/org.service.js
import ApiError from "../../common/utils/api-error.js";
import orgModel from "./org.model.js";
import userModel from "../auth/auth.model.js";
const addMember = async (req) => {
const newMember = req.body.member;
// Step 1: Check user exists
const newMemberUser = await userModel.findOne({
username: newMember
});
if (!newMemberUser) {
throw ApiError.notFound("User not exist");
}
// Step 2: Find organisation (admin only)
const orgDetails = await orgModel.findOne({
admin: req.userId
});
if (!orgDetails) {
throw ApiError.notFound("Organisation not exists");
}
// Step 3: Prevent duplicate members
const memberExists = orgDetails.member.includes(newMemberUser._id);
if (memberExists) {
throw ApiError.badRequest("Member already exists in organisation");
}
// Step 4: Add member
orgDetails.member.push(newMemberUser._id);
await orgDetails.save();
return orgDetails;
};
export {
addMember
};
Request Flow
POST /api/org/add-member
↓
auth.middleware.js (verify token)
↓
org.controller.js
↓
org.service.js
↓
MongoDB
↓
Response
Final Result: Testing api/org/add-member API Using Postman
Step 14: Remove Member from Organisation
After adding members, the next important feature is allowing admins to remove members — similar to how team management works in Trello.
Requirement
Admins should be able to:
Remove users from the organisation
Only remove existing members
Only admin can perform this action
Update Routes
Update:
src/modules/org/org.routes.js
import { Router } from "express";
import * as controller from "./org.controller.js";
import authMiddleware from "../auth/auth.middleware.js";
const router = Router();
router.post("/create-organisation", authMiddleware, controller.createOrganisation);
router.post("/add-member", authMiddleware, controller.addMember);
router.delete("/delete-member", authMiddleware, controller.deleteMember);
export default router;
Controller Layer
Update:
src/modules/org/org.controller.js
const deleteMember = async (req, res, next) => {
try {
const data = await orgService.deleteMember(req);
ApiResponse.ok(
res,
"Member get deleted",
data
);
} catch (error) {
next(error);
}
};
export {
deleteMember
};
Business Logic (Service Layer)
Update:
src/modules/org/org.service.js
import ApiError from "../../common/utils/api-error.js";
import orgModel from "./org.model.js";
import userModel from "../auth/auth.model.js";
const deleteMember = async (req) => {
const deleteUser = req.body.username;
// Step 1: Check user exists
const deleteUserData = await userModel.findOne({
username: deleteUser
});
if (!deleteUserData) {
throw ApiError.notFound("User not exist");
}
// Step 2: Find organisation (admin only)
const orgDetails = await orgModel.findOne({
admin: req.userId
});
if (!orgDetails) {
throw ApiError.notFound("Organisation not exists");
}
// Step 3: Check if user is actually a member
const isMember = orgDetails.member.some(
id => id.toString() === deleteUserData._id.toString()
);
if (!isMember) {
throw ApiError.badRequest("User is not a member of this organisation");
}
// Step 4: Remove member
orgDetails.member = orgDetails.member.filter(
id => id.toString() !== deleteUserData._id.toString()
);
await orgDetails.save();
return orgDetails;
};
export {
deleteMember
};
Request Flow
DELETE /api/org/delete-member
↓
auth.middleware.js (verify token)
↓
org.controller.js
↓
org.service.js
↓
MongoDB
↓
Response
Final Result: Testing api/org/delete-member API Using Postman
Step 15: Create Board
Now comes the most important feature — boards — just like in Trello.
Requirement
Each organisation can have multiple boards
Only authorized users (admin) can create boards
Each board belongs to a specific organisation
Module Structure
Inside src/modules/, create:
board/
├── board.controller.js
├── board.middleware.js
├── board.model.js
├── board.routes.js
├── board.service.js
Create Board Schema
Create:
src/modules/board/board.model.js
import mongoose from "mongoose";
const boardSchema = mongoose.Schema({
boardName: "String",
organisationId: mongoose.Types.ObjectId
});
const boardModel = mongoose.model("boards", boardSchema);
export default boardModel;
Define Routes
Create:
src/modules/board/board.routes.js
import { Router } from "express";
import authMiddleware from "../auth/auth.middleware.js";
import * as controller from "./board.controller.js";
const router = Router();
router.post("/create-board", authMiddleware, controller.createBoard);
export default router;
Register Route in App
Update src/app.js:
import boardRoute from "./modules/board/board.routes.js";
app.use("/api/board", boardRoute);
Controller Layer
Create:
src/modules/board/board.controller.js
import ApiResponse from "../../common/utils/api-response.js";
import * as boardService from "./board.service.js";
const createBoard = async (req, res, next) => {
try {
const data = await boardService.createBoard(req);
ApiResponse.ok(
res,
"Board get created",
data
);
} catch (error) {
next(error);
}
};
export {
createBoard
};
Business Logic (Service Layer)
Create:
src/modules/board/board.service.js
import ApiError from "../../common/utils/api-error.js";
import orgModel from "../org/org.model.js";
import boardModel from "./board.model.js";
const createBoard = async (req) => {
const boardName = req.body.boardName;
// Step 1: Check organisation (admin only)
const orgDetails = await orgModel.findOne({
admin: req.userId
});
if (!orgDetails) {
throw ApiError.notFound("Organisation not exists");
}
// Step 2: Create board
const newBoard = await boardModel.create({
boardName,
organisationId: orgDetails._id
});
return newBoard;
};
export {
createBoard
};
Request Flow
POST /api/board/create-board
↓
auth.middleware.js (verify token)
↓
board.controller.js
↓
board.service.js
↓
MongoDB
↓
Response
Final Result: Testing api/board/create-board API Using Postman
Step 16: Task Management (Create Task)
Now comes the most important part of any Trello-like application — Tasks.
This is where users actually track their work.
Requirement
We want users to:
Create tasks inside a board
Update task status (Todo → In Progress → Done) (next step)
Delete tasks (next step)
Ensure tasks are tied to the logged-in user
This forms the core task lifecycle system
Module Structure
Inside src/modules/, create:
task/
├── task.controller.js
├── task.middleware.js
├── task.model.js
├── task.routes.js
├── task.service.js
Task Schema
Create:
src/modules/task/task.model.js
import mongoose from "mongoose";
const taskSchema = mongoose.Schema({
description: "String",
status: "String",
boardId: mongoose.Types.ObjectId,
userId: mongoose.Types.ObjectId,
});
const taskModel = mongoose.model("tasks", taskSchema);
export default taskModel;
Define Routes
Create:
src/modules/task/task.routes.js
import { Router } from "express";
import authMiddleware from "../auth/auth.middleware.js";
import * as controller from "./task.controller.js";
const router = Router();
router.post("/create-task", authMiddleware, controller.createTask);
export default router;
Register Route in App
Update src/app.js:
import taskRouter from "./modules/task/task.routes.js";
app.use("/api/task", taskRouter);
Controller Layer
Create:
src/modules/task/task.controller.js
import ApiResponse from "../../common/utils/api-response.js";
import * as taskService from "./task.service.js";
const createTask = async (req, res, next) => {
try {
const data = await taskService.createTask(req);
ApiResponse.ok(
res,
"Task created successfully",
data
);
} catch (error) {
next(error);
}
};
export {
createTask
};
Business Logic (Service Layer)
Create:
src/modules/task/task.service.js
import ApiError from "../../common/utils/api-error.js";
import boardModel from "../board/board.model.js";
import taskModel from "./task.model.js";
const createTask = async (req) => {
const { description, status, boardId } = req.body;
// Step 1: Validate input
if (!description || !status || !boardId) {
throw ApiError.badRequest("All fields are required");
}
// Step 2: Validate status
const allowedStatus = ["Todo", "In Progress", "Done"];
if (!allowedStatus.includes(status)) {
throw ApiError.badRequest("Invalid status");
}
// Step 3: Check board exists
const board = await boardModel.findById(boardId);
if (!board) {
throw ApiError.notFound("Board not found");
}
// Step 4: Prevent duplicate task in same board
const taskExists = await taskModel.findOne({
userId: req.userId,
description,
boardId
});
if (taskExists) {
throw ApiError.forbidden("Task already exists in this board");
}
// Step 5: Create task
const newTask = await taskModel.create({
userId: req.userId,
description,
status,
boardId
});
return newTask;
};
export {
createTask
};
Request Flow
POST /api/task/create-task
↓
auth.middleware.js (verify token)
↓
task.controller.js
↓
task.service.js
↓
MongoDB
↓
Response
Final Result: Testing api/task/create-task API Using Postman
Step 17: Update Task Status
Now that tasks can be created, the next step is to update their status — this is what enables the classic workflow of moving tasks across stages (Todo → In Progress → Done), just like in Trello.
Requirement
When updating a task:
Identify the task using
taskIdValidate the new
statusEnsure the task belongs to the logged-in user
Update the status
Define Route
Update:
src/modules/task/task.routes.js
import { Router } from "express";
import authMiddleware from "../auth/auth.middleware.js";
import * as controller from "./task.controller.js";
const router = Router();
router.post("/create-task", authMiddleware, controller.createTask);
router.put("/update-task", authMiddleware, controller.updateTask);
export default router;
Controller Layer
Update:
src/modules/task/task.controller.js
const updateTask = async (req, res, next) => {
try {
const data = await taskService.updateTask(req);
ApiResponse.ok(
res,
"Task updated successfully",
data
);
} catch (error) {
next(error);
}
};
export {
updateTask
};
Business Logic (Service Layer)
Update:
src/modules/task/task.service.js
import ApiError from "../../common/utils/api-error.js";
import taskModel from "./task.model.js";
const updateTask = async (req) => {
const { taskId, status } = req.body;
// Step 1: Validate status
const allowedStatus = ["Todo", "In Progress", "Done"];
if (!allowedStatus.includes(status)) {
throw ApiError.badRequest("Invalid status");
}
// Step 2: Find task (only owner can update)
const task = await taskModel.findOne({
_id: taskId,
userId: req.userId
});
if (!task) {
throw ApiError.notFound("Task not found");
}
// Step 3: Update status
task.status = status;
await task.save();
return task;
};
export {
updateTask
};
Request Flow
PUT /api/task/update-task
↓
auth.middleware.js (verify token)
↓
task.controller.js
↓
task.service.js
↓
MongoDB
↓
Response
Final Result: Testing api/task/update-task API Using Postman
Step 18: Delete Task
Now that tasks can be created and updated, the final step in the task lifecycle is deleting tasks — just like removing completed work in Trello.
Requirement
When deleting a task:
Identify the task using
taskIdEnsure the task belongs to the logged-in user
If it exists → delete it from the database
Define Route
Update:
src/modules/task/task.routes.js
import { Router } from "express";
import authMiddleware from "../auth/auth.middleware.js";
import * as controller from "./task.controller.js";
const router = Router();
router.post("/create-task", authMiddleware, controller.createTask);
router.put("/update-task", authMiddleware, controller.updateTask);
router.delete("/delete-task", authMiddleware, controller.deleteTask);
export default router;
Controller Layer
Update:
src/modules/task/task.controller.js
const deleteTask = async (req, res, next) => {
try {
const data = await taskService.deleteTask(req);
ApiResponse.ok(
res,
"Task deleted successfully",
data
);
} catch (error) {
next(error);
}
};
export {
deleteTask
};
Business Logic (Service Layer)
Update:
src/modules/task/task.service.js
import ApiError from "../../common/utils/api-error.js";
import taskModel from "./task.model.js";
const deleteTask = async (req) => {
const { taskId } = req.body;
// Step 1: Find task (only owner can delete)
const task = await taskModel.findOne({
_id: taskId,
userId: req.userId
});
if (!task) {
throw ApiError.notFound("Task not found");
}
// Step 2: Delete task
await taskModel.deleteOne({ _id: taskId });
return task;
};
export {
deleteTask
};
Request Flow
DELETE /api/task/delete-task
↓
auth.middleware.js (verify token)
↓
task.controller.js
↓
task.service.js
↓
MongoDB
↓
Response
What Changed from Previous Approach?
Before:
All logic in one file
Hard to scale
Now:
Clean modular structure
Separation of concerns
Reusable logic
Easy to maintain
Complete Source Code
I’ve uploaded the complete project on GitHub. You can check it here:
GitHub Repo:
https://github.com/shubhamsinghbundela/Rest-API-Trello




