Problem: You Need to Extract a Service Without Breaking Everything
Your 200K-line monolith has a payment module buried in 47 files. You need to extract it into a microservice, but grep shows 312 references and you don't know which are critical.
You'll learn:
- How @Codebase indexing finds hidden dependencies
- Safe extraction patterns for production systems
- Validation strategies before deployment
Time: 45 min | Level: Advanced
Why This Happens
Legacy monoliths accumulate implicit dependencies through shared state, circular imports, and undocumented coupling. Traditional search tools find syntax matches but miss semantic relationships like "this function assumes UserSession exists in global scope."
Common symptoms:
- Tests pass but production breaks after extraction
- "Cannot read property of undefined" in unrelated modules
- Database transaction failures from split code
- Circular dependency errors after refactor
Solution
Step 1: Index Your Codebase
Open your monolith in Cursor and let it build the semantic index:
# Cursor indexes automatically, but force refresh if needed
# Cmd+Shift+P → "Cursor: Reindex Codebase"
Expected: Status bar shows "Indexing complete" after 2-5 minutes (depends on repo size)
Why this works: Cursor's index uses embeddings to understand code relationships beyond string matching - it knows paymentService.charge() relates to Transaction.create() even without direct imports.
Step 2: Map All Dependencies
Use @Codebase to find everything touching your target module:
@Codebase Find all code that depends on the payment processing module in /src/payments. Include:
- Direct imports and function calls
- Shared database models
- Global state or singletons
- Environment variables or config
Expected output: Cursor returns a dependency graph showing:
- 47 direct imports (expected)
- 12 shared database models (🚩 critical)
- 3 global variables (🚩 breaking change)
- 8 config keys
If it fails:
- Returns too many results: Narrow scope with
in /src/payments and /src/billing only - Misses obvious deps: Check
.cursorignoreisn't excluding key directories
Step 3: Create Extraction Plan
Ask Cursor to generate a safe migration strategy:
@Codebase Create a step-by-step plan to extract /src/payments into a standalone service. Consider:
1. Which models need to stay shared vs duplicated
2. API contract for the new service
3. Order of changes to avoid breaking main app
4. Rollback strategy
Example output:
## Extraction Plan for Payment Service
### Phase 1: Preparation (No Production Impact)
1. Create new repo: payment-service
2. Copy models: Transaction, PaymentMethod, Invoice
3. Define API contract (REST):
- POST /charges
- GET /transactions/:id
- POST /refunds
### Phase 2: Dual-Write Period
1. Deploy payment-service (no traffic yet)
2. Update monolith to write to BOTH old code AND new API
3. Monitor for 48 hours
### Phase 3: Read Migration
1. Route 10% reads to new service
2. Compare results with old code (feature flag)
3. Gradually increase to 100%
### Phase 4: Cleanup
1. Remove old payment code from monolith
2. Archive shared models
**Rollback:** Keep dual-write for 2 weeks, flip feature flag to revert
Step 4: Generate Interface Code
Let Cursor write the service boundary:
@Codebase Based on the current payment module, generate:
1. TypeScript interface for the new payment service API
2. Migration shim that routes calls to either old or new code based on feature flag
3. Test cases for the shim
Generated code:
// payment-api.interface.ts
export interface PaymentServiceAPI {
/**
* Process a charge - creates transaction record
* @throws PaymentDeclinedError if card rejected
*/
processCharge(params: ChargeRequest): Promise<Transaction>;
/**
* Issue refund - must be within 90 days
*/
issueRefund(transactionId: string, amount: number): Promise<Refund>;
}
// payment-shim.ts
export class PaymentShim implements PaymentServiceAPI {
constructor(
private oldPaymentModule: typeof import('./legacy/payments'),
private newServiceClient: PaymentServiceAPI,
private featureFlags: FeatureFlagService
) {}
async processCharge(params: ChargeRequest): Promise<Transaction> {
const useNewService = await this.featureFlags.isEnabled('payment-service-v2');
if (useNewService) {
// Dual-write: call both, return new service result
const [newResult] = await Promise.all([
this.newServiceClient.processCharge(params),
this.oldPaymentModule.charge(params) // for comparison
]);
// Log discrepancies for monitoring
this.compareResults(newResult, oldResult);
return newResult;
}
// Fallback to old code
return this.oldPaymentModule.charge(params);
}
}
Why this pattern works: The shim lets you deploy without risk - production keeps using old code until you're confident in the new service.
Step 5: Find Hidden Side Effects
Critical step that grep can't do:
@Codebase What side effects happen when payment.charge() is called? Include:
- Database writes outside the Transaction table
- Event emitters or webhooks
- Cache invalidations
- Logging or analytics calls
Example discoveries:
// Hidden coupling found by @Codebase:
1. UserStats.incrementPurchaseCount() - called in payments/charge.ts line 47
🚩 Must replicate in new service or add to API
2. Redis.del(`cart:${userId}`) - clears shopping cart after payment
🚩 New service needs cart-service client
3. Analytics.track('purchase', {...}) - sends to internal analytics
🚩 Extract to shared event bus instead
4. Email.sendReceipt() - synchronous call blocks response
🚩 Move to async queue in new architecture
Action: For each side effect, decide:
- Keep in new service: Add as explicit dependencies
- Remove: Technical debt to fix during extraction
- Convert to events: Use message queue for loose coupling
Step 6: Validate Before Deployment
Generate comprehensive tests:
@Codebase Create integration tests that verify:
1. Old code and new service return identical results for same inputs
2. All database foreign keys still resolve
3. Transaction rollback works across service boundary
Generated test:
describe('Payment Service Migration', () => {
it('produces identical results for charge flow', async () => {
const testCharge = {
amount: 1000,
currency: 'USD',
userId: 'test-user-123'
};
// Call both implementations
const oldResult = await legacyPaymentModule.charge(testCharge);
const newResult = await paymentServiceClient.processCharge(testCharge);
// Compare outcomes
expect(newResult.amount).toBe(oldResult.amount);
expect(newResult.status).toBe(oldResult.status);
// Verify side effects happened in both
const userStats = await db.userStats.findOne({ userId: testCharge.userId });
expect(userStats.purchaseCount).toBe(1); // incremented once, not twice
});
it('handles rollback when new service fails', async () => {
// Mock new service failure
jest.spyOn(paymentServiceClient, 'processCharge')
.mockRejectedValue(new Error('Service unavailable'));
// Should fall back to old code
const result = await paymentShim.processCharge(testCharge);
expect(result).toBeDefined();
expect(result.source).toBe('legacy'); // feature flag logged this
});
});
Verification
Before deploying:
# Run full test suite
npm test
# Check for circular dependencies in new service
npx madge --circular --extensions ts src/
# Verify no shared mutable state
npx eslint --rule 'no-restricted-globals: error'
You should see:
- All tests passing
- Zero circular dependencies
- No global variable usage in extracted code
In production (after deployment):
# Monitor error rates
curl https://api.yourapp.com/metrics | jq '.payment_errors'
# Compare old vs new results (during dual-write)
curl https://api.yourapp.com/admin/payment-comparison
Success criteria:
- Error rate unchanged from baseline
- <1% discrepancy between old and new implementations
- P95 latency within 10% of old code
What You Learned
- @Codebase finds semantic dependencies that grep misses (shared state, side effects)
- Dual-write pattern enables safe production migration
- Feature flags let you test in prod without risk
- Extract side effects to events instead of tight coupling
Limitations:
- Cursor's index updates when files change - reindex if refactor takes >1 day
- Complex runtime behavior (dynamic imports, eval) may not be caught
- Database schema changes need separate migration planning
When NOT to use this:
- Codebase >500K lines (Cursor indexing becomes slow)
- Heavy use of metaprogramming (runtime code generation)
- Need to preserve exact behavior including bugs (compliance reasons)
Advanced: Using OpenClaw for Complex Refactors
For monoliths >500K lines, combine Cursor with OpenClaw for better dependency analysis:
# Install OpenClaw
npm install -g @openclaw/cli
# Generate full dependency graph
openclaw analyze --repo . --output deps.json
# Find critical paths
openclaw find-critical-path --from src/payments --to src/users