Skip to content

Instantly share code, notes, and snippets.

@gpsarkar
Last active November 4, 2023 08:37
Show Gist options
  • Save gpsarkar/3e9283c6f9840a95fcacfb12ac6eb5c7 to your computer and use it in GitHub Desktop.
Save gpsarkar/3e9283c6f9840a95fcacfb12ac6eb5c7 to your computer and use it in GitHub Desktop.
node express based api project boilerplate

Best practices and modular structuring for an enterprise-level Node.js API project using TypeScript:

Step 1: Project Initialization and Dependency Installation

mkdir my-typescript-api
cd my-typescript-api
npm init -y
npm install typescript express dotenv helmet cors
npm install --save-dev @types/node @types/express @types/cors ts-node nodemon eslint
npm install --save-dev jest ts-jest @types/jest supertest @types/supertest

Step 2: TypeScript Configuration

Create a tsconfig.json file:

{
  "compilerOptions": {
    "target": "ES2018",
    "module": "commonjs",
    "lib": ["esnext"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "**/*.spec.ts", "**/*.test.ts"]
}

Step 3: Project Structure

Organize the project files in a structured manner:

/my-typescript-api
|--/src
   |--/api
      |--/routes
         |-- index.ts
         |-- dataRoutes.ts
      |--/controllers
         |-- dataController.ts
      |--/models
         |-- dataModel.ts
      |--/services
         |-- dataService.ts
   |--/middlewares
      |-- errorHandler.ts
      |-- notFoundHandler.ts
   |--/utils
      |-- logger.ts
   |--/config
      |-- config.ts
   |-- index.ts
|--/tests
   |-- dataController.test.ts
|-- package.json
|-- tsconfig.json
|-- .env
|-- .env.example
|-- .eslintrc.json
|-- jest.config.js

Step 4: Environment Configuration

Use .env files for configuration, with .env.example as a template for required variables.

Step 5: Implementing Best Practices

  • Secure headers with Helmet.
  • Configure CORS appropriately.
  • Parse JSON and URL-encoded data with Express middlewares.
  • Centralize configuration management in a config.ts file.
  • Create a robust logging utility with logger.ts.
  • Define error handling middlewares: errorHandler.ts and notFoundHandler.ts.
  • Write modular and scalable code by separating concerns into models, services, controllers, and routes.

Step 6: Scripts for Development and Operations

Add scripts to package.json for development, testing, linting, building, and production:

"scripts": {
  "start": "node dist/index.js",
  "build": "tsc",
  "dev": "nodemon src/index.ts",
  "watch": "tsc -w",
  "test": "jest",
  "lint": "eslint . --ext .ts",
  "prepare": "husky install"
}

Step 7: Testing and Linting

  • Use Jest and Supertest for writing unit and integration tests.
  • Configure ESLint for TypeScript code quality checks.

Step 8: API Documentation

  • Document code where necessary and use tools like Swagger for API endpoint documentation.

Step 9: Prepare for Production

  • Use a process manager like PM2 in production for process management.
  • Set up CI/CD pipelines for automated testing and deployment.

Step 10: Running the Application

  • During development, use npm run dev.
  • For production, compile the TypeScript code with npm run build and start the application with npm start.

This setup ensures that your project is not only well-structured but also adheres to good practices regarding security, maintainability, and scalability. The inclusion of a CI/CD pipeline, robust testing, and environment management positions your project for enterprise-level development and deployment.


boilerplate code

Code examples

/src/api/controllers/dataController.ts:

import { Request, Response } from 'express';
import { DataService } from '../services/dataService';

export class DataController {
  private dataService: DataService;

  constructor() {
    this.dataService = new DataService();
  }

  public getData = async (req: Request, res: Response): Promise<void> => {
    try {
      const data = await this.dataService.getAllData();
      res.json(data);
    } catch (error) {
      res.status(500).send(error.message);
    }
  };

  public postData = async (req: Request, res: Response): Promise<void> => {
    try {
      const { body } = req;
      const newData = await this.dataService.createData(body);
      res.status(201).json(newData);
    } catch (error) {
      res.status(500).send(error.message);
    }
  };

  // Additional methods for updating and deleting...
}

/src/api/routes/dataRoutes.ts:

import { Router } from 'express';
import { DataController } from '../controllers/dataController';

const router = Router();
const dataController = new DataController();

router.get('/data', dataController.getData);
router.post('/data', dataController.postData);

// Additional routes for updating and deleting...

export default router;

/src/index.ts:

import express, { Express, Request, Response } from 'express';
import helmet from 'helmet';
import cors from 'cors';
import dotenv from 'dotenv';
import dataRoutes from './api/routes/dataRoutes';
import { notFoundHandler } from './middlewares/notFoundHandler';
import { errorHandler } from './middlewares/errorHandler';
import logger from './utils/logger';

dotenv.config();

const app: Express = express();
const PORT: string | number = process.env.PORT || 3000;

app.use(helmet());
app.use(cors());
app.use(express.json());

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

app.use(notFoundHandler);
app.use(errorHandler);

app.listen(PORT, () => {
  logger.info(`Server is running at https://localhost:${PORT}`);
});

export default app;

/src/api/models/dataModel.ts:

// This is a simple TypeScript interface representing the data model.
// In a real-world scenario, you'd likely be interfacing with a database and thus would need a library such as TypeORM or Sequelize.
export interface Data {
  id: number;
  attribute: string;
}

/src/api/services/dataService.ts:

import { Data } from '../models/dataModel';

export class DataService {
  public async getAllData(): Promise<Data[]> {
    // Replace with actual database call
    return Promise.resolve([{ id: 1, attribute: 'Example' }]);
  }

  public async createData(data: Data): Promise<Data> {
    // Replace with actual database call
    return Promise.resolve({ ...data, id: Date.now() }); // Mocked data creation with current timestamp as ID
  }

  // Implement additional methods for update, delete, etc.
}

/src/middlewares/errorHandler.ts:

import { Request, Response, NextFunction } from 'express';

export const errorHandler = (error: Error, req: Request, res: Response, next: NextFunction): void => {
  res.status(500).json({
    message: error.message,
    stack: process.env.NODE_ENV === 'production' ? '🥞' : error.stack,
  });
};

/src/middlewares/notFoundHandler.ts:

import { Request, Response, NextFunction } from 'express';

export const notFoundHandler = (req: Request, res: Response, next: NextFunction): void => {
  res.status(404).json({ message: 'Resource not found' });
};

/src/utils/logger.ts:

// In a real-world application, you would probably use a more robust logging solution like Winston or Bunyan.
export default {
  info: (message: string): void => {
    if (process.env.NODE_ENV !== 'test') {
      console.log(message);
    }
  },
  error: (message: string): void => {
    if (process.env.NODE_ENV !== 'test') {
      console.error(message);
    }
  },
};

/src/config/config.ts:

import dotenv from 'dotenv';

dotenv.config();

export default {
  PORT: process.env.PORT || 3000,
  // Include other configuration parameters here, e.g., database configuration
};

/src/api/routes/index.ts:

import { Router } from 'express';
import dataRoutes from './dataRoutes';

const router = Router();

router.use('/data', dataRoutes);

export default router;

/tests/dataController.test.ts:

import request from 'supertest';
import app from '../src/index'; // Assume this exports the express app

describe('DataController', () => {
  it('GET /api/data - success', async () => {
    const result = await request(app).get('/api/data');
    expect(result.status).toBe(200);
    expect(result.body).toEqual([{ id: 1, attribute: 'Example' }]); // Mocked data from the service
  });

  // Add more tests for POST, UPDATE, DELETE etc.
});

jest.config.js:

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  moduleNameMapper: {
    // If you are using ES modules, map them here
    '^(\\.{1,2}/.*)\\.js$': '$1',
  },
  // For projects that are a mix of TypeScript and JavaScript files:
  transform: {
    '^.+\\.(t|j)sx?$': ['ts-jest', {
      // ts-jest configuration goes here
      useESM: true,
    }],
  },
  // If using Babel alongside TypeScript to transform your imports:
  transformIgnorePatterns: ['<rootDir>/node_modules/'],
};

This collection of files represents the typical components of an API project using TypeScript. The testing file (dataController.test.ts) assumes that your Express app is exportable from the index.ts file, which is common practice to facilitate testing. Additionally, the services and models are very simplistic and would need to be expanded with actual database logic and potentially ORM (Object-Relational Mapping) integrations. The error handling is basic and might need to be extended to handle different types of errors more granularly. The logger utility is also simple and for a production-grade application, you would likely use a more comprehensive logging solution.

Best Practices Notes:

  • dataController.ts contains a class DataController that provides methods for handling the API logic. These methods interact with a hypothetical DataService, which would encapsulate the business logic.
  • dataRoutes.ts sets up the routes for your data resources. It imports DataController and uses its methods to respond to HTTP requests.
  • index.ts is the entry point of the application. It sets up middlewares like helmet for security, cors for Cross-Origin Resource Sharing, and express.json for body parsing. It also uses the .env configuration loaded by dotenv, includes the dataRoutes, and defines error handling middleware.
  • Error handling middleware notFoundHandler and errorHandler are used to handle 404 and other errors gracefully.
  • Logging utility logger is used instead of console.log for better control and potential log management solutions integration.
  • The environment variables like PORT are managed via dotenv, allowing different configurations for different environments.

Each part of the application is modular and has a single responsibility, making the codebase easier to maintain and scale in an enterprise context.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment