When I joined CalcMenu Philippines in early 2021, the company had a problem.
Their flagship product—a nutrition calculation software that had served restaurants and food manufacturers for over 15 years—was showing its age. The codebase was a tangled mess of VB.NET and stored procedures. Adding new features took months. The UI looked like it was designed in 2005 (because it was). Client support tickets were piling up.
But here’s the kicker: the product worked. Clients relied on it daily. Some had built their entire nutrition compliance workflows around it. One wrong move during a migration could mean losing accounts worth $50K-$100K annually.
The ask was simple (in theory): build a modern replacement, migrate all clients, and don’t lose a single one.
No pressure.
Four years later, we hit 100% client retention on the migration to CalcMenu Cloud. Here’s how we pulled it off.
The Situation: Why This Was Harder Than It Sounds
Let me paint the picture of what we were dealing with:
The Old System:
- 15+ years of accumulated technical debt
- Tightly coupled desktop application (Windows only)
- SQL Server database with 400+ tables (many undocumented)
- Client-specific customizations baked into the core codebase
- Zero automated tests
- Deployment process: burn CDs, mail to clients (I’m not joking)
The Clients:
- Food manufacturers with strict FDA compliance requirements
- Restaurant chains with thousands of menu items
- Nutritionists managing multiple client portfolios
- Many were non-technical users in their 50s-60s who hated change
The Constraints:
- Can’t force migration (clients on support contracts, legally entitled to keep using old version)
- Can’t break existing workflows (some clients had documented SOPs referencing specific UI elements)
- Can’t have downtime during migration (compliance deadlines don’t wait)
- Can’t hire a huge team (budget for 10 people max, including QA and design)
Oh, and management wanted to start seeing ROI within 18 months.
Easy, right?
The Strategy: Parallel Existence + Gradual Migration
After two months of discovery (talking to clients, mapping workflows, documenting edge cases), I proposed a strategy that made some stakeholders nervous:
Build the new system completely in parallel. Don’t touch the old one.
Here’s why:
- Risk isolation: If the new system had issues, clients could keep using the old one
- Real feedback: We could roll out to early adopters and iterate based on actual usage
- Confidence building: Clients could try the new system while keeping the old one as a safety net
- Team focus: No context switching between maintaining legacy code and building new features
The plan had three phases:
Phase 1: Core Feature Parity (Months 1-8)
Build the essential 20% of features that 80% of clients used daily:
- Recipe creation and editing
- Nutrition calculations (USDA database integration)
- Label generation (FDA-compliant formatting)
- Basic reporting
Phase 2: Migration Tools + Early Adopters (Months 9-14)
- Build automated data migration pipeline
- Create side-by-side comparison tools
- Onboard 5-10 friendly clients as beta testers
- Fix all the things we got wrong in Phase 1
Phase 3: Full Rollout + Long Tail Features (Months 15-48)
- Migrate remaining clients in waves
- Build client-specific features that justified premium pricing
- Sunset the old system gradually
The Tech Stack: Boring Is Good
I picked proven, stable technologies. This wasn’t the time to experiment.
Frontend:
- React 17 → 18 (functional components, hooks)
- TypeScript for type safety
- Material-UI for consistent design
- React Query for server state management
Backend:
- Node.js + Express (later refactored to Fastify)
- PostgreSQL (migrated from SQL Server)
- Redis for caching and session management
- RabbitMQ for async jobs (label generation, exports)
Infrastructure:
- Azure App Service (most clients were already in Azure)
- Docker for dev environment consistency
- GitHub Actions for CI/CD
- Application Insights for monitoring
Why these choices?
Not because they’re “sexy”—because my team already knew them. Learning curve was low. Hiring was easier. Stack Overflow had answers. Libraries were mature.
When you’re on a deadline with client money on the line, boring is beautiful.
The Hard Parts (And What I Learned)
1. The Data Migration Nightmare
The old database was… creative. Foreign key constraints? Sometimes. Nullable vs empty string? Flip a coin. Dates stored as strings? You bet.
Example horror:
-- Real column from the old DB
CREATE TABLE Recipes (
RecipeID INT PRIMARY KEY,
Name VARCHAR(255),
CreatedDate VARCHAR(50), -- Sometimes "2021-03-15", sometimes "March 15, 2021", sometimes NULL
ServingSize VARCHAR(100), -- Could be "100g" or "1 cup" or "2.5 oz" or "100"
-- ... 47 more columns, many unused
)
What worked:
- Built a validation layer that cleaned data as it migrated
- Created a “translation dictionary” for edge cases
- Migrated data in phases: first read-only (so clients could verify), then full switchover
- Automated 90% of transformations, manually reviewed the other 10%
Lesson learned: Budget 3x more time than you think for data migration. Always.
2. Feature Creep vs Client Happiness
Two months into Phase 2, a major client said: “We love the new UI, but we can’t switch until you add [obscure feature only they use].”
This happened. A lot.
I had to make hard calls:
- Is this feature blocking other clients too? → Build it now
- Is this client worth $X in ARR? → Build it for them specifically
- Can they work around it for 6 months? → Add to roadmap, don’t block migration
What worked:
- Weekly prioritization meetings with the product owner
- “Migration blocker” vs “nice to have” labels in our backlog
- Transparent roadmap shared with clients
Lesson learned: You can’t build everything. But you can communicate clearly about what you’re building and when.
3. The Team Dynamic
Managing a team of 10 across 3 time zones (Philippines, Europe, Turkey) while also writing code was… interesting.
Early mistakes:
- Trying to code 60% of my time while managing 100% of the time (burnout speedrun)
- Assuming everyone had the same context I did (they didn’t)
- Not delegating architecture decisions (became a bottleneck)
What actually worked:
- Daily 15-min async standups (Slack threads, not meetings)
- Weekly sync for blockers and architecture discussions
- Pair programming sessions for knowledge sharing
- Clear ownership: each developer owned specific features end-to-end
- Documentation culture: every major decision got a short RFC doc
I went from coding 60% of my time in Year 1 to maybe 20% by Year 3. That was hard to accept, but necessary.
Lesson learned: Your job as a lead isn’t to write the most code. It’s to multiply the productivity of everyone else.
4. The “Just One More Thing” Problem
Six months before planned sunset of the old system, the CEO asked: “Can we add AI-powered recipe suggestions to the new platform?”
This kept happening. New ideas. Scope expansion. “Since we’re rebuilding anyway…”
I learned to ask three questions:
- Does this help us hit 100% migration? (No → defer)
- Will clients pay more for this? (No → defer)
- Can we build an MVP in < 2 weeks? (No → defer)
Lesson learned: Protect your team’s focus like it’s a finite resource (because it is).
The Results
After 4 years:
- 100% client retention during migration
- Zero critical outages during rollouts
- 15+ new features that weren’t possible in the old system
- 50% improvement in page load times (as measured by clients)
- Positive NPS scores from previously frustrated clients
Revenue impact:
- Upsell opportunities from new features: ~$200K ARR
- Reduced support costs (modern system = fewer bugs): ~$80K/year saved
- Client acquisition: new clients who wouldn’t touch the old UI
More importantly, the team stayed intact. Low turnover. High morale. People were proud of what we built.
What I’d Do Differently
1. Start with even smaller scope We tried to achieve feature parity too quickly. Should’ve launched with 50% of features and iterated faster.
2. Invest in automated testing earlier We wrote tests, but not comprehensively enough early on. Paid for that in regression bugs during Phase 3.
3. Budget more time for client training We built great documentation, but some clients needed hand-holding. Should’ve hired a dedicated training specialist earlier.
4. Push back harder on feature creep I said “yes” too often in Year 2. Burned out the team. Should’ve been more protective of scope.
Advice for Anyone Doing This
If you’re about to lead a legacy migration:
Do:
- Spend 2-3 months in discovery before writing code
- Build migration tools alongside the new system
- Get 5-10 friendly clients to beta test early
- Communicate relentlessly (weekly email updates, monthly demos)
- Keep the old system running longer than you want to
- Celebrate small wins with your team
Don’t:
- Assume you understand all client workflows (you don’t)
- Try to force migration before the new system is ready
- Underestimate the emotional attachment clients have to familiar UIs
- Neglect documentation and training materials
- Burn out your team chasing arbitrary deadlines
Most importantly: Remember that the goal isn’t just to ship new code. It’s to make clients happier while reducing technical debt. If you lose clients during migration, you failed—no matter how clean your architecture is.
The Bigger Picture
This project taught me more about software engineering than any tutorial or bootcamp ever could:
- How to make technical decisions with business constraints
- How to lead without being a bottleneck
- How to say “no” to good ideas because they’re not the right ideas right now
- How to keep a team motivated through a multi-year slog
- How to ship code that makes users’ lives better, not just developers’ resumes shinier
Four years is a long time. There were moments I wanted to quit. Moments the team wanted to quit. Moments where it felt like we’d never get across the finish line.
But we did. And every single client came with us.
That’s something I’m genuinely proud of.
Questions? War stories of your own? Drop them in the comments. I’m always curious how others have tackled migrations like this.