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
andnotFoundHandler.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 withnpm 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.
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 classDataController
that provides methods for handling the API logic. These methods interact with a hypotheticalDataService
, which would encapsulate the business logic.dataRoutes.ts
sets up the routes for your data resources. It importsDataController
and uses its methods to respond to HTTP requests.index.ts
is the entry point of the application. It sets up middlewares likehelmet
for security,cors
for Cross-Origin Resource Sharing, andexpress.json
for body parsing. It also uses the.env
configuration loaded bydotenv
, includes thedataRoutes
, and defines error handling middleware.- Error handling middleware
notFoundHandler
anderrorHandler
are used to handle 404 and other errors gracefully. - Logging utility
logger
is used instead ofconsole.log
for better control and potential log management solutions integration. - The environment variables like
PORT
are managed viadotenv
, 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.