Legacy code is not a dirty word — it is code that makes money, serves real users, and pays engineering salaries. The problem is not its existence, but that it eventually accumulates enough technical debt to slow the entire business down.
I know this because I have spent the past three years living it. Since September 2022, I have been working as a Full Stack Engineer at VacancySoft, a data intelligence company whose platform serves vacancy data to clients across the recruitment and professional services industries. When I joined, the platform ran on a legacy monolith that had served the company well — but was buckling under 50,000+ daily API requests, growing data volumes, and a feature velocity the architecture could no longer support.
Over the following months, I led the architectural redesign and incremental migration of 15+ modules to a modern Node.js/TypeScript architecture — without a single minute of planned downtime. This is the story of how we did it: the strategy, the patterns, the code, and the results.
The Problem: A Monolith Under Pressure
The platform had the hallmarks of a system that grew organically over many years — not badly built, but built for a different era. By the time I joined, several compounding issues were creating real business impact:
Technical Debt Symptoms
Inconsistent module architecture. Each of the 15+ modules had been built by different engineers at different times. Some used callbacks, others early Promises, a few had partial async/await. There was no unified error handling, no consistent validation layer, and no shared utility patterns.
Tightly coupled database queries. Business logic was interleaved with raw SQL scattered throughout controllers and route handlers. Schema changes required tracing query strings across dozens of files, with no ORM or query builder providing a single source of truth.
No TypeScript, no type safety. The entire codebase was plain JavaScript. Refactoring any function required manually tracing every call site to understand expected input and output shapes. Function signatures were documented only in the minds of the original authors — several of whom had left the company.
Test coverage below 15%. Critical business logic — data transformation, API response formatting, access control — had no automated tests. Every deployment was a manual QA exercise, and regressions were discovered by clients.
Business Impact
These issues translated directly into business problems:
- Feature delivery had slowed ~40%. Engineers spent more time working around existing code than writing new functionality.
- Query execution times had degraded. Key client-facing endpoints were responding in 2+ seconds, triggering SLA concerns.
- Deployment confidence was low. Without test coverage, the team batched changes into infrequent releases rather than shipping continuously.
- Onboarding new engineers took weeks. Missing type definitions, documentation, and consistent patterns meant new team members needed extensive hand-holding.
The codebase needed a fundamental transformation — but the platform was in active use by paying clients. A "big bang" rewrite was commercially unacceptable. We needed to modernise incrementally while keeping the platform fully operational.
My Role: Architect and Migration Lead
I drove the migration strategy from conception to execution — designing the architectural approach, selecting the migration pattern, defining TypeScript adoption standards, establishing the testing strategy, and personally leading the refactoring of the most complex modules. I also mentored other engineers through the transition, running code reviews to ensure consistency.
This was not a committee decision. I proposed the strangler fig approach to leadership, built the proof of concept on the first module, and established every pattern the rest of the migration followed.
The Strategy: Strangler Fig Pattern
I chose the strangler fig pattern — named after the tropical vine that gradually envelops and replaces its host tree. Rather than rewriting everything at once, we would build new implementations alongside legacy code, gradually routing traffic to new modules, and removing old code once each was fully replaced.
This approach offered three critical advantages:
- Zero downtime. At no point would the platform be partially functional. Both old and new implementations ran simultaneously.
- Incremental risk. Each module migration was an isolated, reversible change. If a new module had issues, we could route traffic back to the legacy implementation within seconds.
- Continuous feature delivery. The team could ship new features on modules that had not yet been migrated, while migration work progressed in parallel on other modules.
The Migration Architecture
I designed a proxy layer sitting in front of both legacy and new implementations:
// migration-router.ts — Proxy layer for gradual migration
import { Request, Response, NextFunction } from 'express';
import { FeatureFlagService } from './services/feature-flags';
interface MigrationRoute {
legacyHandler: (req: Request, res: Response, next: NextFunction) => void;
modernHandler: (req: Request, res: Response, next: NextFunction) => void;
moduleName: string;
}
export function createMigrationRouter(route: MigrationRoute) {
return async (req: Request, res: Response, next: NextFunction) => {
const useModern = await FeatureFlagService.isEnabled(
`migration:${route.moduleName}`,
{ userId: req.user?.id }
);
if (useModern) {
return route.modernHandler(req, res, next);
}
return route.legacyHandler(req, res, next);
};
}
Feature flags controlled which implementation served each request. I could migrate internal users first, then roll out to 10%, 25%, 50%, and finally 100% of client traffic — monitoring error rates and response times at every stage.
Technical Deep Dive: The Migration Process
Phase 1: TypeScript Foundation and Shared Infrastructure
Before migrating any module, I established the shared infrastructure every new module would depend on.
TypeScript Configuration
I introduced TypeScript with a strict configuration enforcing type safety from the start:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"esModuleInterop": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"paths": {
"@modules/*": ["src/modules/*"],
"@shared/*": ["src/shared/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "legacy/**/*"]
}
The strict: true flag was non-negotiable. I had seen migrations where strict: false was used to avoid friction, yielding TypeScript no safer than the JavaScript it replaced.
Shared Data Access Layer
I built a unified data access layer replacing scattered raw SQL queries with typed repository patterns:
// shared/repositories/base.repository.ts
import { Pool, QueryResult } from 'pg';
import { DatabasePool } from '../database/pool';
export abstract class BaseRepository<T> {
protected pool: Pool;
constructor() {
this.pool = DatabasePool.getInstance();
}
protected async query<R = T>(
sql: string,
params: unknown[] = []
): Promise<R[]> {
const start = Date.now();
const result: QueryResult = await this.pool.query(sql, params);
const duration = Date.now() - start;
if (duration > 500) {
logger.warn('Slow query detected', {
sql: sql.substring(0, 200),
duration,
rowCount: result.rowCount,
});
}
return result.rows as R[];
}
protected async queryOne<R = T>(
sql: string,
params: unknown[] = []
): Promise<R | null> {
const rows = await this.query<R>(sql, params);
return rows[0] || null;
}
}
Every migrated module used this base repository, giving us automatic slow query logging, consistent error handling, and typed return values. The legacy code had none of this — slow queries went undetected until clients complained.
Standardised Error Handling
I introduced a typed error hierarchy replacing the inconsistent error handling across the legacy codebase:
// shared/errors/application-errors.ts
export class ApplicationError extends Error {
constructor(
message: string,
public readonly statusCode: number,
public readonly code: string,
public readonly isOperational: boolean = true
) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
export class NotFoundError extends ApplicationError {
constructor(resource: string, identifier: string | number) {
super(
`${resource} with identifier '${identifier}' not found`,
404,
'RESOURCE_NOT_FOUND'
);
}
}
export class ValidationError extends ApplicationError {
constructor(
public readonly errors: Array<{ field: string; message: string }>
) {
super('Validation failed', 400, 'VALIDATION_ERROR');
}
}
Phase 2: Module-by-Module Migration
With the foundation in place, I began migrating modules from lowest-risk to highest-risk — starting with lower-traffic, simpler modules to build confidence before tackling the complex, high-traffic ones.
Before (Legacy JavaScript):
// legacy/controllers/vacancyController.js
const db = require('../db');
exports.getVacancies = function(req, res) {
var page = req.query.page || 1;
var limit = req.query.limit || 50;
var offset = (page - 1) * limit;
var sector = req.query.sector;
var sql = 'SELECT * FROM vacancies';
var params = [];
if (sector) {
sql += ' WHERE sector = $1';
params.push(sector);
}
sql += ' ORDER BY created_at DESC LIMIT $' + (params.length + 1) +
' OFFSET $' + (params.length + 2);
params.push(limit, offset);
db.query(sql, params, function(err, result) {
if (err) {
console.log('Error fetching vacancies:', err);
return res.status(500).json({ error: 'Internal server error' });
}
db.query('SELECT COUNT(*) FROM vacancies' +
(sector ? ' WHERE sector = $1' : ''), sector ? [sector] : [],
function(err2, countResult) {
if (err2) {
return res.status(500).json({ error: 'Internal server error' });
}
res.json({
data: result.rows,
total: parseInt(countResult.rows[0].count),
page: parseInt(page),
limit: parseInt(limit)
});
}
);
});
};
After (Modern TypeScript):
// modules/vacancies/vacancy.controller.ts
import { Request, Response } from 'express';
import { VacancyService } from './vacancy.service';
import { GetVacanciesSchema } from './vacancy.validation';
import { asyncHandler } from '@shared/middleware/async-handler';
export class VacancyController {
constructor(private readonly vacancyService: VacancyService) {}
getVacancies = asyncHandler(async (req: Request, res: Response) => {
const query = GetVacanciesSchema.parse(req.query);
const result = await this.vacancyService.getVacancies({
page: query.page,
limit: query.limit,
sector: query.sector,
sortBy: query.sortBy,
sortOrder: query.sortOrder,
});
res.json({
data: result.items,
meta: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: Math.ceil(result.total / result.limit),
},
});
});
}
// modules/vacancies/vacancy.service.ts
import { VacancyRepository } from './vacancy.repository';
import { CacheService } from '@shared/services/cache.service';
import { PaginatedResult, VacancyListItem } from './vacancy.types';
export class VacancyService {
constructor(
private readonly repository: VacancyRepository,
private readonly cache: CacheService
) {}
async getVacancies(params: GetVacanciesParams): Promise<PaginatedResult<VacancyListItem>> {
const cacheKey = `vacancies:${JSON.stringify(params)}`;
const cached = await this.cache.get<PaginatedResult<VacancyListItem>>(cacheKey);
if (cached) return cached;
const [items, total] = await Promise.all([
this.repository.findPaginated(params),
this.repository.countFiltered(params),
]);
const result: PaginatedResult<VacancyListItem> = {
items,
total,
page: params.page,
limit: params.limit,
};
await this.cache.set(cacheKey, result, 300);
return result;
}
}
The contrast is stark. The legacy version had no validation, no caching, nested callbacks, string-concatenated SQL, and console.log as its only error reporting. The migrated version has Zod schema validation, a service layer with caching, typed repositories, and structured error handling — all running through the same API routes, invisible to clients.
Phase 3: Query Optimisation During Migration
Migration was not just code restructuring. I used each module migration as an opportunity to optimise underlying queries, auditing every SQL statement with EXPLAIN ANALYZE to identify inefficiencies.
// modules/vacancies/vacancy.repository.ts
import { BaseRepository } from '@shared/repositories/base.repository';
import { VacancyListItem, GetVacanciesParams } from './vacancy.types';
export class VacancyRepository extends BaseRepository<VacancyListItem> {
async findPaginated(params: GetVacanciesParams): Promise<VacancyListItem[]> {
const conditions: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
if (params.sector) {
conditions.push(`v.sector = $${paramIndex++}`);
values.push(params.sector);
}
const whereClause = conditions.length
? `WHERE ${conditions.join(' AND ')}`
: '';
values.push(params.limit, (params.page - 1) * params.limit);
return this.query<VacancyListItem>(`
SELECT v.id, v.title, v.sector, v.location, v.published_at,
c.name AS company_name
FROM vacancies v
INNER JOIN companies c ON c.id = v.company_id
${whereClause}
ORDER BY v.published_at DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex}
`, values);
}
}
The legacy code selected SELECT * from tables with 30+ columns when the API response only needed 6 fields. The migrated queries selected only required columns, used proper indexing, and parallelised count queries with data queries using Promise.all.
Phase 4: Testing Strategy
Every migrated module shipped with comprehensive tests. I established a three-layer testing strategy:
// modules/vacancies/__tests__/vacancy.service.test.ts
describe('VacancyService', () => {
let service: VacancyService;
let mockRepository: jest.Mocked<VacancyRepository>;
let mockCache: jest.Mocked<CacheService>;
beforeEach(() => {
mockRepository = { findPaginated: jest.fn(), countFiltered: jest.fn() } as any;
mockCache = { get: jest.fn().mockResolvedValue(null), set: jest.fn() } as any;
service = new VacancyService(mockRepository, mockCache);
});
it('should return cached result when available', async () => {
const cached = { items: [{ id: 1, title: 'Engineer' }], total: 1, page: 1, limit: 50 };
mockCache.get.mockResolvedValue(cached);
const result = await service.getVacancies({ page: 1, limit: 50 });
expect(result).toEqual(cached);
expect(mockRepository.findPaginated).not.toHaveBeenCalled();
});
it('should query repository and cache on miss', async () => {
mockRepository.findPaginated.mockResolvedValue([{ id: 1, title: 'Engineer' }]);
mockRepository.countFiltered.mockResolvedValue(1);
const result = await service.getVacancies({ page: 1, limit: 50 });
expect(result.items).toHaveLength(1);
expect(mockCache.set).toHaveBeenCalled();
});
});
Test coverage across migrated modules went from under 15% to above 85%. This fundamentally changed deployment confidence — the team began shipping smaller, more frequent releases because they trusted the test suite to catch regressions.
The Results: Measured in Production
The migration was executed over a continuous period, with each module validated and fully deployed before moving to the next. Here are the production metrics we measured:
| Metric | Before Migration | After Migration | Change |
| Average API response time | 1.8s | 320ms | 82% reduction |
| Average query execution time | 1.2s | 480ms | 60% reduction |
| Overall system performance | Baseline | +40% throughput | 40% improvement |
| Daily API request capacity | ~50,000 (strained) | 50,000+ (comfortable headroom) | Stable at scale |
| Test coverage | <15% | >85% | 5.7x increase |
| Deployment frequency | Bi-weekly | Multiple per week | 3-4x increase |
| Production incidents (monthly) | 6-8 | 1-2 | 75% reduction |
| New engineer onboarding time | ~4 weeks | ~1.5 weeks | 63% reduction |
| Planned downtime during migration | — | Zero | No service interruption |
The 40% performance improvement and 60% query time reduction were measured across the full platform using APM over a 30-day window after the final module was migrated.
Lessons for Engineers Facing Legacy Migrations
Here is what I would tell any engineer facing a similar challenge:
Never rewrite from scratch. The strangler fig pattern works. It feels slower than a rewrite, but is dramatically less risky. The business never stops.
Establish patterns before scaling. I spent two weeks building shared infrastructure and migrating one module as a reference. Every subsequent migration was faster because the patterns were proven.
Use the migration as a performance audit. Every legacy module contains hidden performance debt. Migrating is the perfect time to resolve it — you are already reading every line of code.
Type safety pays for itself immediately. The bugs caught by TypeScript during migration — latent in the JavaScript codebase for months — convinced even the most sceptical team members that strict typing was worth it.
Test coverage makes everything else possible. Without tests, every migration step is a gamble. With them, it is engineering.
Measure before, during, and after. Without performance numbers, the migration is a story. With them, it is evidence.
Final Thoughts
Migrating a legacy codebase is not glamorous work — no greenfield architectures to design, no new frameworks to evaluate. It is the painstaking work of understanding what exists, designing better versions, and replacing components one by one without breaking anything.
But it is some of the most impactful work an engineer can do. The VacancySoft platform now processes 50,000+ daily requests on an architecture that is maintainable, testable, performant, and ready for growth. The team ships faster, with more confidence, and new engineers become productive in days rather than weeks.
I have led this transformation from start to finish, and the lessons have shaped how I approach every codebase I work on — legacy or otherwise.