Available for new projects & full-time roles

Let's Talk
All posts
I Modernized a 20-Year-Old VB.NET System Without Burning the Business Down
2025-10-20·20 min read·technical

I Modernized a 20-Year-Old VB.NET System Without Burning the Business Down

A technical case study on migrating a 20-year-old VB.NET monolith with millions of MSSQL records to a modern Nuxt.js stack, including strategy, pitfalls, and measurable results.

legacy modernization
VB.NET
Nuxt.js
MSSQL
migration
software engineering
case study

Legacy modernization is the most underrated skill in software engineering.

Everyone wants to build greenfield. Almost no one wants to touch the system that's been running since 2003, has no documentation, and directly controls the revenue of a real business. That's exactly what I signed up for at Masleap when I led the Grimm Scientific Industries migration.

Grimm had a VB.NET desktop application sitting on top of a 20-year-old MSSQL database. Millions of records. Business logic embedded in stored procedures nobody fully understood. Queries that ran in nested loops written before JOIN optimization was a concern. The system worked, barely, and replacing it wrong would've taken the business down.

TL;DR: I migrated a 20-year-old VB.NET monolith running on millions of MSSQL records to a Nuxt.js stack using the strangler-fig pattern, an API intermediary in front of the legacy database instead of a big-bang rewrite. The business ran the whole time with zero downtime and zero data loss, while p95 load times dropped 97.2 percent. The lesson: legacy modernization is 40 percent technical and 60 percent managing stakeholder trust in a slower, safer rollout.


The System We Inherited

Before touching a single line, I spent two weeks mapping the actual state:

flowchart TD A["VB.NET Forms App\n(.NET 2.0, Windows-only)"] B["Stored Procedures\n1,400+ · 40% undocumented"] C["MSSQL 2008 R2\n340 tables · ~40% no FK relations\n18M rows in core transaction table\nZero composite indexes · cursor-based loops\nAvg query time: 45–120s"] A -- "ADO.NET direct SQL" --> B B --> C

The archaeological phase produced three artifacts before any code was written:

  • Entity map: all 340 tables, relationships inferred from column naming conventions and cross-referencing stored procedure calls
  • Hotspot report: query profiler output identifying the 15 stored procedures responsible for 70% of execution time
  • Business logic catalogue: every stored procedure classified as (a) pure retrieval, (b) computation, (c) state mutation, or (d) unknown

Classification mattered because the migration order followed it: pure retrieval first (safest), unknown last.

The Approach: Strangler Fig, Not Big Bang

The worst mistake in legacy migration is trying to replace everything at once. I went strangler fig, run old and new in parallel, migrate surface by surface, validate against the live system.

flowchart TD subgraph phase0["Phase 0: API Intermediary"] VB0[VB.NET App] --> MSSQL0[(MSSQL)] MSSQL0 --> API[NestJS API\nwraps legacy queries\nbehind clean contracts] API --> MSSQL0 NUXT0[Nuxt.js] --> API end subgraph phase1["Phase 1: Migrate Surfaces"] VB1[VB.NET module] -- one module at a time --> NX1[Nuxt.js module] NX1 --> DONE[All modules migrated] DONE --> DECOMM[VB.NET decommissioned] end phase0 --> phase1

The API intermediary was the architectural cornerstone. By inserting a NestJS layer between the old MSSQL and the new frontend, we could:

  1. Migrate the frontend (VB.NET forms → Nuxt.js) independently of the database
  2. Migrate the database schema incrementally behind the API contract
  3. Run both old and new simultaneously, validating output parity
typescript
// The API intermediary pattern: NestJS wrapping the legacy query
// The Nuxt.js frontend never knows it's hitting a 2003 database

@Injectable()
class PayrollService {
  constructor(private readonly mssql: MSSQLService) {}

  async getPayrollByEmployee(employeeId: string): Promise<PayrollRecord[]> {
    // Phase 1: delegate to legacy stored procedure, wrapped in clean contract
    const raw = await this.mssql.query<LegacyPayrollRow>(
      `EXEC sp_GetPayrollByEmp @EmpId = @0`,
      [employeeId]
    );

    // Normalize the inconsistent legacy schema to clean DTOs
    return raw.map(row => ({
      id: row.PayrollID,
      employeeId: row.EmpID,
      period: { start: row.PayPeriodStart, end: row.PayPeriodEnd },
      gross: parseFloat(row.GrossAmt ?? "0"),
      deductions: parseFloat(row.DeductTotal ?? "0"),
      net: parseFloat(row.NetPay ?? "0"),
    }));

    // Phase 2 (later): swap the query internals, keep the return type identical
    // The Nuxt.js layer never changes
  }
}

The Query Rehabilitation

This was the most technically interesting phase. The original system had queries doing full table scans on tables with 18 million rows.

A representative example, the attendance summary report, which previously took 87 seconds:

sql
-- BEFORE: cursor loop over 18M rows
DECLARE @EmpID INT
DECLARE emp_cursor CURSOR FOR SELECT EmpID FROM Employees
OPEN emp_cursor
FETCH NEXT FROM emp_cursor INTO @EmpID

WHILE @@FETCH_STATUS = 0
BEGIN
    -- Nested scan per employee: O(n*m) against a non-indexed table
    INSERT INTO #TempAttendance
    SELECT EmpID, COUNT(*) AS DaysPresent
    FROM AttendanceLog
    WHERE EmpID = @EmpID
      AND AttDate BETWEEN @StartDate AND @EndDate
    GROUP BY EmpID

    FETCH NEXT FROM emp_cursor INTO @EmpID
END

CLOSE emp_cursor
DEALLOCATE emp_cursor
sql
-- AFTER: set-based with composite index
-- Step 1: Create composite index on the 18M-row table
CREATE NONCLUSTERED INDEX IX_AttendanceLog_EmpDate
ON AttendanceLog (EmpID, AttDate)
INCLUDE (Status);

-- Step 2: Replace cursor with a single set-based aggregation
SELECT
    e.EmpID,
    e.FullName,
    e.Department,
    COUNT(CASE WHEN a.Status = 'P' THEN 1 END)  AS DaysPresent,
    COUNT(CASE WHEN a.Status = 'A' THEN 1 END)  AS DaysAbsent,
    COUNT(CASE WHEN a.Status = 'L' THEN 1 END)  AS DaysLeave
FROM Employees e
LEFT JOIN AttendanceLog a
    ON e.EmpID = a.EmpID
    AND a.AttDate BETWEEN @StartDate AND @EndDate
GROUP BY e.EmpID, e.FullName, e.Department
OPTION (MAXDOP 4);

The impact across the 15 critical operations:

Report / Operation Before After Reduction
Attendance summary 87s 1.2s 98.6%
Payroll computation 45s 0.8s 98.2%
KPI dashboard load 120s 2.1s 98.3%
Supply chain report 65s 3.4s 94.8%
Employee record search 8s 0.05s 99.4%
Vendor payment listing 30s 0.4s 98.7%
Overall p95 load time 90s 2.5s 97.2%

Three techniques drove most of the improvement:

  • Composite indexes: covering the exact column combinations used in JOINs and WHERE clauses
  • Cursor elimination: replacing RBAR (row-by-agonizing-row) processing with set-based SQL
  • Subquery refactoring: replacing correlated subqueries with CTEs and window functions

Data Validation: The Zero-Tolerance Contract

With millions of live records, data integrity during migration was non-negotiable. I wrote a dual-read validator that ran nightly for six weeks, comparing output from the old and new layers:

typescript
async function validateMigrationParity(
  legacyService: LegacyPayrollService,
  newService: PayrollService,
  sampleSize = 500
): Promise<ValidationReport> {
  const employeeIds = await sampleEmployees(sampleSize);
  const discrepancies: Discrepancy[] = [];

  for (const id of employeeIds) {
    const [legacy, migrated] = await Promise.all([
      legacyService.getPayrollByEmployee(id),
      newService.getPayrollByEmployee(id),
    ]);

    if (!isDeepEqual(normalise(legacy), normalise(migrated))) {
      discrepancies.push({
        employeeId: id,
        legacy: normalise(legacy),
        migrated: normalise(migrated),
        diff: deepDiff(normalise(legacy), normalise(migrated)),
      });
    }
  }

  return {
    sampleSize,
    discrepancyCount: discrepancies.length,
    discrepancyRate: discrepancies.length / sampleSize,
    details: discrepancies,
  };
}

Any discrepancy rate above 0.1% blocked the corresponding module from promotion to production. We hit zero data loss across the full migration.

The Numbers

Metric Result
P95 load time reduction 97.2% (90s → 2.5s)
Development velocity gain 30% faster post-migration
Data loss 0 records
Business downtime 0 minutes
Migration duration 9 months (phased)
Modules migrated 8 (payroll, CAPA, vendor, customer, logistics, attendance, KPI, HR)

What Most Devs Get Wrong About Legacy Work

They treat it as purely technical. It's not. The hardest part of this project was convincing stakeholders that the strangler fig approach meant the old system would keep running for months. That felt like failure to them. Reframing it as "zero-risk parallel validation" was as important as any line of code I wrote.

Two mental models that matter here:

The cost of wrong beats the cost of slow. A big-bang migration that fails corrupts live data, triggers emergency rollbacks, and destroys trust. A phased migration that takes 3x longer ships zero regressions.

Archaeology before architecture. You cannot design a migration for a system you haven't mapped. Two weeks of profiling and documentation before writing a line of code saved months of wrong-direction work.

Legacy modernization is 40% technical, 60% trust management.

If you have a system that's too important to break and too outdated to maintain, that's exactly the engagement I specialize in.


Got a system that's too important to break and too outdated to maintain?

That's the exact problem I've been paid to solve. The strangler fig approach, the query profiling, the zero-downtime migration strategy, I've done this in production, on a live business, with real data. If you're facing a modernisation project and need an engineer who won't burn it down, reach out.

Frequently asked

How long did the migration take?

Roughly nine months end to end, shipping incrementally behind the strangler-fig proxy rather than a big-bang cutover.

Did users experience downtime?

No. Old and new ran side by side, traffic shifted route by route, with instant rollback per slice.

What was the hardest part?

Untangling business logic buried in stored procedures nobody fully understood. We characterized behavior with tests before moving it.

Can you do this for my legacy system?

Yes, this is core work I take on. Book a call and we will scope a safe path.

Hiring or have a project?

Let's build something that holds.

Full-stack engineering, system design, and legacy modernization. Available for freelance, contract, and full-time roles.

Keep reading