Organize your code for scalability:
src/
├── api/ # API routes
├── services/ # Business logic
├── models/ # Data models
├── repositories/ # Data access
├── middleware/ # Express middleware
├── utils/ # Utility functions
└── config/ # Configuration
Keep related code together. Group by feature rather than by type.
Use clear, descriptive names:
// Good
async function getUserById(id: string): Promise<User>;
const isAuthenticated = checkAuth();
// Avoid
async function get(id: string): Promise<User>;
const auth = checkAuth();
Create specific error types:
class NotFoundError extends Error {
constructor(resource: string, id: string) {
super(`${resource} with id ${id} not found`);
this.name = "NotFoundError";
}
}
class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "ValidationError";
}
}
Handle errors centrally:
app.use((err, req, res, next) => {
logger.error(err);
if (err instanceof ValidationError) {
return res.status(400).json({ error: err.message });
}
if (err instanceof NotFoundError) {
return res.status(404).json({ error: err.message });
}
// Default to 500 for unexpected errors
res.status(500).json({
error: "Internal server error",
});
});
Never expose error stack traces or sensitive information in production.
Always validate user input:
import { z } from "zod";
const createUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(1).max(100),
});
app.post("/users", async (req, res) => {
const input = createUserSchema.parse(req.body);
// ... create user
});
Never store plain text passwords:
import bcrypt from "bcrypt";
async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 12);
}
async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
return bcrypt.compare(password, hash);
}
Use parameterized queries:
// Parameterized query - safe
const user = await db.query(
"SELECT * FROM users WHERE email = $1",
[email]
);
Implement caching for frequently accessed data:
import Redis from "ioredis";
const redis = new Redis();
async function getUser(id: string): Promise<User> {
// Check cache first
const cached = await redis.get(`user:${id}`);
if (cached) {
return JSON.parse(cached);
}
// Fetch from database
const user = await db.users.findOne({ id });
// Store in cache (1 hour TTL)
await redis.setex(`user:${id}`, 3600, JSON.stringify(user));
return user;
}
Test business logic in isolation:
describe("TaskService", () => {
let service: TaskService;
beforeEach(() => {
service = new TaskService();
});
it("should create a task", async () => {
const input = {
title: "Test task",
description: "Test description",
};
const task = await service.create(input);
expect(task.id).toBeDefined();
expect(task.title).toBe(input.title);
expect(task.completed).toBe(false);
});
it("should find task by id", async () => {
const created = await service.create({
title: "Find me",
});
const found = await service.findById(created.id);
expect(found).toEqual(created);
});
});
Test API endpoints:
describe("POST /tasks", () => {
it("should create a task", async () => {
const response = await request(app)
.post("/tasks")
.send({
title: "Test task",
description: "Test description",
})
.expect(201);
expect(response.body).toMatchObject({
title: "Test task",
description: "Test description",
completed: false,
});
});
it("should return 400 for invalid input", async () => {
await request(app).post("/tasks").send({ title: "" }).expect(400);
});
});
Aim for at least 80% code coverage, but focus on testing critical paths.
Use structured logging for better observability:
import winston from "winston";
const logger = winston.createLogger({
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: "error.log", level: "error" }),
new winston.transports.File({ filename: "combined.log" }),
],
});
logger.info("User created", {
userId: user.id,
email: user.email,
timestamp: Date.now(),
});
Use appropriate log levels:
- error: Critical errors that need immediate attention
- warn: Warning messages for degraded functionality
- info: General informational messages
- debug: Detailed debugging information
Never hardcode configuration:
const config = {
nodeEnv: process.env.NODE_ENV || "development",
port: parseInt(process.env.PORT || "3000"),
databaseUrl: process.env.DATABASE_URL!,
jwtSecret: process.env.JWT_SECRET!,
logLevel: process.env.LOG_LEVEL || "info",
};
// Validate required variables
if (!config.databaseUrl) {
throw new Error("DATABASE_URL is required");
}
Implement health check endpoints:
app.get("/health", async (req, res) => {
const checks = {
database: await checkDatabase(),
redis: await checkRedis(),
uptime: process.uptime(),
};
const healthy = Object.values(checks).every(Boolean);
res.status(healthy ? 200 : 503).json({
status: healthy ? "healthy" : "unhealthy",
checks,
});
});
Handle shutdown signals properly:
const server = app.listen(port);
process.on("SIGTERM", async () => {
logger.info("SIGTERM received, shutting down gracefully");
// Stop accepting new connections
server.close(() => {
logger.info("HTTP server closed");
});
// Close database connections
await db.close();
// Exit process
process.exit(0);
});
Track these essential metrics:
Request Rate
Requests per second by endpoint
Following these best practices will help you build reliable, maintainable
applications.