Refactoring Legacy JavaScript Code: Advanced Level
Working with legacy code is an inevitable reality for any developer, especially in the Node.js domain. Often, such code is a "swamp" of global variables, nested callbacks, functions hundreds of lines long, and a complete lack of modular structure. Simple refactoring methods don't work here; a systematic approach and knowledge of advanced techniques are required. In this article, we will analyze strategies that help turn chaos into a maintainable architecture without stopping production.
1. Analysis and Dependency Mapping
Before changing code, you need to understand it. In legacy projects, documentation is often missing or outdated. The first step is to build a dependency map.
Tools:
- Madge — a utility for visualizing dependencies between modules. It will show cyclic dependencies and "dead" files.
- ESLint with plugins — will help identify unused variables, functions, and imports.
- Deploy to an isolated environment — run tests (if there are none, write at least smoke tests) and record which modules are loaded.
Example: Suppose we have a file legacy.js that contains 2000 lines and uses a global variable config.
// legacy.js (before refactoring)var config = { apiUrl: 'https://api.example.com', timeout: 5000};
function fetchData(callback) { // 50 lines of logic with nested if and try/catch // uses global config if (config.timeout > 0) { // ... } callback(null, data);}Action: Using Madge, we see that legacy.js is imported in 15 other files. It is a "god object". Our task is to break it down into atomic modules.
2. The "Strangler Fig" Pattern
This pattern is ideal for refactoring without stopping the system. The essence: we don't rewrite everything at once, but gradually replace parts of the old system with new ones until the old one dies out on its own.
Implementation in Node.js:
- Create a new module with a clean architecture.
- Write a proxy layer (or facade) that redirects calls to either the old or the new module.
- Gradually switch consumers to the new module.
Example: Replacing the old callback style with Promise/async/await.
// Old API (callback-based)function getUser(id, callback) { // legacy code callback(null, { id: id, name: 'John' });}
// New API (Promise-based)async function getUserAsync(id) { // new implementation with async/await const user = await database.findUser(id); return user;}
// Proxy facadeasync function getUserFacade(id) { if (USE_NEW_IMPLEMENTATION) { return getUserAsync(id); } else { return new Promise((resolve, reject) => { getUser(id, (err, user) => { if (err) reject(err); else resolve(user); }); }); }}The USE_NEW_IMPLEMENTATION flag can be moved to configuration or a feature toggle. This allows enabling the new code for a subset of users (canary release) or rolling back changes without a deployment.
3. Modularization and Isolating Side Effects
Legacy code often suffers from hidden side effects: a function modifies global state, writes to a file, or calls an external API without returning a result. Refactoring such functions involves extracting pure logic.
Technique: "Extract Function" followed by Dependency Injection.
Problematic code: