Recipe: Tech Stack Migration
This recipe walks through planning and executing a technology migration using SpecForge. We'll use migrating a Node.js API from Express 4 to Fastify as the example — a common migration that touches routing, middleware, plugins, and error handling.
The same workflow applies to any tech migration: framework upgrades, database engine changes, ORM replacements, or language upgrades.
Before You Start
Migrations are high-risk because they touch many files and can break behavior in subtle ways. SpecForge reduces this risk by:
- Creating a functional parity spec before any code changes
- Generating tests that verify behavior matches before and after
- Providing a phased migration plan with rollback at each step
- Detecting drift as the migration progresses
Step 1 — Analyze the Source Codebase
Prompt:
Reverse engineer my codebase at /Users/me/my-app/src for project proj_abc123This creates specs from the current implementation — documenting what the app actually does. These specs become the functional parity baseline.
Step 2 — Analyze the Migration Scope
Prompt:
Plan a migration from Express to Fastify for project proj_abc123What to expect:
## Migration Analysis: Express 4 → Fastify 5
### Scope
- 23 route files
- 8 middleware (auth, logging, rate-limit, cors, compression, error, validation, cache)
- 4 error handlers
- 2 WebSocket integrations (socket.io → @fastify/websocket)
- 12 test files that mock express internals
### Equivalence Map
Express → Fastify
─────────────────────────────────────────
app.use(middleware) → fastify.addHook / fastify.register(plugin)
app.get('/path', handler) → fastify.get('/path', { schema }, handler)
req.body → request.body (same)
res.json(data) → reply.send(data)
res.status(201).json(data) → reply.code(201).send(data)
next(err) → throw err (or return reply.send(err))
express-validator → @fastify/ajv-compiler + JSON Schema
multer → @fastify/multipart
cors (package) → @fastify/cors
helmet → @fastify/helmet
express-rate-limit → @fastify/rate-limit
socket.io → @fastify/websocket + ws directly
### Risk Areas
- High: socket.io migration (no direct equivalent — behavior change)
- High: Middleware order (Fastify lifecycle differs from Express)
- Medium: Error handling (Fastify uses setErrorHandler, not error middleware)
- Low: Route parameters (compatible syntax)
### Estimated effort: 8–14 days
### Recommended approach: Strangler fig (route-by-route)Step 3 — Create the Migration Spec
Prompt:
Create a spec for migrating from Express to Fastify in project proj_abc123.
Approach: strangler fig — migrate route groups one at a time behind a reverse proxy.Acceptance criteria the spec will include:
- All 23 route groups behave identically before and after migration (parity test suite)
- Response schemas are validated with Fastify's built-in AJV integration
- Authentication middleware migrated to Fastify lifecycle hooks (onRequest)
- Error responses maintain the same JSON structure as Express implementation
- WebSocket functionality preserved (migration from socket.io to @fastify/websocket)
- No regression in response time — Fastify P99 latency ≤ Express P99 × 1.05
- All existing integration tests pass against Fastify server
- Zero downtime migration — Express and Fastify run concurrently during transition
Step 4 — Challenge the Migration Plan
Prompt:
Challenge the migration spec for project proj_abc123SpecForge will probe edge cases:
- What happens to in-flight requests during the Express → Fastify cutover?
- How are session cookies handled during the transition period?
- What if a feature flag fails and some users hit Express, others Fastify?
- Are there any Express-specific middleware that have no Fastify equivalent?
Step 5 — Generate Functional Parity Tests
Prompt:
Generate tests for the migration spec in project proj_abc123These tests run against both the Express and Fastify server — ensuring identical behavior:
typescript
// parity.test.ts
describe.each([
['Express', expressApp],
['Fastify', fastifyApp],
])('%s — POST /api/orders', (_, app) => {
it('returns 201 with order ID on valid payload', async () => {
const res = await request(app)
.post('/api/orders')
.send({ productId: 'prod_1', quantity: 2 })
expect(res.status).toBe(201)
expect(res.body).toMatchObject({ orderId: expect.any(String) })
})
it('returns 422 on missing productId', async () => {
const res = await request(app)
.post('/api/orders')
.send({ quantity: 2 })
expect(res.status).toBe(422)
})
})Step 6 — Generate the Execution Plan
Prompt:
Generate an execution plan for the migration spec in project proj_abc123What a strangler fig plan looks like:
markdown
## Phase 1: Foundation
- [ ] Install Fastify + plugins (fastify-cors, fastify-helmet, etc.)
- [ ] Set up Fastify server alongside Express (different port)
- [ ] Configure reverse proxy (nginx/Caddy) with feature flag routing
- [ ] Write parity test harness that runs against both servers
## Phase 2: Migrate routes (per group — can parallelize groups)
- [ ] Group A: /api/auth routes (low risk, no WebSocket)
- [ ] Group B: /api/users routes (medium risk, validation changes)
- [ ] Group C: /api/orders routes (high risk, payment integration)
- [ ] Group D: /api/admin routes (low risk, internal only)
## Phase 3: Migrate middleware
- [ ] Auth hook (Express middleware → Fastify onRequest)
- [ ] Error handler (next(err) → setErrorHandler)
- [ ] Rate limiting (@fastify/rate-limit)
- [ ] CORS + Helmet (@fastify/cors, @fastify/helmet)
## Phase 4: WebSocket migration
- [ ] Replace socket.io with @fastify/websocket + ws
- [ ] Migrate client-side socket.io → native WebSocket
- [ ] Parity test: WebSocket message flow
## Phase 5: Cutover
- [ ] Switch reverse proxy to 100% Fastify
- [ ] Keep Express running for 48h (rollback window)
- [ ] Remove Express after 48h with no incidents
- [ ] Update PLAN.md and mark migration spec as doneStep 7 — Run the Migration
With each phase:
- Implement the changes
- Run parity tests against both servers
- Validate functional parity with
validate - Detect drift between the migration spec and implementation
Prompt after each phase:
Validate the migration spec against the code at /Users/me/my-app/src for project proj_abc123Step 8 — Performance Validation
Prompt:
Audit the code at /Users/me/my-app/src for project proj_abc123Check that the Fastify implementation scores at least as well as the Express baseline on architecture compliance and code quality.
For latency validation, run your load test tool (k6, Artillery, wrk) against both servers and compare P99 latency — the spec criterion says Fastify P99 ≤ Express P99 × 1.05.
Step 9 — Document Architectural Decisions
Prompt:
Generate ADRs for the migration spec in project proj_abc123This produces:
- ADR-001: Why strangler fig over big-bang migration
- ADR-002: @fastify/websocket over socket.io in the Fastify context
- ADR-003: AJV JSON Schema over express-validator for request validation
Step 10 — Capture Learnings
Prompt:
Capture learning: when migrating from Express to Fastify, middleware order matters more —
Fastify lifecycle hooks (onRequest, preHandler, onSend) must be planned before migration starts,
not discovered during implementationRollback Plan
SpecForge's strangler fig approach means rollback is always possible:
| Phase | Rollback action |
|---|---|
| During migration | Switch reverse proxy back to 100% Express |
| After cutover (< 48h) | Express is still running — switch proxy |
| After Express removed | Restore from git tag pre-migration |
Always tag the last stable Express commit before starting: git tag pre-fastify-migration.
Applying This Recipe to Other Migrations
| Migration | Key concern | Recommended approach |
|---|---|---|
| Django → FastAPI | Async patterns, ORM differences | Strangler fig |
| Mongoose → Prisma | Schema migration, query API | Module by module |
| React class → hooks | Behavioral parity, lifecycle | Component by component |
| PostgreSQL → CockroachDB | SQL dialect differences | Read replica first |
| REST → GraphQL | Client-side breaking change | Additive (keep REST) |
| Node 18 → Node 22 | API deprecations | detect_deprecations first |