Subscribe
Aug 08, 2023
10 min read
RESTexpress.jsarchitecture

Architecting REST API

In this article, we delve into crafting a REST API using the 3-tier architecture, emphasizing its layered design. We also explore the merits of a component-based folder structure for enhanced clarity and management.

Build a MEAN web app - Article Series

Introduction

In the rapidly changing landscape of web development, there's an increasing emphasis on building applications that are not only robust and scalable but also maintainable. The key to achieving this lies in adopting architectural patterns that foster clarity and intuitive code organization. The 3-tier architecture is divided into three strategic layers: Presentation, Business Logic, and Data Access. With Express.js, one of Node.js's premier web application frameworks, the result is a powerhouse for streamlined RESTful API development. Yet, the journey doesn't end there. A component-based folder structure elevates this setup, promoting enhanced code reusability and a systematic approach to organizing functionalities.

While Express.js is praised for its flexibility, arising from its unopinionated design, this trait can sometimes be a double-edged sword. Without a defined structure, it's easy to spiral into a realm of inconsistent and tangled codebases, especially in collaborative environments. Fear not; this article is crafted with a singular purpose: to steer you towards leveraging the 3-tier architecture and a component-based directory seamlessly within your Express.js REST API projects.

Structured Layering: 3-Tier Architecture

3-tier architecture for REST API implementation

The 3-Tier Architecture, or 3-Layer Architecture, is a design pattern that divides an application into three functional areas or tiers. This separation into distinct layers creates a robust framework that allows developers to modify or add to a layer without impacting the rest of the system. The three primary tiers usually include:

  • The Routing Layer is the application's front end, handling user interactions within a REST API framework by efficiently managing HTTP requests and responses. It understands user requests and directs them to the appropriate service handlers, ensuring smooth communication between the user and the system. This layer carefully verifies the authenticity of incoming client requests, comparing them to established standards, before passing them on to the service layer, reducing the chances of errors. It bridges the client and service layer, improving communication and the system's efficiency.
  • The Service Layer is a vital part of your application, handling user requests from the routing layer. This layer manages and interprets each request, selecting the necessary service operations. It also coordinates with the Data Access Layer to fetch, modify, add, or remove data. Finally, the Service Layer prepares the data to be returned to the client through the Routing Layer.

  • The Data Access Layer bridges your application and its underlying data storage, such as databases like MongoDB. It executes CRUD (Create, Read, Update, Delete) operations when equipped with repositories, queries, and data connections.

In the context of building a REST API using Express.js, the 3-layer architecture often makes more sense, and here's why:

  • Simplified Complexity: The 3-tier architecture's inherent simplicity is a significant benefit when designing a REST API in Express.js, as it aligns seamlessly with the RESTful design principle of providing an uncomplicated interface for server-side resource management. This architecture simplifies the API design by minimizing unnecessary complexity and optimizes the path between client requests and server responses, making the overall system easier to comprehend, implement, and use.

  • Defined Responsibilities and Enhanced Maintainability: In the 3-tier architecture, each layer, namely the Routing, Service, and Data Access Layers, have distinct responsibilities such as handling client requests, managing business rules, and directly interacting with the data source, respectively. This explicit separation of concerns significantly streamlines debugging and maintenance, as developers can modify one layer without inadvertently impacting others, thus enhancing the overall maintainability of the codebase.

  • Seamless Alignment with REST Principles: The principles of RESTful design strongly advocate for stateless communication and resource representation. This philosophy links seamlessly with the 3-tier architecture. The model provides an explicit and efficient path from the HTTP request to the application's business logic and data storage. This alignment enhances the API's conformity with REST principles and elevates the overall quality of the REST API design.

  • Scalability: Lastly, the 3-tier architecture does not restrict scalability. By isolating the business logic from data access and routing, developers can scale each application part independently if needed. This architectural flexibility is a significant advantage when dealing with the growth of the application and increased demand.

The 3-layer architecture in Express.js is an excellent choice for building a REST API. It separates concerns, making the codebase more modular, reusable, and scalable. This approach simplifies development and testing, making it a top choice for various applications.

Dependency Management and Flow

3-tier architecture - Dependency Flow

Managing dependencies is critical for maintaining a clean, testable, scalable codebase. In the 3-Tier Architecture, understanding how dependencies flow and interact across the Routing Layer (Presentation Layer in other contexts), Service Layer (Business Logic Layer), and Data Access Layer can be critical to developing efficient applications.

  • Routing Layer (Presentation Layer): In a RESTful API, the routing layer directs incoming HTTP requests to the appropriate service handlers. Dependencies at this layer may include middleware functions for handling request and response objects and services from the Service Layer. Here, it is crucial to keep the dependencies minimal to maintain the layer's primary focus - routing and minor data formatting.
  • Service Layer (Business Logic Layer): This layer manages the core application logic. It integrates validation tools, business rule modules, and data access objects. Proper dependency management here ensures clarity and ease of maintenance.
  • Data Access Layer: This layer interacts directly with the database or other data sources. It will depend on modules and libraries related to database access, such as ORM libraries (like Mongoose or Sequelize), database drivers, and perhaps caching systems. This layer should not have upward dependencies to prevent tight coupling with business logic or routing concerns.

It is important to note that dependencies must align with the control flow, which means they should point inward. Precisely, dependencies must move from the Routing Layer to the Service Layer and then to the Data Access Layer. This rule, known as the "Dependence Rule", is a fundamental principle of Clean Architecture. It guarantees that high-level policies like business rules are not dependent on low-level details like database access.

Folder Structure in Express APIs

Organizing code well-structured and meaningfully is crucial for a software project's maintainability, scalability, and comprehensibility. Two prominent patterns in organizing code are folder structure by Technical Role and folder structure by Business Component. Let's dive into each.

Based on Technical Role

In this approach, the codebase is organized based on its technical function within the application. Common directory names you might encounter include controllers, models, routes, services, and dao; Each directory group files based on their primary role.

|-- api/
|   |-- controllers/
|   |   |-- project-controller.ts
|   |   |-- task-controller.ts
|   |-- dao/
|   |   |-- project-dao.ts
|   |   |-- task-dao.ts
|   |-- models/
|   |   |-- project-model.ts
|   |   |-- task-model.ts
|   |-- routes/
|   |   |-- project-routes.ts
|   |   |-- task-routes.ts
|   |-- services/
|   |   |-- project-service.ts
|   |   |-- task-service.ts
|-- app.ts
|-- index.ts
|-- api/
|   |-- controllers/
|   |   |-- project-controller.ts
|   |   |-- task-controller.ts
|   |-- dao/
|   |   |-- project-dao.ts
|   |   |-- task-dao.ts
|   |-- models/
|   |   |-- project-model.ts
|   |   |-- task-model.ts
|   |-- routes/
|   |   |-- project-routes.ts
|   |   |-- task-routes.ts
|   |-- services/
|   |   |-- project-service.ts
|   |   |-- task-service.ts
|-- app.ts
|-- index.ts

Structuring by technical role provides an intuitive and consistent layout, allowing developers to identify component roles quickly. Its predictability is further enhanced as many frameworks adopt or recommend this approach, easing the onboarding process for new team members.

However, its primary drawback is the potential obscurity of business context, making it challenging to relate parts to specific business functionalities. This structure can become cumbersome and hard to manage as the application expands. Developers familiar with traditional MVC might find it unfamiliar, and it demands a solid grasp of the application's business domains.

Based on Business Component

In contrast, structuring by component groups code based on the specific business feature or domain it addresses. Here, directories might be named after the major features of your application, such as project and task. Each of these directories would then contain all technical roles related to that feature.

|-- api/
|   |-- components/
|   |   |-- project/
|   |   |   |-- controller.ts
|   |   |   |-- dao.ts
|   |   |   |-- model.ts
|   |   |   |-- routes.ts
|   |   |   |-- service.ts
|   |   |-- task/
|   |   |   |-- controller.ts
|   |   |   |-- dao.ts
|   |   |   |-- model.ts
|   |   |   |-- routes.ts
|   |   |   |-- service.ts
|-- app.ts
|-- index.ts
|-- api/
|   |-- components/
|   |   |-- project/
|   |   |   |-- controller.ts
|   |   |   |-- dao.ts
|   |   |   |-- model.ts
|   |   |   |-- routes.ts
|   |   |   |-- service.ts
|   |   |-- task/
|   |   |   |-- controller.ts
|   |   |   |-- dao.ts
|   |   |   |-- model.ts
|   |   |   |-- routes.ts
|   |   |   |-- service.ts
|-- app.ts
|-- index.ts

Structuring by business domain aligns seamlessly with the application's business logic, offering clarity to all parties. This approach promotes scalability, introducing new components without overwhelming existing folders. Collaborative efforts benefit, as teams can work on distinct components without overlap. The design remains modular and decoupled, enhancing maintainability by minimizing unintended interdependencies. Additionally, developers find navigating and understanding specific features more intuitive.

On the downside, initiating this structure can be challenging, mainly if the business domain needs clarification. There's also a risk of inconsistent component structuring, which might disrupt the uniformity of the codebase.

Based on the information provided, what are the reasons for choosing to organize by Business Components? Here are some convincing factors:

  • Enhanced Modularity: Breaking down the application by components ensures each module is self-contained with its responsibilities. This promotes better code isolation and easier debugging, as issues within a component can be addressed without interfering with other application parts.
  • Clear Business Context: Structuring by component intuitively aligns with business functionalities. When developers need to work on a particular feature or functionality, everything related to that component - from routes to data access - is located in one place, reducing the time spent searching for related files.
  • Scalability: As the application grows, adding new features or components becomes seamless. Each new component can be slotted into the existing structure without disrupting the established flow, ensuring the application remains maintainable even as its complexity increases.
  • Facilitated Collaboration: When multiple developers work on different components, the risk of merge conflicts is reduced. Each developer can work within their designated component folder, making parallel development more manageable.
  • Testing Boundaries: Components can be individually tested, making focusing on their unique business logic simpler during the testing phase.

Technical Role structuring provides function-based organization, whereas Business Components structure reflects business operations, enhancing scalability and maintainability. The choice depends on the project's nuances. However, the Business Components approach is often more beneficial for projects with distinct business components and modular expansion.

Practical Implementation

Within your "src" folder, create an "api" directory. This will house your application's primary components and associated elements like middleware. Under the "api" directory, create a "components" sub-directory. Each component of your application, for instance, "task", will reside in its distinct directory.

The "task" directory will contain all the files related to our task component, such as routes, services, data access objects (DAO), and models.

Start by defining your Task model in a "model.ts" file. This file describes the structure of a task, including properties such as id, name, description, and status. No dependencies are injected into this file as it merely defines the data structure.

src/api/components/task/model.ts
type Task = {
	id: string;
	name: string;
	description: string;
	status: 'To Do' | 'In Progress' | 'Done';
};
 
export default Task;
src/api/components/task/model.ts
type Task = {
	id: string;
	name: string;
	description: string;
	status: 'To Do' | 'In Progress' | 'Done';
};
 
export default Task;

Next, create a "dao.ts" file. This file includes functions to interact with your data sources. In our example, we're using hard-coded tasks in an array. However, in a real-world application, you would replace these with functions that interact with your database (We'll do so when we introduce MongoDB and Mongoose). This file has only the model as its dependency, but you can also remove the model file and use it directly in the "dao.ts" file.

src/api/components/task/dao.ts
import Task from './model';
 
const tasks: Task[] = [
	{
		id: 'task-001',
		name: 'Implement Authentication',
		description: 'Create the authentication module with JWT for user login.',
		status: 'In Progress'
	},
	{
		id: 'task-002',
		name: 'Design Database Schema',
		description: 'Design the database schema for the product catalog and user profiles.',
		status: 'To Do'
	},
	{
		id: 'task-003',
		name: 'Fix Navigation Bug',
		description: 'Resolve the navigation bug that occurs on mobile devices in landscape mode.',
		status: 'Done'
	}
];
 
const find = (): Task[] => {
	return tasks;
};
 
const findById = (id: string): Task | undefined => {
	return tasks.find((task) => task.id === id);
};
 
const findByStatus = (status: 'To Do' | 'In Progress' | 'Done'): Task[] => {
	return tasks.filter((task) => task.status === status);
};
 
export { find, findById, findByStatus };
src/api/components/task/dao.ts
import Task from './model';
 
const tasks: Task[] = [
	{
		id: 'task-001',
		name: 'Implement Authentication',
		description: 'Create the authentication module with JWT for user login.',
		status: 'In Progress'
	},
	{
		id: 'task-002',
		name: 'Design Database Schema',
		description: 'Design the database schema for the product catalog and user profiles.',
		status: 'To Do'
	},
	{
		id: 'task-003',
		name: 'Fix Navigation Bug',
		description: 'Resolve the navigation bug that occurs on mobile devices in landscape mode.',
		status: 'Done'
	}
];
 
const find = (): Task[] => {
	return tasks;
};
 
const findById = (id: string): Task | undefined => {
	return tasks.find((task) => task.id === id);
};
 
const findByStatus = (status: 'To Do' | 'In Progress' | 'Done'): Task[] => {
	return tasks.filter((task) => task.status === status);
};
 
export { find, findById, findByStatus };

The "service.ts" file interacts with the DAO to manipulate the data. Here, the DAO is used as a dependency so that the service layer interacts with it to perform necessary operations. The service layer may contain additional processing logic before forwarding the data to the controller.

src/api/components/task/service.ts
import * as dao from './dao';
 
const getTasks = () => {
	return dao.find();
};
 
const getTaskById = (id: string) => {
	return dao.findById(id);
};
 
const getTasksByStatus = (status: 'To Do' | 'In Progress' | 'Done') => {
	return dao.findByStatus(status);
};
 
export { getTasks, getTaskById, getTasksByStatus };
src/api/components/task/service.ts
import * as dao from './dao';
 
const getTasks = () => {
	return dao.find();
};
 
const getTaskById = (id: string) => {
	return dao.findById(id);
};
 
const getTasksByStatus = (status: 'To Do' | 'In Progress' | 'Done') => {
	return dao.findByStatus(status);
};
 
export { getTasks, getTaskById, getTasksByStatus };

The "controller.ts" file communicates with the service layer to retrieve or modify data. The outcomes are then conveyed to the client. The service is included in this file as a dependency on the controller. The controller offers functions that manage different tasks, such as retrieving all tasks or a specific task by its id.

src/api/components/task/controller.ts
import * as service from './service';
 
const getTasks = () => {
	const tasks = service.getTasks();
	return tasks;
};
 
const getTask = (id: string | undefined) => {
	if (!id) {
		// you can do whatever process you like, ex: throw an error
		return;
	}
	const task = service.getTaskById(id!);
	return task;
};
 
const getTasksByStatus = (status: string) => {
	if (!status || !['To Do', 'In Progress', 'Done'].includes(status)) {
		// you can do whatever process you like, ex: throw an error
		return;
	}
	const tasks = service.getTasksByStatus(status as 'To Do' | 'In Progress' | 'Done');
	return tasks;
};
 
export { getTasks, getTask, getTasksByStatus };
src/api/components/task/controller.ts
import * as service from './service';
 
const getTasks = () => {
	const tasks = service.getTasks();
	return tasks;
};
 
const getTask = (id: string | undefined) => {
	if (!id) {
		// you can do whatever process you like, ex: throw an error
		return;
	}
	const task = service.getTaskById(id!);
	return task;
};
 
const getTasksByStatus = (status: string) => {
	if (!status || !['To Do', 'In Progress', 'Done'].includes(status)) {
		// you can do whatever process you like, ex: throw an error
		return;
	}
	const tasks = service.getTasksByStatus(status as 'To Do' | 'In Progress' | 'Done');
	return tasks;
};
 
export { getTasks, getTask, getTasksByStatus };

The "routes.ts" file handles HTTP requests and directs them to the appropriate controller functions. The file exports an array of route objects containing a path, method, and handler. In this file, the controller is used as a dependency to route handlers.

src/api/components/task/routes.ts
import { Request, Response } from 'express';
import * as controller from './controller';
 
const routes = [
	{
		path: '/tasks',
		method: 'get',
		handler: (req: Request, res: Response) => {
			const data = controller.getTasks();
			res.status(200).json(data);
		}
	},
	{
		path: '/tasks/:id',
		method: 'get',
		handler: (req: Request, res: Response) => {
			const { id } = req.params;
			const data = controller.getTask(id);
			res.status(200).json(data);
		}
	},
	{
		path: '/tasks/status/:status',
		method: 'get',
		handler: (req: Request, res: Response) => {
			const { status } = req.params;
			const data = controller.getTasksByStatus(status!);
			res.status(200).json(data);
		}
	}
];
 
export default routes;
src/api/components/task/routes.ts
import { Request, Response } from 'express';
import * as controller from './controller';
 
const routes = [
	{
		path: '/tasks',
		method: 'get',
		handler: (req: Request, res: Response) => {
			const data = controller.getTasks();
			res.status(200).json(data);
		}
	},
	{
		path: '/tasks/:id',
		method: 'get',
		handler: (req: Request, res: Response) => {
			const { id } = req.params;
			const data = controller.getTask(id);
			res.status(200).json(data);
		}
	},
	{
		path: '/tasks/status/:status',
		method: 'get',
		handler: (req: Request, res: Response) => {
			const { status } = req.params;
			const data = controller.getTasksByStatus(status!);
			res.status(200).json(data);
		}
	}
];
 
export default routes;

Now that we created our files, we still need to plug the routes into the express Router to access them. As of now, attempting to navigate to http://localhost:4000/api/tasks would greet you with a "Cannot GET /api/tasks" alert since our Express server doesn't know about these routes yet.

A conventional solution is manually importing the Router from Express and registering our routes in the routes.ts file. Here's a simplified example of how you could do this:

import { Router } from 'express';
import controller from './controller';
 
const router = Router();
 
router.get('/tasks', controller.getTasks);
...
...
 
export default router;
import { Router } from 'express';
import controller from './controller';
 
const router = Router();
 
router.get('/tasks', controller.getTasks);
...
...
 
export default router;

You can then bring in this Router and integrate it with your Express app as follows:

app.use('/api', tasksRouter);
app.use('/api', tasksRouter);

Now, imagine that you have multiple components, each with a handful of routes. It would become tedious to register each route manually. This is where dynamic route loading can be very helpful.

To streamline the organization of your component, create an index.ts file inside the task folder; this file serves as the entry point for your component. This is where other related parts of the task routing, such as middleware, could be exported. For the current implementation, focus will be placed on exporting the routes.

src/api/components/task/index.ts
import routes from './routes';
 
export default routes;
src/api/components/task/index.ts
import routes from './routes';
 
export default routes;

Before we start with the process of routes loading, I want to shift to a more organized server structure. As your application expands, the once-simple app.ts file can become cluttered. To maintain clarity, consider introducing a providers folder. This structure separates configurations, making the server more modular and easier to manage.

The providers folder typically includes foundational parts of your application, which aren't necessarily tied to any specific feature but are essential for the functioning of your app. This can include services like database connections, authentication providers, third-party integrations, etc.

Within the providers folder, create a server sub-folder to house the specific logic related to your Express.js server. The server folder can include configuration for your express application, middleware registration, error handling, route registration, and even the code to start the server.

src/
├── api/
│   ├── components/
│   │   ├── task/
├── providers/
│   ├── server/
│   │   ├── index.ts
│   │   ├── components.ts
│   │   └── helper.ts
└── index.ts
src/
├── api/
│   ├── components/
│   │   ├── task/
├── providers/
│   ├── server/
│   │   ├── index.ts
│   │   ├── components.ts
│   │   └── helper.ts
└── index.ts

Create a components.ts file within the server folder. This file is a registry for all the application's components, such as task.

src/providers/server/components.ts
export type Component = string | NestedComponents;
 
export interface NestedComponents {
	[key: string]: Component;
}
 
const components: NestedComponents = {
	task: 'task',
	/* for nested components
	users: {
		roles: 'roles',
		auth: 'auth'
	}*/
};
 
export default components;
src/providers/server/components.ts
export type Component = string | NestedComponents;
 
export interface NestedComponents {
	[key: string]: Component;
}
 
const components: NestedComponents = {
	task: 'task',
	/* for nested components
	users: {
		roles: 'roles',
		auth: 'auth'
	}*/
};
 
export default components;

In this setup, a Component can be a string that identifies the name of a folder or a NestedComponents object for nested Components. The NestedComponents interface maps folder names to corresponding Component values, accommodating basic and complex structures. This design makes it effortless to integrate new components into dynamic routing.

Let's create a "helper.ts" file containing utility functions that help dynamically load your components and register their routes with an Express Router.

src/providers/server/helper.ts
import { Request, Response, Router } from 'express';
import { NestedComponents } from './components';
 
type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'patch';
export type Module = { default: Route[] };
 
interface Route {
	path: string;
	method: HttpMethod;
	handler: (req: Request, res: Response) => void;
}
 
const validMethods: HttpMethod[] = ['get', 'post', 'put', 'delete', 'patch'];
 
const getPaths = (components: NestedComponents, parentKey = ''): string[] => {
	return Object.keys(components).reduce((acc, key) => {
		const fullPath = parentKey ? `${parentKey}/${key}` : key;
		const value = components[key];
		if (typeof value === 'object' && value) {
			acc.push(...getPaths(value, fullPath));
		} else {
			acc.push(fullPath);
		}
		return acc;
	}, [] as string[]);
};
 
const registerRoutes = (router: Router, module: Module) => {
	if (!Array.isArray(module.default)) {
		throw new Error(`Invalid module`);
	}
	const routes = module.default;
	for (const route of routes) {
		const method = route.method.toLowerCase() as HttpMethod;
		if (validMethods.includes(method)) {
			router[method](route.path, route.handler);
		} else {
			throw new Error(`Invalid method: ${method}`);
		}
	}
};
 
export { getPaths, registerRoutes };
src/providers/server/helper.ts
import { Request, Response, Router } from 'express';
import { NestedComponents } from './components';
 
type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'patch';
export type Module = { default: Route[] };
 
interface Route {
	path: string;
	method: HttpMethod;
	handler: (req: Request, res: Response) => void;
}
 
const validMethods: HttpMethod[] = ['get', 'post', 'put', 'delete', 'patch'];
 
const getPaths = (components: NestedComponents, parentKey = ''): string[] => {
	return Object.keys(components).reduce((acc, key) => {
		const fullPath = parentKey ? `${parentKey}/${key}` : key;
		const value = components[key];
		if (typeof value === 'object' && value) {
			acc.push(...getPaths(value, fullPath));
		} else {
			acc.push(fullPath);
		}
		return acc;
	}, [] as string[]);
};
 
const registerRoutes = (router: Router, module: Module) => {
	if (!Array.isArray(module.default)) {
		throw new Error(`Invalid module`);
	}
	const routes = module.default;
	for (const route of routes) {
		const method = route.method.toLowerCase() as HttpMethod;
		if (validMethods.includes(method)) {
			router[method](route.path, route.handler);
		} else {
			throw new Error(`Invalid method: ${method}`);
		}
	}
};
 
export { getPaths, registerRoutes };

Here, HttpMethod refers to a category that includes all acceptable HTTP methods. Meanwhile, Route is an interface that defines a route's format, including its path, HTTP method, and handler function.The moduleis a type that represents the structure of an imported component module. It expects the module to export an array of Route objects as its default export.

getPaths is a recursive function that takes a NestedComponents object and builds an array of paths to all components, considering nested component structures.

registerRoutestakes an Express Router** and a Moduleand registers all of the module's routes with theRouter`.

Finally, create "index.ts" file, the entry point for starting your server. It imports your components and helper functions, then uses them to dynamically load your components, register their routes, and start your Express server.

src/providers/server/index.ts
import express, { Router } from 'express';
import { Module, getPaths, registerRoutes } from './helper';
import components from './components';
 
const COMPONENTS_DIRECTORY = '../../api/components';
const BASE_API_PATH = '/api';
const PORT = 4000;
 
const start = async () => {
	const app = express();
	const router = Router();
	const componentsPath = getPaths(components);
	try {
		const modulePromises: Promise<Module>[] = componentsPath?.map(
			(path) => import(`${COMPONENTS_DIRECTORY}/${path}`)
		);
		const modulesList = await Promise.all(modulePromises);
		for (const module of modulesList || []) {
			try {
				registerRoutes(router, module);
			} catch (error) {
				if (error instanceof Error) {
					// eslint-disable-next-line no-console
					console.log(error.message);
				}
			}
		}
	} catch (error) {
		if (error instanceof Error) {
			// eslint-disable-next-line no-console
			console.log(error.message);
		}
	}
 
	app.use(BASE_API_PATH, router);
	app.listen(PORT, () => {
		// eslint-disable-next-line no-console
		console.info('Server running on port 4000...');
	});
};
 
export { start };
src/providers/server/index.ts
import express, { Router } from 'express';
import { Module, getPaths, registerRoutes } from './helper';
import components from './components';
 
const COMPONENTS_DIRECTORY = '../../api/components';
const BASE_API_PATH = '/api';
const PORT = 4000;
 
const start = async () => {
	const app = express();
	const router = Router();
	const componentsPath = getPaths(components);
	try {
		const modulePromises: Promise<Module>[] = componentsPath?.map(
			(path) => import(`${COMPONENTS_DIRECTORY}/${path}`)
		);
		const modulesList = await Promise.all(modulePromises);
		for (const module of modulesList || []) {
			try {
				registerRoutes(router, module);
			} catch (error) {
				if (error instanceof Error) {
					// eslint-disable-next-line no-console
					console.log(error.message);
				}
			}
		}
	} catch (error) {
		if (error instanceof Error) {
			// eslint-disable-next-line no-console
			console.log(error.message);
		}
	}
 
	app.use(BASE_API_PATH, router);
	app.listen(PORT, () => {
		// eslint-disable-next-line no-console
		console.info('Server running on port 4000...');
	});
};
 
export { start };

Since the start function has become asynchronous, update the index.ts in the src folder to await the server initialization.

src/providers/server/index.ts
import * as server from './providers/server';
 
(async () => {
	await server.start();
})();
src/providers/server/index.ts
import * as server from './providers/server';
 
(async () => {
	await server.start();
})();

To verify our API's functionality, access the endpoint that retrieves all tasks. Navigate to http://localhost:4000/api/tasks in your browser or use your preferred API development platform. I'm using Insomnia:

API testing usin Insomnia

Conclusion

The 3-tier architecture with a component-based folder setup in Express.js helps build a tidy and easy-to-manage REST API. Splitting the app into transparent layers and organizing them by business parts makes it reflect real-world needs and makes adding new stuff easier. Express gives a lot of freedom, and by adding this structured approach, we dodge potential messes and end up with a neat, expandable app. It's a handy roadmap for building something new or refactoring an old project.

You can find the complete code source in this repository; feel free to give it a star ⭐️.

If you want to keep up with this series, consider subscribing to my newsletter to receive updates as soon as I publish an article.