Full-Stack Integration Guardian
Mission: Prevent frontend-backend integration failures through systematic validation of API contracts, data transformations, and type safety across the stack. This skill operates proactively - catching data flow issues before they cause runtime crashes.
Activation Triggers
- User mentions "API not working" or "partners not showing"
- Frontend displaying blank/empty data despite successful API calls
- Console errors:
Cannot read property 'X' of undefined - TypeError:
X.toFixed is not a functionor similar - API response structure mismatches
- Database DECIMAL/number type issues
- Implementing new backend-to-frontend data flow
- "Why is my data null?"
Critical Patterns from Production Incidents
🔴 INCIDENT 1: API Wrapper Response Access Pattern
Historical Failure: Frontend accessed response.data.partners but API wrapper (api.get()) already unwraps responses
Symptoms:
console.log('[Partners] Response:', response) // {partners: Array(4)}
console.log('[Partners] Response data:', response.data) // undefined
console.log('[Partners] No partners in response')
Root Cause:
- Custom API wrapper (
lib/api.ts) uses Axios but unwraps the response - Developer assumed standard Axios pattern:
response.data.partners - Actual pattern: API wrapper returns
response.partnersdirectly
Red Flags to Scan For:
- Custom API client wrappers that modify response structure
- Frontend accessing
response.data.Xwhen wrapper already unwraps - Console showing successful data but code treating it as undefined
- Working API calls (HTTP 200) but UI shows "No data"
Detection Method:
// 1. Read the API client wrapper (lib/api.ts or similar)
// 2. Check if it unwraps response.data automatically:
class ApiClient {
async get(url: string) {
const response = await axios.get(url)
return response.data // ⚠️ This unwraps! Frontend should NOT use .data again
}
}
// 3. Check frontend usage
const response = await api.get('/endpoint')
if (response.data.items) { // ❌ WRONG - .data already unwrapped
setItems(response.data.items)
}
// ✅ CORRECT
const response = await api.get('/endpoint')
if (response.items) { // Direct access
setItems(response.items)
}
Fix Template:
// BEFORE (Broken)
const partnersResponse = await api.get('/partners/admin/all')
setPartners(partnersResponse.data.partners || []) // ❌ response.data is undefined
// AFTER (Working)
const response = await api.get('/partners/admin/all')
if (response?.partners) { // ✅ Direct access with null safety
setPartners(response.partners)
} else {
setPartners([])
}
Lessons Learned:
- Always check API wrapper implementation before assuming standard Axios pattern
- Add debug logging to see actual response structure
- Use null-safe access patterns (
response?.items) - Document API client behavior in comments
🔴 INCIDENT 2: Database DECIMAL Types Return as Strings
Historical Failure: Sequelize returns MySQL DECIMAL columns as strings, causing .toFixed() to fail in React
Symptoms:
TypeError: partner.total_revenue.toFixed is not a function
// Even after null check:
TypeError: (partner.total_revenue || 0).toFixed is not a function
Root Cause:
- MySQL
DECIMAL(10,2)columns return as"0.00"(string) through Sequelize - Frontend calls
.toFixed(2)assuming number type - Null check
|| 0doesn't help because string"0.00"is truthy
Red Flags to Scan For:
- Backend models with DECIMAL/FLOAT/NUMERIC columns
- Frontend using
.toFixed(),.toLocaleString(), math operations - No explicit type conversion in backend API response
- TypeScript types showing
numberbut runtime values are strings
Detection Method:
// 1. Check backend models for DECIMAL fields
// backend/src/models/Partner.ts
Partner.init({
total_revenue: {
type: DataTypes.DECIMAL(10, 2), // ⚠️ Returns string!
}
})
// 2. Check backend controller response
res.json({
partners: partners.map(p => ({
total_revenue: p.total_revenue, // ❌ Still a string "0.00"
}))
})
// 3. Check frontend usage
<p>${partner.total_revenue.toFixed(2)}</p> // ❌ Crashes if string
Fix Template:
Backend Fix (Preferred):
// backend/src/controllers/partner.controller.ts
res.status(200).json({
partners: partners.map((partner) => ({
id: partner.id,
name: partner.name,
total_revenue: parseFloat(partner.total_revenue_generated?.toString() || '0'), // ✅
total_commission_earned: parseFloat(partner.total_commission_earned?.toString() || '0'),
pending_commission: parseFloat(partner.getPendingCommission()?.toString() || '0'),
total_signups: partner.total_signups || 0, // ✅ Already number
}))
})
Frontend Defensive Fix (Backup):
// app/admin/partners/page.tsx
<div>
<p>Revenue</p>
<p>${(Number(partner.total_revenue) || 0).toFixed(2)}</p> {/* ✅ Convert first */}
</div>
// Or with type checking
<div>
<p>Revenue</p>
<p>${(typeof partner.total_revenue === 'string'
? parseFloat(partner.total_revenue)
: partner.total_revenue || 0).toFixed(2)}</p>
</div>
Lessons Learned:
- Convert DECIMAL to numbers in backend - single source of truth
- Never assume database types match TypeScript types
- Test with real database data - mock data won't reveal string types
- Add defensive frontend parsing as backup layer
- Document type conversions in API documentation
🟡 INCIDENT 3: Null/Undefined Fields Causing Rendering Crashes
Historical Pattern: Database returns null for optional fields, frontend doesn't handle gracefully
Symptoms:
TypeError: Cannot read property 'name' of null
TypeError: partner.promo_codes.map is not a function // null.map()
Root Cause:
- Database allows NULL for optional foreign keys
- Backend returns
nullinstead of empty arrays/default values - Frontend assumes all fields exist
Red Flags to Scan For:
- Database schema with nullable columns
- Backend not providing defaults for null values
- Frontend using
.map(),.filter(),.lengthwithout null checks - Optional chaining (
?.) used inconsistently
Fix Template:
Backend (Data Normalization):
// backend/src/controllers/partner.controller.ts
res.status(200).json({
partners: partners.map((partner) => ({
id: partner.id,
name: partner.name || 'Unknown', // ✅ Default string
total_signups: partner.total_signups || 0, // ✅ Default number
promo_codes: partner.promo_codes?.map(c => c.code) || [], // ✅ Default array
conversion_rate: partner.getConversionRate()?.toFixed(2) + '%' || '0%', // ✅ Safe call
}))
})
Frontend (Defensive Rendering):
// app/admin/partners/page.tsx
{partners.map((partner) => (
<div key={partner.id}>
<h3>{partner.name || 'Unknown Partner'}</h3>
<p>Signups: {partner.total_signups || 0}</p>
<p>Conversions: {partner.total_conversions || 0}</p>
<p>Rate: {partner.conversion_rate || '0%'}</p>
{/* Array handling */}
{(partner.promo_codes || []).length > 0 && (
<div>
{partner.promo_codes.map(code => <span key={code}>{code}</span>)}
</div>
)}
</div>
))}
Lessons Learned:
- Normalize data in backend - frontend should receive clean data
- Use
|| defaultValuepattern for primitives (strings, numbers) - Use
|| []pattern for arrays before.map() - Use optional chaining (
?.) for nested objects - Provide TypeScript types that reflect actual nullability
Systematic Scan Checklist
When implementing new backend-to-frontend data flow, validate ALL these checkpoints:
1. API Client Architecture
- Does project use a custom API client wrapper? (Check
lib/api.ts,services/api.ts) - Does wrapper unwrap
response.dataautomatically? - Are all frontend calls using correct access pattern?
- Is response structure documented?
2. Backend Data Types
- Identify all DECIMAL/FLOAT/NUMERIC database columns
- Check if backend converts them to actual numbers
- Verify boolean columns return
true/false(not1/0) - Ensure date columns are ISO strings or proper Date objects
3. Null Safety
- List all nullable database columns
- Verify backend provides defaults for null values
- Check frontend uses null-safe operators (
?.,||) - Ensure arrays default to
[]notnull
4. Type Consistency
- TypeScript interfaces match actual API response structure
- No
anytypes in API response handling - Frontend types match backend response types
- Shared type definitions if using monorepo
5. Error Handling
- Frontend catches API errors gracefully
- Loading states prevent rendering undefined data
- Empty states show when data is
[]vs error vs loading - Console errors provide actionable debugging info
6. Testing Strategy
- Test with real database data (not just mocks)
- Test with null/empty database values
- Test with edge cases (empty arrays, zero numbers)
- Console logging removed or behind debug flag
Auto-Scan Report Template
When user asks "why isn't my data showing?" or you detect integration issues:
═══════════════════════════════════════════════
🔗 FULL-STACK INTEGRATION SCAN
═══════════════════════════════════════════════
📊 ANALYSIS SCOPE
• API Endpoint: /api/partners/admin/all
• Frontend: app/admin/partners/page.tsx
• Backend: backend/src/controllers/partner.controller.ts
• Database: partners table (MySQL)
🚨 CRITICAL ISSUES FOUND: [count]
❌ ISSUE 1: API Response Access Pattern Mismatch
File: app/admin/partners/page.tsx:113
Problem: Accessing response.data.partners but API wrapper unwraps response
Evidence:
console.log(response) // {partners: Array(4)}
console.log(response.data) // undefined
Impact: Frontend receives data but code treats as undefined
Fix:
- BEFORE: setPartners(partnersResponse.data.partners || [])
- AFTER: setPartners(response.partners || [])
Affected Lines: page.tsx:113-123
❌ ISSUE 2: DECIMAL Type Returned as String
File: backend/src/controllers/partner.controller.ts:368
Problem: total_revenue_generated is DECIMAL, returns as "0.00" string
Evidence: Frontend calls .toFixed() causing TypeError
Database Schema:
total_revenue_generated DECIMAL(10,2) // ⚠️ Returns string!
Fix:
- Add: parseFloat(partner.total_revenue_generated?.toString() || '0')
- Also fix: total_commission_earned, pending_commission
Affected Fields: 3 DECIMAL columns
⚠️ WARNING: Null Safety Issues
• promo_codes can be null, frontend uses .map()
• conversion_rate calculation not null-safe
• Recommendation: Add || [] for arrays, || 0 for numbers
💡 OPTIMIZATIONS:
• Add TypeScript interfaces for API responses
• Create shared types between frontend/backend
• Add API response logging in development
• Document API client wrapper behavior
═══════════════════════════════════════════════
INTEGRATION HEALTH: 6/10
═══════════════════════════════════════════════
🎯 ACTION PLAN:
1. Fix API response access pattern (5 min)
2. Convert DECIMAL types to numbers in backend (10 min)
3. Add null safety to array/number fields (15 min)
4. Remove debug console.log statements (5 min)
5. Test with real database edge cases
Estimated Fix Time: 35 minutes
Risk if not fixed: HIGH (users see blank pages)
═══════════════════════════════════════════════
Prevention Strategies
For New API Endpoints
Backend Checklist:
// 1. Convert DECIMAL to numbers
const response = {
revenue: parseFloat(record.revenue?.toString() || '0'),
commission: parseFloat(record.commission?.toString() || '0'),
}
// 2. Provide defaults for nulls
const response = {
name: record.name || 'Unknown',
tags: record.tags || [],
metadata: record.metadata || {},
}
// 3. Ensure consistent date format
const response = {
created_at: record.created_at?.toISOString(), // ISO 8601
}
// 4. Document response structure
/**
* GET /api/partners/admin/all
* Returns: { partners: Partner[] }
* Partner shape: { id, name, total_revenue: number, ... }
*/
Frontend Checklist:
// 1. Check API wrapper pattern
const response = await api.get('/endpoint')
console.log('[Debug] Response structure:', response) // Temporarily
// 2. Use null-safe access
if (response?.items) {
setItems(response.items)
}
// 3. Provide defaults
setRevenue((response.revenue || 0).toFixed(2))
setTags(response.tags || [])
// 4. Type the response
interface PartnerResponse {
partners: Array<{
id: string
name: string
total_revenue: number // Not string!
promo_codes: string[] // Not null!
}>
}
const response = await api.get<PartnerResponse>('/partners/admin/all')
Quick Diagnostic Commands
# 1. Check API wrapper implementation
cat lib/api.ts | grep "response.data"
# 2. Find DECIMAL columns in models
grep -r "DECIMAL\|FLOAT\|NUMERIC" backend/src/models/
# 3. Find .toFixed() usage in frontend
grep -r "\.toFixed\|\.toLocaleString" app/ components/
# 4. Check for unsafe array access
grep -r "\.map\|\.filter" app/ | grep -v "|| \[\]"
# 5. Test API response structure
curl http://localhost:3006/api/endpoint | jq .
Cross-Skill Integration
When Database Migration Guardian is active:
- Validate that DECIMAL columns are handled in API layer
- Flag nullable columns that need defaults in backend
When React/Next.js Guardian is active:
- Ensure components handle loading/error/empty states
- Validate TypeScript types match runtime data
When API Endpoint Guardian is active:
- Verify consistent response structure across endpoints
- Check error responses return expected format
Key Principles
- Backend owns data normalization - convert types, provide defaults
- Frontend defends against unexpected data - null checks, type guards
- Always test with real database data - mocks hide type mismatches
- Document API wrapper behavior - prevent response.data confusion
- Use TypeScript strictly - types should reflect reality
- Log response structure during development - catch issues early
Incident Response Protocol
When user reports "data not showing":
- Verify API call succeeds (check Network tab, backend logs)
- Log actual response structure (console.log full response)
- Check API wrapper implementation (does it unwrap response.data?)
- Identify type mismatches (DECIMAL as string, null as number)
- Apply fixes in order: Backend normalization → Frontend defense
- Remove debug logging after fix confirmed
Response Time Target: 30-45 minutes from report to fix
Success Metrics
✅ Integration is healthy when:
- API calls return HTTP 200 AND data displays in UI
- No "Cannot read property" errors in console
- No TypeError for .toFixed(), .map(), etc.
- Real database nulls/zeros handled gracefully
- Frontend types match backend response structure
- Debug logging present only in development
❌ Integration needs attention when:
- Successful API calls but blank UI
- Console shows data structure but code accesses wrong path
- Type errors on number methods (.toFixed, Math operations)
- Crashes when database returns null
- TypeScript types don't match runtime data
🔴 INCIDENT 4: Double API Prefix Bug (Nov 2024)
Real Production Incident: Partner Applications page showed "No pending applications found" despite database having 10 applications.
Symptoms:
// Frontend successfully called API
const response = await api.get('/api/partner-applications?status=pending')
// Backend logs showed ZERO incoming requests
// Network tab would show 404 error (if checked)
// Frontend showed empty state with no errors
Root Cause:
// lib/api.ts - Custom API wrapper
class ApiClient {
async get(endpoint: string): Promise<any> {
const response = await fetch(`${this.baseUrl}/api${endpoint}`, {
// ↑ Already adds /api prefix!
})
return await response.json()
}
}
// app/admin/partner-applications/page.tsx - Component
const response = await api.get(`/api/partner-applications?status=${filter}`)
↑ Should NOT have /api here!
// Resulted in: http://localhost:3006/api/api/partner-applications → 404
Investigation Breakthrough:
- ✅ Verified database has 10 applications
- ✅ Backend API endpoint exists at
/api/partner-applications - ❌ Backend logs showed ZERO API requests ← Critical clue!
- 🔍 This meant frontend wasn't calling API correctly
- 🎯 Analyzed API wrapper → Found automatic
/apiprefix
Fix:
// BEFORE (404 error)
const response = await api.get('/api/partner-applications?status=pending')
await api.post('/api/partner-applications/approve', body)
// AFTER (Success)
const response = await api.get('/partner-applications?status=pending')
await api.post('/partner-applications/approve', body)
Red Flags to Scan For:
- Backend logs show no API requests despite frontend making calls
- Network tab shows 404 for
/api/api/...double prefix - Custom API wrapper adds URL prefixes automatically
- Inconsistent endpoint patterns across codebase
- No errors in console but data never loads
Prevention:
// Document API wrapper behavior clearly
/**
* API Client Wrapper
*
* IMPORTANT: This wrapper automatically adds '/api' prefix to all endpoints.
*
* ✅ CORRECT USAGE:
* api.get('/users') → GET /api/users
* api.post('/login', data) → POST /api/login
*
* ❌ WRONG USAGE:
* api.get('/api/users') → GET /api/api/users (404)
* api.post('/api/login') → POST /api/api/login (404)
*/
class ApiClient {
private baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3006'
async get(endpoint: string): Promise<any> {
// Ensure endpoint doesn't start with /api to prevent double prefix
const cleanEndpoint = endpoint.startsWith('/api/')
? endpoint.replace('/api/', '/')
: endpoint
const response = await fetch(`${this.baseUrl}/api${cleanEndpoint}`, {
headers: { 'Authorization': `Bearer ${token}` }
})
return await response.json()
}
}
Debugging Checklist:
# 1. Check backend logs first!
tail -f backend.log | grep "GET /api/"
# 2. If no logs, frontend isn't calling API
# Check API wrapper implementation
cat lib/api.ts | grep "baseUrl\|fetch"
# 3. Search for all API calls
grep -r "api.get\|api.post" app/ components/
# 4. Verify no double /api/ prefix
grep -r "api.get('/api/" app/ components/
Lessons Learned:
- Backend logs are the first diagnostic tool - No logs = frontend bug
- API wrappers are abstraction leaks - Must understand wrapper behavior
- Double prefix is a common pattern - Check wrapper + call site
- Document wrapper behavior in JSDoc - Prevent future misuse
- Add URL validation in wrapper - Auto-strip duplicate prefixes
Time to Fix: 15 minutes (once backend logs revealed no requests)
🔴 INCIDENT 5: Component Library Selector Mismatch (Nov 2024)
Real E2E Test Failure: Playwright test looked for select[name="tier"] but element was never found, causing timeout.
Symptoms:
// Test code
await page.waitForSelector('select[name="tier"]', { timeout: 10000 })
// TimeoutError: Timeout 10000ms exceeded waiting for selector
// Error context showed combobox role, not select element
// Screenshot showed approval dialog with tier dropdown visible
Root Cause:
// Component uses Shadcn UI Select (not native HTML select)
<Select name="tier" value={formData.tier}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="silver">Silver</SelectItem>
</SelectContent>
</Select>
// Renders as:
<button role="combobox" aria-label="Partner Tier">Silver</button>
<div role="listbox" hidden>
<div role="option" data-value="silver">Silver</div>
</div>
// NOT:
<select name="tier">
<option value="silver">Silver</option>
</select>
Investigation Method:
- ✅ Test opened approval dialog successfully
- ✅ Screenshot showed tier field visible
- ❌
error-context.mdshowed combobox role, not select - 🎯 Realized Shadcn components use ARIA roles
Fix:
// BEFORE (Native HTML selector)
await page.waitForSelector('select[name="tier"]')
await page.selectOption('select[name="tier"]', 'silver')
// AFTER (ARIA role selector)
const tierCombobox = page.getByRole('combobox', { name: /partner tier/i })
await tierCombobox.waitFor({ state: 'visible', timeout: 10000 })
await tierCombobox.click()
await page.waitForSelector('[role="listbox"]', { timeout: 5000 })
await page.getByRole('option', { name: /silver/i }).click()
Red Flags to Scan For:
- Using Shadcn, Radix, or similar component libraries
- Tests using element selectors (
select,input,button) - Tests timing out despite element visible in screenshots
- Error context shows
role="combobox"orrole="listbox" - Component library documentation mentions "accessible components"
Component Library Selector Guide:
| Component | Native HTML | Shadcn/Radix Renders As | Test Selector |
|---|---|---|---|
| Select | <select> |
<button role="combobox"> |
getByRole('combobox') |
| Checkbox | <input type="checkbox"> |
<button role="checkbox"> |
getByRole('checkbox') |
| Radio | <input type="radio"> |
<button role="radio"> |
getByRole('radio') |
| Dialog | <div> |
<div role="dialog"> |
getByRole('dialog') |
| Switch | <input type="checkbox"> |
<button role="switch"> |
getByRole('switch') |
Testing Pattern:
// ✅ GOOD - Semantic, accessible, component-agnostic
await page.getByRole('combobox', { name: /tier/i }).click()
await page.getByRole('option', { name: /silver/i }).click()
await page.getByRole('button', { name: /approve/i }).click()
// ❌ BAD - Brittle, implementation-specific
await page.locator('select[name="tier"]').selectOption('silver')
await page.locator('button.btn-primary').click()
await page.locator('#approve-button').click()
Debugging Checklist:
# 1. Check component library usage
grep -r "from '@radix-ui\|from 'shadcn'" components/
# 2. Find tests using element selectors
grep -r "select\[name=\|input\[name=" e2e/ tests/
# 3. Check error-context.md for actual HTML
cat test-results/*/error-context.md | grep "role="
# 4. Verify component renders with ARIA roles
grep -r "role=\"combobox\|role=\"listbox\"" node_modules/@radix-ui/
Prevention:
// Add component testing documentation
/**
* SHADCN UI TESTING GUIDE
*
* All Shadcn components render with ARIA roles for accessibility.
* Use role-based selectors, not element selectors.
*
* Select Component:
* ✅ page.getByRole('combobox', { name: /tier/i })
* ❌ page.locator('select[name="tier"]')
*
* Dialog Component:
* ✅ page.getByRole('dialog', { name: /approve/i })
* ❌ page.locator('.modal-dialog')
*
* Button Component:
* ✅ page.getByRole('button', { name: /submit/i })
* ✅ page.locator('button:has-text("Submit")') // Also works
* ❌ page.locator('#submit-btn')
*/
Lessons Learned:
- Component libraries render ARIA roles, not native elements
- Use
getByRole()for better test resilience - Check error-context.md for actual HTML structure
- Semantic selectors are more maintainable than CSS selectors
- Read component library docs for rendered HTML
Time to Fix: 10 minutes (once error-context.md revealed combobox role)
Updated Investigation Methodology (Nov 2024)
Based on recent production incidents, follow this exact order when debugging "data not showing":
1. Backend Logs First 🚨 CRITICAL
# Check if API requests are reaching backend
tail -f backend.log | grep "/api/endpoint"
# If NO LOGS → Frontend bug (not calling API correctly)
# If LOGS → Backend bug (API called but not working)
This single check saves 50% of debugging time.
2. Database Verification
-- Verify data exists
SELECT COUNT(*) FROM table_name WHERE status = 'pending';
-- If 0 rows → Data problem
-- If >0 rows → Integration problem
3. API Wrapper Analysis
// Read API client implementation
// Check:
// - Does it add /api prefix?
// - Does it unwrap response.data?
// - What headers does it add?
// - How does it handle errors?
4. Frontend Response Handling
// Add temporary debug logging
const response = await api.get('/endpoint')
console.log('[DEBUG] Full response:', response)
console.log('[DEBUG] response.data:', response.data)
console.log('[DEBUG] Expected data:', response.items || response.data?.items)
5. Skills Pattern Matching
- Check if issue matches known incidents
- Apply fix templates if pattern matches
- Document new patterns if not in skill
6. Component/Test Validation
# If E2E test failing:
# 1. Check test screenshots
# 2. Read error-context.md for actual HTML
# 3. Verify selectors match rendered elements
# 4. Use role-based selectors for component libraries
Estimated Debug Time by Following This Order:
- Double API prefix: 15 minutes
- Response access pattern: 10 minutes
- Component selector: 10 minutes
- Database types: 20 minutes
- Null safety: 15 minutes
Total saved vs. random debugging: 2-3 hours