Introduction: Why Legacy Code is Not a Death Sentence
Every developer sooner or later encounters legacy JavaScript code. It could be monolithic Node.js scripts written "on the fly" 5 years ago, or tangled callback functions that even senior developers are afraid to touch. However, refactoring legacy code is not just "rewriting everything from scratch," but a systematic effort to improve the architecture without changing external behavior.
In this article, we will break down a step-by-step refactoring plan for JavaScript (Node.js): from analysis and testing to implementing modern patterns. You will learn how to safely transform a "swamp" into maintainable code using real-world examples.
1. Analysis and Mapping of Legacy Code
Before changing the code, you need to understand its current state. Start by creating a dependency map and identifying "hot spots."
1.1. Analysis Tools
For JavaScript/Node.js, use:
- ESLint with the
complexityandmax-linesrules — to identify complex functions. - Plato or CodeClimate — to visualize cyclomatic complexity.
- Madge — to build a module dependency graph.
1.2. Identifying "Code Smells"
Typical legacy JS problems:
- Functions over 100 lines long.
- Global variables and state mutation.
- Callback hell (nested asynchronous calls).
- Lack of modularity (one file with 2000+ lines).
// Example of a "code smell": a function that does everything at oncefunction processUserData(userId) { // 100 lines: validation, DB query, formatting, sending email // ... difficult to test and maintain}2. Building a "Safety Net": Testing
Without tests, refactoring is "Russian roulette." Create Characterization Tests (tests that capture the current behavior).
2.1. Adding Unit Tests
Use Jest or Mocha. Start with critical paths:
// Testing the existing function without changing itconst { processUserData } = require('./legacy');
describe('processUserData (legacy)', () => { it('should process user with ID 1', () => { const result = processUserData(1); expect(result).toHaveProperty('email'); expect(result.status).toBe('processed'); });});2.2. Integration Tests for Node.js
If the code works with a database or API, use supertest for HTTP requests:
const request = require('supertest');const app = require('../app');
describe('POST /api/users', () => { it('should create user (legacy behavior)', async () => { const res = await request(app) .post('/api/users') .send({ name: 'Test' }); expect(res.status).toBe(200); expect(res.body.id).toBeDefined(); });});3. Step-by-Step Refactoring: From Monolith to Modules
Let's break the process into 3 stages: isolation, decomposition, optimization.
3.1. Isolation and Wrappers
Don't touch the internals — wrap the legacy function in a new interface:
// Legacy functionfunction oldFormatDate(date) { // ... 50 lines with mutations return formattedDate;}
// New wrapperfunction formatDateSafe(date) { if (!(date instanceof Date)) { throw new Error('Invalid date'); } return oldFormatDate(date);}3.2. Decomposition using Extract Function
Extract logical blocks into separate functions:
// Before: one function of 80 linesfunction handleOrder(order) { // 1. Validation if (!order.id || !order.total) { ... } // 2. Discount calculation