Legacy code is not a dirty word. Legacy code is code that makes money. It runs in production, serves real users, and generates the revenue that pays engineering salaries. The problem is not that legacy code exists — it is that legacy code 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 processes and serves vacancy data to clients across the recruitment and professional services industries. When I joined, the platform was built on a legacy monolithic architecture that had served the company well for years — but was buckling under the weight of 50,000+ daily API requests, growing data volumes, and a feature velocity that the architecture could no longer support.
Over the following months, I led the architectural redesign and incremental migration of 15+ modules from the legacy codebase to a modern Node.js/TypeScript architecture — without a single minute of planned downtime. This article is the story of how we did it: the strategy, the technical patterns, the code, and the measurable results.
The Problem: A Monolith Under Pressure
The VacancySoft platform had the hallmarks of a system that grew organically over many years. It was not badly built — it was built for a different era. But 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, with different conventions. Some modules used callback-style asynchronous patterns, others used early Promise implementations, and a few had partial async/await adoption. There was no unified error handling, no consistent data validation layer, and no shared utility patterns.
Tightly coupled database queries. Business logic was interleaved with raw SQL queries scattered throughout controllers and route handlers. Changing a database schema required tracing query strings across dozens of files, with no ORM or query builder providing a single source of truth for data access patterns.
No TypeScript, no type safety. The entire codebase was plain JavaScript. This meant that refactoring any function required manually tracing every call site to understand the expected input and output shapes. Function signatures were often 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 in production.
Business Impact
These technical issues translated directly into business problems:
- Feature delivery had slowed by an estimated 40%. Engineers spent more time understanding and working around existing code than writing new functionality.
- Query execution times had degraded. Several API endpoints used by key clients were responding in 2+ seconds, triggering SLA concerns.
- Deployment confidence was low. Without adequate test coverage, the team deployed cautiously, batching changes into infrequent releases rather than shipping continuously.
- Onboarding new engineers took weeks. The lack of type definitions, documentation, and consistent patterns meant new team members needed extensive hand-holding to become productive.
The codebase needed a fundamental transformation. But the platform was in active use by paying clients. A "big bang" rewrite — stopping feature work to rebuild everything from scratch — was commercially unacceptable. We needed a strategy that allowed us to modernise incrementally while keeping the platform fully operational.
My Role: Architect and Migration Lead
Olamilekan Lamidi drove the migration strategy from conception to execution. I designed the architectural approach, selected the migration pattern, defined TypeScript adoption standards, established the testing strategy, and personally led the refactoring of the most complex modules. I also mentored other engineers on the team through the transition, running code review sessions to ensure consistency across migrated modules.
This was not a committee decision. I proposed the strangler fig approach to engineering leadership, built the proof of concept on the first module, and established every pattern that 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 a host tree — as the migration strategy. Rather than rewriting the entire codebase at once, we would build new implementations alongside the legacy code, gradually routing traffic to the new modules, and eventually removing the old code once each module 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 that sat in front of both the legacy and new module 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 a module for internal users first, then gradually 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 foundational infrastructure that every new module would depend on.
TypeScript Configuration
I introduced TypeScript with a strict configuration that enforced type safety from the start:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": ".",
"paths": {
"@modules/*": ["src/modules/*"],
"@shared/*": ["src/shared/*"],
"@config/*": ["src/config/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "legacy/**/*"]
}
The strict: true flag was non-negotiable. I had seen too many TypeScript migrations where teams set strict: false to avoid friction, only to end up with TypeScript that offered no more safety than the JavaScript it replaced.
Shared Data Access Layer
I built a unified data access layer that replaced the 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;
}
protected async execute(
sql: string,
params: unknown[] = []
): Promise<number> {
const result = await this.pool.query(sql, params);
return result.rowCount ?? 0;
}
}
Every migrated module used this base repository, which gave us automatic slow query logging, consistent error handling, and typed return values. The legacy code had no such instrumentation — slow queries went undetected until clients complained.
Standardised Error Handling
I introduced a typed error hierarchy that replaced 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');
}
}
export class ConflictError extends ApplicationError {
constructor(message: string) {
super(message, 409, 'CONFLICT');
}
}
Phase 2: Module-by-Module Migration
With the foundation in place, I began migrating modules from lowest-risk to highest-risk. The order was deliberate: we started with modules that had lower traffic and simpler logic, allowing the team to build confidence with the migration pattern before tackling the complex, high-traffic modules.
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 transformation is stark. The legacy version had no input validation, no caching, nested callbacks, string-concatenated SQL, and console.log as the only error reporting. The migrated version has schema validation with Zod, a service layer with caching, typed repository methods, and structured error handling — all running through the same API routes, invisible to clients.
Phase 3: Query Optimisation During Migration
Migration was not just a code restructuring exercise. I used each module migration as an opportunity to optimise the underlying queries. I audited every SQL query in each module before writing the replacement, using 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);
}
if (params.dateFrom) {
conditions.push(`v.published_at >= $${paramIndex++}`);
values.push(params.dateFrom);
}
if (params.dateTo) {
conditions.push(`v.published_at <= $${paramIndex++}`);
values.push(params.dateTo);
}
const whereClause = conditions.length
? `WHERE ${conditions.join(' AND ')}`
: '';
const sortColumn = this.sanitiseSortColumn(params.sortBy);
const sortOrder = params.sortOrder === 'asc' ? 'ASC' : 'DESC';
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.${sortColumn} ${sortOrder}
LIMIT $${paramIndex++} OFFSET $${paramIndex}
`, values);
}
private sanitiseSortColumn(column: string = 'published_at'): string {
const allowed = ['published_at', 'title', 'sector', 'location'];
return allowed.includes(column) ? column : 'published_at';
}
}
The legacy code had been selecting SELECT * from tables with 30+ columns when the API response only needed 6 fields. Across high-traffic endpoints, this wasteful data transfer was a significant contributor to slow response times. The migrated queries selected only the columns needed, used proper indexing, and parallelised count queries with data queries using Promise.all.
Phase 4: Testing Strategy
Every migrated module was delivered with comprehensive test coverage. I established a three-layer testing strategy:
// modules/vacancies/__tests__/vacancy.service.test.ts
import { VacancyService } from '../vacancy.service';
import { VacancyRepository } from '../vacancy.repository';
import { CacheService } from '@shared/services/cache.service';
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().mockResolvedValue(undefined),
} as any;
service = new VacancyService(mockRepository, mockCache);
});
describe('getVacancies', () => {
it('should return cached result when available', async () => {
const cachedResult = {
items: [{ id: 1, title: "'Engineer' }],"
total: 1,
page: 1,
limit: 50,
};
mockCache.get.mockResolvedValue(cachedResult);
const result = await service.getVacancies({ page: 1, limit: 50 });
expect(result).toEqual(cachedResult);
expect(mockRepository.findPaginated).not.toHaveBeenCalled();
});
it('should query repository and cache when no cache hit', async () => {
const items = [{ id: 1, title: "'Engineer' }];"
mockRepository.findPaginated.mockResolvedValue(items);
mockRepository.countFiltered.mockResolvedValue(1);
const result = await service.getVacancies({ page: 1, limit: 50 });
expect(result.items).toEqual(items);
expect(result.total).toBe(1);
expect(mockCache.set).toHaveBeenCalled();
});
it('should pass filter parameters to repository', async () => {
mockRepository.findPaginated.mockResolvedValue([]);
mockRepository.countFiltered.mockResolvedValue(0);
await service.getVacancies({
page: 2,
limit: 25,
sector: 'Technology',
});
expect(mockRepository.findPaginated).toHaveBeenCalledWith(
expect.objectContaining({ sector: 'Technology', page: 2, limit: 25 })
);
});
});
});
Test coverage across migrated modules went from under 15% to above 85%. This was not just a quality improvement — it 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 migrated, validated, and fully deployed before moving to the next. Here are the production metrics that Olamilekan Lamidi and the engineering team 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% system performance improvement and 60% query execution time reduction were measured across the full platform using application performance monitoring over a 30-day observation window after the final module was migrated.
Lessons for Engineers Facing Legacy Migrations
Having now completed this migration, here is what I would tell any engineer facing a similar challenge:
Never rewrite from scratch. The strangler fig pattern works. It is slower than a rewrite feels, but it is dramatically less risky. The business never stops.
Establish patterns before scaling. I spent the first two weeks building shared infrastructure and migrating one module as a reference implementation. Every subsequent module migration was faster because the patterns were clear and proven.
Use the migration as a performance audit. Every legacy module contains hidden performance debt. Migrating is the perfect opportunity to identify and resolve it, because you are already reading every line of code.
Type safety pays for itself immediately. The number of bugs caught by TypeScript during migration — bugs that had been latent in the JavaScript codebase for months — convinced even the most sceptical team members that strict typing was worth the effort.
Test coverage is the safety net that makes everything else possible. Without comprehensive tests, every migration step is a gamble. With them, it is engineering.
Measure before, during, and after. If you cannot show the performance numbers, the migration is a story. If you can, it is evidence.
Final Thoughts
Migrating a legacy codebase is not glamorous engineering work. There are no greenfield architectures to design, no new frameworks to evaluate. It is the painstaking work of understanding what exists, designing a better version, 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 the next phase of growth. The engineering team ships faster, with more confidence, and new engineers become productive in days rather than weeks.
Olamilekan Lamidi has led this transformation from start to finish, and the lessons from this experience have shaped how I approach every codebase I work on — legacy or otherwise.
Top comments (0)