Introduction: why refactoring legacy code is a necessity
Every developer sooner or later encounters legacy code — outdated, poorly documented, and often unreadable. In the world of JavaScript and Node.js, this is especially relevant: projects grow quickly, technologies change, and old code sections turn into a "swamp" that slows down development. Refactoring legacy code is not just a whim, but a necessity for maintaining application performance, security, and scalability.
In this article, we will break down how to approach refactoring legacy code in JavaScript (Node.js) safely and effectively. You will learn about the key stages: analysis, splitting into modules, replacing outdated constructs, and testing. All examples are written in modern JavaScript (ES6+) and are oriented towards the Node.js environment.
Step 1: Analysis and understanding of legacy code
Before changing the code, you need to understand it. Legacy code is often written in a "spaghetti" style: functions hundreds of lines long, global variables, and mixed logic. Start with an audit.
1.1. Create a dependency map
Use tools like madge or dependency-cruiser to visualize connections between modules. This will help identify circular dependencies and "god objects".
// Example: installing madgenpm install -g madgemadge --image graph.svg src/1.2. Identify "risk zones"
Find code sections that change most often or cause errors. Use the Git commit history:
git log --oneline --follow src/legacyModule.jsAlso pay attention to:
- Functions longer than 50 lines
- Global variables (e.g.,
varwithoutlet/const) - Callback hell (nested asynchronous calls)
1.3. Write tests for existing behavior
Without tests, refactoring is a ticking time bomb. Use jest or mocha to cover key functions. Tests should check the current behavior, not the "ideal" one.
// Example test for a legacy functionconst sum = require('./legacyModule').sum;test('sum adds two numbers', () => { expect(sum(1, 2)).toBe(3);});Step 2: Gradual improvement of structure
Don't try to rewrite everything at once. Use the "Strangler Fig" strategy — replace parts of the code gradually, maintaining backward compatibility.
2.1. Split the monolith into modules
Extract logically related blocks into separate files. For example, move database work into db.js and request handling into handlers.js.
// Before: one file of 500 linesfunction handleRequest(req, res) { // 100 lines of logic // 50 lines of database work // 30 lines of response formatting}
// After: splitting into modules// db.jsexports.getUser = (id) => { /* ... */ };
// handlers.jsconst db = require('./db');exports.handleRequest = (req, res) => { const user = db.getUser(req.params.id); // response formatting};2.2. Replace var with let/const
This is a simple but important step. var has function scope, which often leads to bugs. Use let for variables and const for constants.
// Wasvar x = 10;if (true) { var x = 20; // overwrites the outer variable}
// Becamelet x = 10;if (true) { let x = 20; // local variable}Step 3: Modernizing asynchronous code
Legacy code in Node.js often suffers from "callback hell". Replace nested callbacks with promises or async/await.
3.1. Converting callbacks to promises
Use util.promisify for functions following the Node.js style (first argument is an error).
const fs = require('fs');const { promisi